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:
@@ -96,6 +96,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route("/folders", get(index_jobs::list_folders))
|
.route("/folders", get(index_jobs::list_folders))
|
||||||
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
.route("/admin/tokens", get(tokens::list_tokens).post(tokens::create_token))
|
||||||
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
.route("/admin/tokens/:id", delete(tokens::revoke_token))
|
||||||
|
.route("/admin/tokens/:id/delete", axum::routing::post(tokens::delete_token))
|
||||||
.merge(settings::settings_routes())
|
.merge(settings::settings_routes())
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ use utoipa::OpenApi;
|
|||||||
crate::tokens::list_tokens,
|
crate::tokens::list_tokens,
|
||||||
crate::tokens::create_token,
|
crate::tokens::create_token,
|
||||||
crate::tokens::revoke_token,
|
crate::tokens::revoke_token,
|
||||||
|
crate::tokens::delete_token,
|
||||||
crate::settings::get_settings,
|
crate::settings::get_settings,
|
||||||
crate::settings::get_setting,
|
crate::settings::get_setting,
|
||||||
crate::settings::update_setting,
|
crate::settings::update_setting,
|
||||||
|
|||||||
@@ -170,3 +170,35 @@ pub async fn revoke_token(
|
|||||||
|
|
||||||
Ok(Json(serde_json::json!({"revoked": true, "id": id})))
|
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})))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
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";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, Badge, FormField, FormInput, FormSelect, FormRow } from "../components/ui";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -31,6 +31,13 @@ export default async function TokensPage({
|
|||||||
revalidatePath("/tokens");
|
revalidatePath("/tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteTokenAction(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
await deleteToken(id);
|
||||||
|
revalidatePath("/tokens");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -109,7 +116,7 @@ export default async function TokensPage({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{!token.revoked_at && (
|
{!token.revoked_at ? (
|
||||||
<form action={revokeTokenAction}>
|
<form action={revokeTokenAction}>
|
||||||
<input type="hidden" name="id" value={token.id} />
|
<input type="hidden" name="id" value={token.id} />
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
@@ -119,6 +126,16 @@ export default async function TokensPage({
|
|||||||
Revoke
|
Revoke
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -254,6 +254,10 @@ export async function revokeToken(id: string) {
|
|||||||
return apiFetch<void>(`/admin/tokens/${id}`, { method: "DELETE" });
|
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(
|
export async function fetchBooks(
|
||||||
libraryId?: string,
|
libraryId?: string,
|
||||||
series?: string,
|
series?: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user