From 03af82d065ca990730688b3bdfa11fbbf6a3aa47 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sun, 15 Mar 2026 15:19:44 +0100 Subject: [PATCH] feat(tokens): allow permanent deletion of revoked tokens Add POST /admin/tokens/{id}/delete endpoint that permanently removes a token from the database (only if already revoked). Add delete button in backoffice UI for revoked tokens. Co-Authored-By: Claude Opus 4.6 --- apps/api/src/main.rs | 1 + apps/api/src/openapi.rs | 1 + apps/api/src/tokens.rs | 32 +++++++++++++++++++++++++++++ apps/backoffice/app/tokens/page.tsx | 21 +++++++++++++++++-- apps/backoffice/lib/api.ts | 4 ++++ 5 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 2fdf673..1baa62b 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -96,6 +96,7 @@ async fn main() -> anyhow::Result<()> { .route("/folders", get(index_jobs::list_folders)) .route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token)) .route("/admin/tokens/:id", delete(tokens::revoke_token)) + .route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token)) .merge(settings::settings_routes()) .route_layer(middleware::from_fn_with_state( state.clone(), diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index c10133c..6126e06 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -31,6 +31,7 @@ use utoipa::OpenApi; crate::tokens::list_tokens, crate::tokens::create_token, crate::tokens::revoke_token, + crate::tokens::delete_token, crate::settings::get_settings, crate::settings::get_setting, crate::settings::update_setting, diff --git a/apps/api/src/tokens.rs b/apps/api/src/tokens.rs index 735538e..6adbd2e 100644 --- a/apps/api/src/tokens.rs +++ b/apps/api/src/tokens.rs @@ -170,3 +170,35 @@ pub async fn revoke_token( Ok(Json(serde_json::json!({"revoked": true, "id": id}))) } + +/// Permanently delete a revoked API token +#[utoipa::path( + post, + path = "/admin/tokens/{id}/delete", + tag = "tokens", + params( + ("id" = String, Path, description = "Token UUID"), + ), + responses( + (status = 200, description = "Token permanently deleted"), + (status = 404, description = "Token not found or not revoked"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - Admin scope required"), + ), + security(("Bearer" = [])) +)] +pub async fn delete_token( + State(state): State, + Path(id): Path, +) -> Result, ApiError> { + let result = sqlx::query("DELETE FROM api_tokens WHERE id = $1 AND revoked_at IS NOT NULL") + .bind(id) + .execute(&state.pool) + .await?; + + if result.rows_affected() == 0 { + return Err(ApiError::not_found("token not found or not revoked")); + } + + Ok(Json(serde_json::json!({"deleted": true, "id": id}))) +} diff --git a/apps/backoffice/app/tokens/page.tsx b/apps/backoffice/app/tokens/page.tsx index e483061..961071c 100644 --- a/apps/backoffice/app/tokens/page.tsx +++ b/apps/backoffice/app/tokens/page.tsx @@ -1,6 +1,6 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import { listTokens, createToken, revokeToken, TokenDto } from "../../lib/api"; +import { listTokens, createToken, revokeToken, deleteToken, TokenDto } from "../../lib/api"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui"; export const dynamic = "force-dynamic"; @@ -31,6 +31,13 @@ export default async function TokensPage({ revalidatePath("/tokens"); } + async function deleteTokenAction(formData: FormData) { + "use server"; + const id = formData.get("id") as string; + await deleteToken(id); + revalidatePath("/tokens"); + } + return ( <>
@@ -109,7 +116,7 @@ export default async function TokensPage({ )} - {!token.revoked_at && ( + {!token.revoked_at ? (
+ ) : ( +
+ + +
)} diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 5358f09..f0f0469 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -254,6 +254,10 @@ export async function revokeToken(id: string) { return apiFetch(`/admin/tokens/${id}`, { method: "DELETE" }); } +export async function deleteToken(id: string) { + return apiFetch(`/admin/tokens/${id}/delete`, { method: "POST" }); +} + export async function fetchBooks( libraryId?: string, series?: string,