Files
HealthLog/src/components/admin/encryption-section.tsx
T
Marc-André Bombeck ba0cd63cf5 refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the
`settings` and `admin` namespaces, median 83 characters, 113 of them two
sentences or more. The rule the design standards now carry is one sentence,
guideline 120 characters, and this is the sweep that makes it true.

Almost nothing was retranslated. A second sentence is already written in
every language — it is just in the wrong slot. `scripts/split-description.mjs`
cuts a description at its first sentence boundary in all six bundles at once
and either drops the tail or files it under its own key; the twenty-one keys
that moved now render in the card body as foreground `text-sm`, which is
where multi-sentence prose belongs and where a reader can actually read it
(the slot is muted `text-xs`). Eight tails were dropped outright, because
"Off by default" under a switch, "Topics and frequency are adjustable" above
the topic switches, and "Only admins can create invitations" on an
admin-gated route are not information.

Twenty-eight keys are gone: the privacy page's intro, which was a table of
contents for the four cards under it; two About descriptions that listed
verbatim the labels and the buttons beneath them; a card description that
repeated its own title; the API-token sentence that said what the paragraph
below it said with the actual instruction; and the eight integration
secondary sentences. Six descriptions were rewritten shorter in all six
languages where trimming was honest rather than lossy.

On the strings that changed: 4391 characters of English down to 2290, and 32
multi-sentence descriptions down to zero.

The rule is now a gate. `card-description-length.test.ts` reads the keys that
actually reach a card header's description slot — statically, from the JSX —
and checks them in EN and DE, because a rule that only holds in English
produces a slot that wraps to two lines in half the locales. Its exception
list is empty and carries a reason requirement plus a test that removes a
stale entry. Verified by putting a second sentence back: it fails.
2026-08-07 20:53:05 +02:00

310 lines
11 KiB
TypeScript

"use client";
/**
* `<EncryptionSection>` — 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 { SettingsCardActions } from "@/components/settings/_card-actions";
import { StatTile } from "@/components/admin/_stat-tile";
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<string, number>;
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<EncryptionStatus> => {
return apiGet<EncryptionStatus>("/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"));
},
});
// The card + header stay rendered across every query state so the heading
// keeps a constant Y-offset — the loading branch used to return a bare
// spinner row and the error branch a bare paragraph, so the section jumped
// downward the moment the status resolved. Same shape as
// `coach-feedback-section.tsx`, which fixed this for itself and never had
// the fix carried across.
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<p className="text-sm">
{t("admin.section.encryption.coverageDetail")}
</p>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
);
}
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 (
<div className="space-y-6">
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
<div className="grid gap-3 sm:grid-cols-2">
<Stat
label={t("admin.section.encryption.activeKeyId")}
value={s.activeKeyId}
/>
<Stat
label={t("admin.section.encryption.configuredKeys")}
value={String(s.configuredKeyCount)}
/>
<Stat
label={t("admin.section.encryption.totalRows")}
value={s.totalRows.toLocaleString()}
/>
<Stat
label={t("admin.section.encryption.staleRows")}
value={s.staleRows.toLocaleString()}
/>
</div>
{s.rotationComplete ? (
<Badge className="border-success/40 bg-success/15 text-success">
{t("admin.section.encryption.safeToDropLegacy")}
</Badge>
) : (
<Badge variant="secondary">
{t("admin.section.encryption.rotationIncomplete", {
count: s.staleRows,
})}
</Badge>
)}
</SettingsCard>
{/* ── Rotation status + trigger ──────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.section.encryption.rotationTitle")}
description={t("admin.section.encryption.rotationDescription")}
/>
<div className="text-muted-foreground space-y-1 text-sm">
<p>
{t("admin.section.encryption.rotationState")}:{" "}
<span className="text-foreground font-medium">
{t(`admin.section.encryption.state.${s.rotation.state}`)}
</span>
</p>
{s.rotation.lastResult && (
<p>
{t("admin.section.encryption.lastRun", {
scanned: s.rotation.lastResult.scanned,
rotated: s.rotation.lastResult.rotated,
errors: s.rotation.lastResult.errors,
})}
</p>
)}
</div>
<p className="text-muted-foreground text-xs">
{t("admin.section.encryption.cliNote")}
</p>
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
{/* ── Per-column coverage table ──────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.columnsTitle")}
description={t("admin.section.encryption.columnsDescription")}
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody>
{columns.map((c) => {
const active = c.byKeyId[s.activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">{c.total.toLocaleString()}</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</SettingsCard>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return <StatTile label={label} value={value} valueClassName="font-mono" />;
}