merge: integrate the v1.23 data export and encryption-status wave

This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:46:08 +02:00
commit 268c5cef05
29 files changed
+2346 -125

No files matched your search

+7
View File
@@ -30,6 +30,7 @@ import {
Server,
Settings,
ShieldAlert,
ShieldCheck,
Sparkles,
Ticket,
Users,
@@ -131,6 +132,12 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.backups.title",
icon: Database,
},
// v1.23 — encryption coverage + key-rotation status / safe trigger.
{
slug: "encryption",
titleKey: "admin.section.encryption.title",
icon: ShieldCheck,
},
{
slug: "danger-zone",
titleKey: "admin.section.danger-zone.title",
+295
View File
@@ -0,0 +1,295 @@
"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 { 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"));
},
});
if (statusQuery.isLoading) {
return (
<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>
);
}
if (statusQuery.isError || !statusQuery.data) {
return (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
);
}
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 className="space-y-4">
<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="bg-emerald-600 text-white hover:bg-emerald-600">
{t("admin.section.encryption.safeToDropLegacy")}
</Badge>
) : (
<Badge variant="secondary">
{t("admin.section.encryption.rotationIncomplete", {
count: s.staleRows,
})}
</Badge>
)}
</SettingsCard>
{/* ── Rotation status + trigger ──────────────────────────────── */}
<SettingsCard className="space-y-4">
<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>
<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>
<p className="text-muted-foreground text-xs">
{t("admin.section.encryption.cliNote")}
</p>
</SettingsCard>
{/* ── Per-column coverage table ──────────────────────────────── */}
<SettingsCard className="space-y-3">
<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 (
<div className="bg-muted/40 rounded-lg px-3 py-2">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="text-foreground font-mono text-sm font-medium">{value}</p>
</div>
);
}
+4
View File
@@ -37,6 +37,10 @@ export const ADMIN_SECTION_SLUGS = [
"login-overview",
"app-logs",
"backups",
// v1.23 — encryption coverage + key-rotation status / safe trigger. Sits
// next to Backups (both are data-at-rest concerns) and just before the
// danger zone.
"encryption",
"danger-zone",
// v1.4.36 W4e — About / version / update check folded into Admin
// Console. Lives at /admin/about; the legacy /settings/about route
+79 -5
View File
@@ -38,6 +38,7 @@ import {
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -412,20 +413,55 @@ function MoodCsvCard() {
);
}
const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
function FullBackupCard() {
const { t } = useTranslations();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// v1.23 — opt-in passphrase encryption. When on, the download POSTs to
// /api/export/encrypted with the passphrase and saves an opaque `.hlx`
// archive instead of plaintext JSON.
const [encrypt, setEncrypt] = useState(false);
const [passphrase, setPassphrase] = useState("");
const passphraseTooShort =
encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH;
async function handleDownload() {
setBusy(true);
setError(null);
try {
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
if (encrypt) {
const res = await apiFetchRaw("/api/export/encrypted", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
});
if (res.status === 401) {
setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded"));
return;
}
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = `healthlog-backup-${stamp}.hlx`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} else {
await downloadFromUrl(
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
}
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
@@ -447,7 +483,7 @@ function FullBackupCard() {
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
disabled={busy}
disabled={busy || passphraseTooShort}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
@@ -458,6 +494,44 @@ function FullBackupCard() {
</Button>
}
>
<label
htmlFor="export-full-backup-encrypt"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
>
<Switch
id="export-full-backup-encrypt"
data-testid="export-full-backup-encrypt"
checked={encrypt}
onCheckedChange={(v) => {
setEncrypt(v);
setError(null);
if (!v) setPassphrase("");
}}
/>
<span className="text-muted-foreground">
{t("settings.sections.export.cards.fullBackup.encryptToggle")}
</span>
</label>
{encrypt && (
<div className="space-y-2">
<PasswordInput
data-testid="export-full-backup-passphrase"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t(
"settings.sections.export.cards.fullBackup.passphrasePlaceholder",
)}
autoComplete="new-password"
className="h-9 text-sm"
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
</p>
<p className="text-xs text-amber-600 dark:text-amber-500">
{t("settings.sections.export.cards.fullBackup.noRecoveryWarning")}
</p>
</div>
)}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}