mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
merge: integrate the v1.23 data export and encryption-status wave
This commit is contained in:
commit
268c5cef05
29 files changed
+2346
-125
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user