"use client"; /** * `` — admin view of encryption coverage + key-rotation * status, with a guarded rotation trigger. * * Reads `GET /api/admin/encryption/status` (per-column rows-per-key-id, legacy * counts, overall rotation progress). NEVER surfaces key material — only key * ids (operator labels) and row counts. The rotation trigger POSTs to * `/api/admin/encryption/rotate`, which is step-up gated server-side; a 401 * here means the admin needs a fresh second factor (or has none enrolled, in * which case the documented CLI is the path). */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { KeyRound, Loader2, RotateCw, ShieldCheck } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { getApiErrorMessage } from "./_shared"; interface ColumnScan { model: string; field: string; kind: "string" | "bytes"; total: number; byKeyId: Record; legacy: number; } interface EncryptionStatus { activeKeyId: string; configuredKeyCount: number; rotationComplete: boolean; totalRows: number; activeRows: number; staleRows: number; columns: ColumnScan[]; rotation: { state: "idle" | "running" | "completed" | "failed"; lastRequestedAt: string | null; lastCompletedAt: string | null; lastResult: { scanned: number; rotated: number; errors: number } | null; }; } export function EncryptionSection() { const { t } = useTranslations(); const queryClient = useQueryClient(); const statusQuery = useQuery({ queryKey: queryKeys.adminEncryptionStatus(), queryFn: async (): Promise => { return apiGet("/api/admin/encryption/status", { credentials: "include", }); }, staleTime: 15_000, }); const rotate = useMutation({ mutationFn: async () => { const res = await apiFetchRaw("/api/admin/encryption/rotate", { method: "POST", credentials: "include", }); if (!res.ok) { if (res.status === 401) { throw new Error(t("admin.section.encryption.stepUpNeeded")); } throw new Error(await getApiErrorMessage(res)); } return res.json(); }, onSuccess: () => { toast.success(t("admin.section.encryption.rotateEnqueued")); queryClient.invalidateQueries({ queryKey: queryKeys.adminEncryptionStatus(), }); }, onError: (err: Error) => { toast.error(err.message || t("admin.section.encryption.rotateFailed")); }, }); if (statusQuery.isLoading) { return (
{t("admin.section.encryption.loading")}
); } if (statusQuery.isError || !statusQuery.data) { return (

{t("admin.section.encryption.loadError")}

); } const s = statusQuery.data; const running = s.rotation.state === "running" || rotate.isPending; // Only the rows that share a coverage view need the per-column table; sort // stale-first so an operator sees what still needs rotating. const columns = [...s.columns].sort((a, b) => b.legacy - a.legacy); return (
{/* ── Coverage summary ───────────────────────────────────────── */}
{s.rotationComplete ? ( {t("admin.section.encryption.safeToDropLegacy")} ) : ( {t("admin.section.encryption.rotationIncomplete", { count: s.staleRows, })} )}
{/* ── Rotation status + trigger ──────────────────────────────── */}

{t("admin.section.encryption.rotationState")}:{" "} {t(`admin.section.encryption.state.${s.rotation.state}`)}

{s.rotation.lastResult && (

{t("admin.section.encryption.lastRun", { scanned: s.rotation.lastResult.scanned, rotated: s.rotation.lastResult.rotated, errors: s.rotation.lastResult.errors, })}

)}
{t("admin.section.encryption.rotateConfirmTitle")} {t("admin.section.encryption.rotateConfirmDescription")} {t("admin.section.encryption.cancel")} rotate.mutate()}> {t("admin.section.encryption.rotateConfirm")}

{t("admin.section.encryption.cliNote")}

{/* ── Per-column coverage table ──────────────────────────────── */}
{columns.map((c) => { const active = c.byKeyId[s.activeKeyId] ?? 0; const stale = c.total - active; return ( ); })}
{t("admin.section.encryption.colColumn")} {t("admin.section.encryption.colTotal")} {t("admin.section.encryption.colActive")} {t("admin.section.encryption.colStale")}
{c.model}.{c.field} {c.total.toLocaleString()} {active.toLocaleString()} {stale > 0 ? ( {stale.toLocaleString()} ) : ( 0 )}
); } function Stat({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); }