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 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 15:19:44 +01:00
parent 78e28a269d
commit 03af82d065
5 changed files with 57 additions and 2 deletions

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, 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})))
}

View File

@@ -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 (
<>
<div className="mb-6">
@@ -109,7 +116,7 @@ export default async function TokensPage({
)}
</td>
<td className="px-4 py-3">
{!token.revoked_at && (
{!token.revoked_at ? (
<form action={revokeTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
@@ -119,6 +126,16 @@ export default async function TokensPage({
Revoke
</Button>
</form>
) : (
<form action={deleteTokenAction}>
<input type="hidden" name="id" value={token.id} />
<Button type="submit" variant="destructive" size="sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</Button>
</form>
)}
</td>
</tr>

View File

@@ -254,6 +254,10 @@ export async function revokeToken(id: string) {
return apiFetch<void>(`/admin/tokens/${id}`, { method: "DELETE" });
}
export async function deleteToken(id: string) {
return apiFetch<void>(`/admin/tokens/${id}/delete`, { method: "POST" });
}
export async function fetchBooks(
libraryId?: string,
series?: string,