feat(admin): replace the key backup step with a recovery kit

The step confirmed one key, the active application key. A restore needs
more: off-host copies are sealed under BACKUP_ENCRYPTION_KEY, a copy
written before a rotation needs the retired application key it was
written under, and every session, token and share link hangs off
API_TOKEN_HMAC_KEY. An operator could do everything the step asked and
still be unable to read a single backup.

The recovery kit lists every key a restore of this server needs, by id
and fingerprint, never key material:

- each id in ENCRYPTION_KEYS, plus any retired id a stored copy
  (innerKeyIds) or an off-host object inside the bucket's retention
  (OffhostBackupKeyUse) still lists, also once the server dropped it;
- BACKUP_ENCRYPTION_KEY and each BACKUP_ENCRYPTION_PREVIOUS_KEYS entry,
  named by the id their objects record;
- API_TOKEN_HMAC_KEY.

Confirming records the set the admin was shown and refuses a stale one
with 409 recovery.kit.stale. The kit is due again as soon as a key it
lists is not in that set (a rotation, a new backup key, a re-keyed
install); a key that merely drops out does not make it due. "Check my
copy" now checks any key in the kit.

Migration 0487 carries a confirmation given under the old step forward
as a kit holding that key, so that part stays confirmed, and drops the
four app_settings columns. It also creates the two tables the readiness
evaluation uses (restore_drill_outcomes, recovery_readiness_notices).

The routes move to /api/admin/recovery-kit; the reader of the keys the
backups still need moves out of the encryption status route into
src/lib/recovery/backup-key-needs.ts so the kit and the status view
share one definition.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:32:29 +01:00
1 parent 953c64f7a4
commit d3c672cc3f
56 files changed
+3042 -1611

No files matched your search

@@ -20,7 +20,7 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null }));
vi.mock("../recovery-kit-card", () => ({ RecoveryKitCard: () => null }));
import { I18nProvider } from "@/lib/i18n/context";
import { ColumnCoverage, EncryptionSection } from "../encryption-section";
@@ -1,101 +0,0 @@
/**
* The key backup step and its dashboard banner.
*
* 1. the card shows the key id and fingerprint and never anything shaped
* like key material; the platform hint picks the open tab;
* 2. a due step offers the confirmation, a done step does not;
* 3. the banner renders only for an admin and only while the step is due;
* 4. the copy resolves in German too.
*
* Mutation check: drop the `isAdmin` guard in the banner and case 3 goes red;
* render the confirm button unconditionally and case 2 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { KeyBackupCard } from "@/components/admin/key-backup-card";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import type { KeyBackupStatus } from "@/components/admin/use-key-backup-status";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/encryption",
}));
const DUE: KeyBackupStatus = {
due: true,
activeKeyId: "v1",
fingerprint: "a1b2c3d4e5f6",
confirmedAt: null,
confirmedKeyId: null,
confirmedFingerprint: null,
confirmedBy: null,
platformHint: "truenas",
};
const DONE: KeyBackupStatus = {
...DUE,
due: false,
confirmedAt: "2026-10-03T10:00:00.000Z",
confirmedKeyId: "v1",
confirmedFingerprint: "a1b2c3d4e5f6",
confirmedBy: { id: "u1", email: "admin@example.test" },
};
function render(
node: React.ReactNode,
status: KeyBackupStatus | null,
locale: "en" | "de" = "en",
): string {
const client = new QueryClient();
if (status) client.setQueryData(queryKeys.adminEncryptionKeyBackup(), status);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("KeyBackupCard", () => {
it("shows the key id and fingerprint, opens the hinted platform, and no key material", () => {
const html = render(<KeyBackupCard />, DUE);
expect(html).toContain("a1b2c3d4e5f6");
expect(html).toContain(">v1<");
expect(html).toContain("Apps, select HealthLog");
expect(html).not.toMatch(/[0-9a-f]{64}/);
});
it("offers the confirmation only while the step is due", () => {
expect(render(<KeyBackupCard />, DUE)).toContain(
"I have backed up this key",
);
const done = render(<KeyBackupCard />, DONE);
expect(done).not.toContain("I have backed up this key");
expect(done).toContain("admin@example.test");
});
it("speaks German", () => {
expect(render(<KeyBackupCard />, DUE, "de")).toContain(
"Sichere deinen Verschlüsselungsschlüssel",
);
});
});
describe("KeyBackupBanner", () => {
it("renders for an admin while the step is due", () => {
expect(render(<KeyBackupBanner isAdmin />, DUE)).toContain(
'data-testid="key-backup-banner"',
);
});
it("renders nothing for a non-admin, or once the step is done", () => {
expect(render(<KeyBackupBanner isAdmin={false} />, DUE)).toBe("");
expect(render(<KeyBackupBanner isAdmin />, DONE)).toBe("");
});
});
@@ -0,0 +1,168 @@
/**
* The recovery kit card and its dashboard banner.
*
* 1. the card lists every key with its id and fingerprint and never
* anything shaped like key material; the platform hint picks the open
* tab; the restore guide is linked;
* 2. a retired key a backup still needs is listed with why, and a key this
* server does not hold says so;
* 3. a due kit offers the confirmation and names what the confirmation
* does not cover, a confirmed one does not;
* 4. the banner renders only for an admin and only while the kit is due;
* 5. the copy resolves in German too.
*
* Mutation check: drop the `isAdmin` guard in the banner and case 4 goes red;
* render the confirm button unconditionally and case 3 goes red.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
DISASTER_RECOVERY_GUIDE_URL,
RecoveryKitCard,
} from "@/components/admin/recovery-kit-card";
import { RecoveryKitBanner } from "@/components/dashboard/recovery-kit-banner";
import type { KitKey, RecoveryKit } from "@/components/admin/use-recovery-kit";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/encryption",
}));
const need = (over: Partial<KitKey["need"]> = {}): KitKey["need"] => ({
active: false,
configured: true,
storedCopies: 0,
offhostUntil: null,
...over,
});
const KEYS: KitKey[] = [
{
kind: "application",
id: "v1",
fingerprint: null,
source: "ENCRYPTION_KEYS",
need: need({ configured: false, storedCopies: 2 }),
confirmed: false,
problem: "not_configured",
},
{
kind: "application",
id: "v2",
fingerprint: "a1b2c3d4e5f6",
source: "ENCRYPTION_KEYS",
need: need({ active: true }),
confirmed: false,
problem: null,
},
{
kind: "backup",
id: "0f0e0d0c0b0a",
fingerprint: "0f0e0d0c0b0a",
source: "BACKUP_ENCRYPTION_KEY",
need: need({ active: true }),
confirmed: true,
problem: null,
},
{
kind: "token",
id: "API_TOKEN_HMAC_KEY",
fingerprint: "112233445566",
source: "API_TOKEN_HMAC_KEY",
need: need({ active: true }),
confirmed: true,
problem: null,
},
];
const DUE: RecoveryKit = {
keys: KEYS,
due: true,
unconfirmed: [
{ kind: "application", id: "v1" },
{ kind: "application", id: "v2" },
],
confirmedAt: "2026-10-03T10:00:00.000Z",
confirmedBy: null,
platformHint: "truenas",
};
const DONE: RecoveryKit = {
...DUE,
keys: KEYS.map((k) => ({ ...k, confirmed: true })),
due: false,
unconfirmed: [],
confirmedBy: { id: "u1", email: "admin@example.test" },
};
function render(
node: React.ReactNode,
kit: RecoveryKit | null,
locale: "en" | "de" = "en",
): string {
const client = new QueryClient();
if (kit) client.setQueryData(queryKeys.adminRecoveryKit(), kit);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("RecoveryKitCard", () => {
it("lists every key by id and fingerprint, opens the hinted platform, links the guide, and shows no key material", () => {
const html = render(<RecoveryKitCard />, DUE);
for (const k of KEYS) {
expect(html).toContain(
`data-testid="recovery-kit-key-${k.kind}-${k.id}"`,
);
}
expect(html).toContain("a1b2c3d4e5f6");
expect(html).toContain("112233445566");
expect(html).toContain("Apps, select HealthLog");
expect(html).toContain(DISASTER_RECOVERY_GUIDE_URL);
expect(html).not.toMatch(/[0-9a-f]{64}/);
});
it("says why a retired key is listed and that this server does not hold it", () => {
const html = render(<RecoveryKitCard />, DUE);
expect(html).toContain("Needed by 2 stored copies.");
expect(html).toContain("Not on this server");
expect(html).toContain("Application key v1");
});
it("offers the confirmation only while the kit is due, naming what is not covered", () => {
const due = render(<RecoveryKitCard />, DUE);
expect(due).toContain("I have a copy of every key");
expect(due).toContain("v1, v2");
const done = render(<RecoveryKitCard />, DONE);
expect(done).not.toContain("I have a copy of every key");
expect(done).toContain("admin@example.test");
});
it("speaks German", () => {
expect(render(<RecoveryKitCard />, DUE, "de")).toContain(
"Wiederherstellungs-Kit",
);
});
});
describe("RecoveryKitBanner", () => {
it("renders for an admin while the kit is due", () => {
expect(render(<RecoveryKitBanner isAdmin />, DUE)).toContain(
'data-testid="recovery-kit-banner"',
);
});
it("renders nothing for a non-admin, or once the kit is confirmed", () => {
expect(render(<RecoveryKitBanner isAdmin={false} />, DUE)).toBe("");
expect(render(<RecoveryKitBanner isAdmin />, DONE)).toBe("");
});
});
+10 -10
View File
@@ -50,8 +50,8 @@ import {
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { KeyBackupCard } from "./key-backup-card";
import { useKeyBackupStatus } from "./use-key-backup-status";
import { RecoveryKitCard } from "./recovery-kit-card";
import { useRecoveryKit } from "./use-recovery-kit";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { EmptyState } from "@/components/ui/empty-state";
@@ -101,7 +101,7 @@ export function EncryptionSection() {
// Same query as the card's own (deduplicated by key): read here only to
// know when the page can paint the cards below it in one step.
const keyBackup = useKeyBackupStatus();
const recoveryKit = useRecoveryKit();
const statusQuery = useQuery({
queryKey: queryKeys.adminEncryptionStatus(),
queryFn: async (): Promise<EncryptionStatus> => {
@@ -158,14 +158,14 @@ export function EncryptionSection() {
// 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.
// Until both reads land, only the key-backup card paints. It is the first
// Until both reads land, only the recovery kit card paints. It is the first
// card and grows into its loaded shape with nothing under it yet; painting
// the coverage card below it while the backup card was still a one-line
// loader moved that card down the page when the backup status arrived.
if (statusQuery.isLoading || keyBackup.isLoading) {
// the coverage card below it while the kit was still a one-line loader
// moved that card down the page when the kit arrived.
if (statusQuery.isLoading || recoveryKit.isLoading) {
return (
<div className="space-y-6">
<KeyBackupCard />
<RecoveryKitCard />
</div>
);
}
@@ -173,7 +173,7 @@ export function EncryptionSection() {
if (statusQuery.isError || !statusQuery.data) {
return (
<div className="space-y-6">
<KeyBackupCard />
<RecoveryKitCard />
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
@@ -208,7 +208,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
{recentProof.dialog}
<KeyBackupCard />
<RecoveryKitCard />
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard>
<SettingsCardHeader
-316
View File
@@ -1,316 +0,0 @@
"use client";
/**
* `<KeyBackupCard>` — the "Back up your encryption key" step on the admin
* Encryption page.
*
* Shows the active key id and its fingerprint (never the key), where the key
* lives on each platform, what is lost without it, a "Check my copy" field
* and the confirmation. The confirmation is bound to the key id and
* fingerprint shown here: if the server's key changed since the page loaded,
* the server answers 409 and the card reloads.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import {
useKeyBackupStatus,
type KeyBackupPlatform,
type KeyBackupStatus,
} from "./use-key-backup-status";
const PLATFORMS: KeyBackupPlatform[] = [
"compose",
"truenas",
"coolify",
"unraid",
"portainer",
];
/** Literal keys per platform, so the i18n coverage guard sees every one. */
function platformCopy(
t: (key: string) => string,
p: KeyBackupPlatform,
): { label: string; where: string } {
switch (p) {
case "truenas":
return {
label: t("admin.keyBackup.platform.truenasLabel"),
where: t("admin.keyBackup.platform.truenasWhere"),
};
case "coolify":
return {
label: t("admin.keyBackup.platform.coolifyLabel"),
where: t("admin.keyBackup.platform.coolifyWhere"),
};
case "unraid":
return {
label: t("admin.keyBackup.platform.unraidLabel"),
where: t("admin.keyBackup.platform.unraidWhere"),
};
case "portainer":
return {
label: t("admin.keyBackup.platform.portainerLabel"),
where: t("admin.keyBackup.platform.portainerWhere"),
};
case "compose":
return {
label: t("admin.keyBackup.platform.composeLabel"),
where: t("admin.keyBackup.platform.composeWhere"),
};
}
}
export function KeyBackupCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useKeyBackupStatus();
const [candidate, setCandidate] = useState("");
const [checkResult, setCheckResult] = useState<boolean | null>(null);
const invalidate = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.adminEncryptionKeyBackup(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.adminEncryptionStatus(),
}),
]);
const confirm = useMutation({
mutationFn: (s: KeyBackupStatus) =>
apiPost<KeyBackupStatus>(
"/api/admin/encryption/key-backup/confirm",
{ keyId: s.activeKeyId, fingerprint: s.fingerprint },
{ credentials: "include" },
),
onSuccess: async () => {
toast(t("admin.keyBackup.confirmed"));
await invalidate();
},
onError: async (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
showErrorToast(t("admin.keyBackup.stale"), { cause: err });
await invalidate();
return;
}
showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), {
cause: err,
});
},
});
const verify = useMutation({
// Not `apiPost`: no idempotency key, so no cached response row exists
// for a request whose body was a key.
mutationFn: (encryptionKey: string) =>
apiFetch<{ matches: boolean }>(
"/api/admin/encryption/key-backup/verify",
{
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ encryptionKey }),
},
),
onSuccess: (data) => {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
setCheckResult(null);
showErrorToast(err.message || t("admin.keyBackup.checkFailed"), {
cause: err,
});
},
});
const insecure =
typeof window !== "undefined" && window.isSecureContext === false;
if (!status.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.keyBackup.title")}
description={t("admin.keyBackup.description")}
/>
{status.isError ? (
<QueryErrorRow
inset
error={status.error}
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
/>
) : (
<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.keyBackup.loading")}
</div>
)}
</SettingsCard>
);
}
const s = status.data;
return (
<SettingsCard data-testid="key-backup-card">
<SettingsCardHeader
icon={KeyRound}
title={t("admin.keyBackup.title")}
description={t("admin.keyBackup.description")}
status={
s.due ? (
<Badge variant="outline" className="border-warning/40 text-warning">
{t("admin.keyBackup.badgeDue")}
</Badge>
) : (
<Badge variant="outline">{t("admin.keyBackup.badgeDone")}</Badge>
)
}
/>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">
{t("admin.keyBackup.keyId")}
</dt>
<dd className="font-mono">{s.activeKeyId}</dd>
</div>
<div>
<dt className="text-muted-foreground">
{t("admin.keyBackup.fingerprint")}
</dt>
<dd className="font-mono" data-testid="key-backup-fingerprint">
{s.fingerprint}
</dd>
</div>
</dl>
<p className="text-sm">{t("admin.keyBackup.whatIsLost")}</p>
<Tabs defaultValue={s.platformHint}>
<TabsList className="flex-wrap">
{PLATFORMS.map((p) => (
<TabsTrigger key={p} value={p}>
{platformCopy(t, p).label}
</TabsTrigger>
))}
</TabsList>
{PLATFORMS.map((p) => (
<TabsContent key={p} value={p} className="text-sm">
{platformCopy(t, p).where}
</TabsContent>
))}
</Tabs>
<p className="text-muted-foreground text-sm">
{t("admin.keyBackup.fingerprintHint")}
</p>
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (candidate.trim()) verify.mutate(candidate);
}}
>
<Label htmlFor="key-backup-candidate">
{t("admin.keyBackup.checkLabel")}
</Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="key-backup-candidate"
type="password"
autoComplete="off"
spellCheck={false}
value={candidate}
onChange={(e) => {
setCandidate(e.target.value);
setCheckResult(null);
}}
className="font-mono"
/>
<Button
type="submit"
variant="outline"
disabled={!candidate.trim() || verify.isPending}
>
{verify.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.keyBackup.checkButton")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{insecure
? t("admin.keyBackup.checkInsecureNote")
: t("admin.keyBackup.checkNote")}
</p>
{checkResult !== null && (
<p
role="status"
data-testid="key-backup-check-result"
className={
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
}
>
{checkResult
? t("admin.keyBackup.checkMatches")
: t("admin.keyBackup.checkNoMatch")}
</p>
)}
</form>
{!s.due && s.confirmedAt && (
<p className="text-muted-foreground text-sm">
{s.confirmedBy?.email
? t("admin.keyBackup.confirmedAtBy", {
date: fmt.dateTime(new Date(s.confirmedAt)),
who: s.confirmedBy.email,
})
: t("admin.keyBackup.confirmedAt", {
date: fmt.dateTime(new Date(s.confirmedAt)),
})}
</p>
)}
{s.due && s.confirmedKeyId && (
<p className="text-sm">
{t("admin.keyBackup.dueAgain", { keyId: s.confirmedKeyId })}
</p>
)}
{s.due && (
<SettingsCardActions>
<Button
onClick={() => confirm.mutate(s)}
disabled={confirm.isPending}
>
{confirm.isPending && (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.keyBackup.confirmButton")}
</Button>
</SettingsCardActions>
)}
</SettingsCard>
);
}
+519
View File
@@ -0,0 +1,519 @@
"use client";
/**
* `<RecoveryKitCard>` — the recovery kit on the admin Encryption page.
*
* Lists every key a restore of this server needs: the application keys
* (including a retired one a backup still lists), the off-host backup keys and
* the token key. Each row shows the key's id and fingerprint (never the key),
* why the kit lists it and whether the confirmation covers it. Below them:
* where the keys are set on each platform, the short restore path with a link
* to the full guide, a "Check my copy" field and the confirmation.
*
* The confirmation sends the kit exactly as this card showed it. When the
* server's kit changed since the page loaded (a rotation, a new backup key),
* the server answers 409 and the card reloads.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BookOpen, KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { InlineAction } from "@/components/ui/inline-action";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ApiError, apiFetch, apiPost } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
useRecoveryKit,
type KitKey,
type KitPlatform,
type RecoveryKit,
} from "./use-recovery-kit";
const PLATFORMS: KitPlatform[] = [
"compose",
"truenas",
"coolify",
"unraid",
"portainer",
];
/** The disaster recovery guide in the repository, the same on every host. */
export const DISASTER_RECOVERY_GUIDE_URL =
"https://github.com/MBombeck/HealthLog/blob/main/docs/ops/disaster-recovery.md";
/** Literal keys per platform, so the i18n coverage guard sees every one. */
function platformCopy(
t: (key: string) => string,
p: KitPlatform,
): { label: string; where: string } {
switch (p) {
case "truenas":
return {
label: t("admin.recoveryKit.platform.truenasLabel"),
where: t("admin.recoveryKit.platform.truenasWhere"),
};
case "coolify":
return {
label: t("admin.recoveryKit.platform.coolifyLabel"),
where: t("admin.recoveryKit.platform.coolifyWhere"),
};
case "unraid":
return {
label: t("admin.recoveryKit.platform.unraidLabel"),
where: t("admin.recoveryKit.platform.unraidWhere"),
};
case "portainer":
return {
label: t("admin.recoveryKit.platform.portainerLabel"),
where: t("admin.recoveryKit.platform.portainerWhere"),
};
case "compose":
return {
label: t("admin.recoveryKit.platform.composeLabel"),
where: t("admin.recoveryKit.platform.composeWhere"),
};
}
}
function keyLabel(
t: (key: string, params?: Record<string, string | number>) => string,
key: KitKey,
): string {
switch (key.kind) {
case "application":
return t("admin.recoveryKit.kind.application", { id: key.id });
case "backup":
return key.need.active
? t("admin.recoveryKit.kind.backup")
: t("admin.recoveryKit.kind.backupPrevious");
case "token":
return t("admin.recoveryKit.kind.token");
}
}
/** Why the kit lists a key, as short sentences. */
function KeyNeeds({ k }: { k: KitKey }) {
const { t } = useTranslations();
const fmt = useFormatters();
const lines: string[] = [];
if (k.kind === "token") {
lines.push(t("admin.recoveryKit.need.token"));
} else if (k.kind === "backup") {
lines.push(
k.need.active
? t("admin.recoveryKit.need.backupActive")
: t("admin.recoveryKit.need.backupPrevious"),
);
} else {
if (k.need.active) lines.push(t("admin.recoveryKit.need.active"));
else if (k.need.configured)
lines.push(t("admin.recoveryKit.need.configured"));
if (k.need.storedCopies > 0) {
lines.push(
t("admin.recoveryKit.need.storedCopies", {
count: k.need.storedCopies,
}),
);
}
if (k.need.offhostUntil === "unknown") {
lines.push(t("admin.recoveryKit.need.offhostUnknown"));
} else if (k.need.offhostUntil) {
lines.push(
t("admin.recoveryKit.need.offhostUntil", {
date: fmt.date(new Date(k.need.offhostUntil)),
}),
);
}
}
lines.push(t("admin.recoveryKit.source", { variable: k.source }));
return <p className="text-muted-foreground text-xs">{lines.join(" ")}</p>;
}
function KeyStatus({ k }: { k: KitKey }) {
const { t } = useTranslations();
if (k.problem === "unreadable") {
return (
<Badge
variant="outline"
className="border-destructive/40 text-destructive"
>
{t("admin.recoveryKit.status.unreadable")}
</Badge>
);
}
if (k.confirmed) {
return (
<Badge variant="outline">{t("admin.recoveryKit.status.confirmed")}</Badge>
);
}
return (
<Badge variant="outline" className="border-warning/40 text-warning">
{k.problem === "not_configured"
? t("admin.recoveryKit.status.notConfigured")
: t("admin.recoveryKit.status.new")}
</Badge>
);
}
/** One row per key: what it is, its id and fingerprint, why it is listed. */
export function RecoveryKitKeyList({ kit }: { kit: RecoveryKit }) {
const { t } = useTranslations();
return (
<ul
className="divide-border divide-y rounded-lg border"
data-testid="recovery-kit-keys"
>
{kit.keys.map((k) => (
<li
key={`${k.kind}:${k.id}`}
className="space-y-1.5 p-3"
data-testid={`recovery-kit-key-${k.kind}-${k.id}`}
data-confirmed={k.confirmed ? "true" : "false"}
>
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 text-sm font-medium">{keyLabel(t, k)}</p>
<div className="shrink-0">
<KeyStatus k={k} />
</div>
</div>
<dl className="grid gap-x-3 gap-y-1 text-sm sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("admin.recoveryKit.keyId")}
</dt>
<dd className="font-mono break-all">{k.id}</dd>
</div>
<div className="min-w-0">
<dt className="text-muted-foreground text-xs">
{t("admin.recoveryKit.fingerprint")}
</dt>
<dd className="font-mono" data-testid="recovery-kit-fingerprint">
{k.fingerprint ?? t("admin.recoveryKit.noFingerprint")}
</dd>
</div>
</dl>
<KeyNeeds k={k} />
{k.problem === "not_configured" && k.kind !== "token" ? (
<p className="text-sm">
{t("admin.recoveryKit.notConfiguredHint")}
</p>
) : null}
{k.problem === "unreadable" ? (
<p className="text-sm">{t("admin.recoveryKit.unreadableHint")}</p>
) : null}
</li>
))}
</ul>
);
}
function CheckMyCopy({ kit }: { kit: RecoveryKit }) {
const { t } = useTranslations();
const checkable = kit.keys.filter((k) => k.fingerprint !== null);
const [selected, setSelected] = useState(
checkable[0] ? `${checkable[0].kind}:${checkable[0].id}` : "",
);
const [candidate, setCandidate] = useState("");
const [checkResult, setCheckResult] = useState<boolean | null>(null);
const verify = useMutation({
// Not `apiPost`: no idempotency key, so no cached response row exists
// for a request whose body was a key.
mutationFn: (keyCopy: string) => {
const target = checkable.find((k) => `${k.kind}:${k.id}` === selected);
return apiFetch<{ matches: boolean }>("/api/admin/recovery-kit/verify", {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
kind: target?.kind,
id: target?.id,
keyCopy,
}),
});
},
onSuccess: (data) => {
setCheckResult(data.matches);
setCandidate("");
},
onError: (err: Error) => {
setCheckResult(null);
showErrorToast(err.message || t("admin.recoveryKit.checkFailed"), {
cause: err,
});
},
});
if (checkable.length === 0) return null;
const insecure =
typeof window !== "undefined" && window.isSecureContext === false;
return (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
if (candidate.trim() && selected) verify.mutate(candidate);
}}
>
<p className="text-sm font-medium">{t("admin.recoveryKit.checkLabel")}</p>
<div className="grid gap-2 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto]">
<div className="space-y-1">
<Label htmlFor="recovery-kit-check-key" className="sr-only">
{t("admin.recoveryKit.checkKeyLabel")}
</Label>
<NativeSelect
id="recovery-kit-check-key"
value={selected}
onChange={(e) => {
setSelected(e.target.value);
setCheckResult(null);
}}
>
{checkable.map((k) => (
<option key={`${k.kind}:${k.id}`} value={`${k.kind}:${k.id}`}>
{keyLabel(t, k)}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1">
<Label htmlFor="recovery-kit-candidate" className="sr-only">
{t("admin.recoveryKit.checkValueLabel")}
</Label>
<Input
id="recovery-kit-candidate"
type="password"
autoComplete="off"
spellCheck={false}
value={candidate}
onChange={(e) => {
setCandidate(e.target.value);
setCheckResult(null);
}}
className="font-mono"
/>
</div>
<Button
type="submit"
variant="outline"
disabled={!candidate.trim() || verify.isPending}
className="min-h-11 sm:min-h-10"
>
{verify.isPending && (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.recoveryKit.checkButton")}
</Button>
</div>
<p className="text-muted-foreground text-xs">
{insecure
? t("admin.recoveryKit.checkInsecureNote")
: t("admin.recoveryKit.checkNote")}
</p>
{checkResult !== null && (
<p
role="status"
data-testid="recovery-kit-check-result"
className={
checkResult ? "text-sm" : "text-destructive text-sm font-medium"
}
>
{checkResult
? t("admin.recoveryKit.checkMatches")
: t("admin.recoveryKit.checkNoMatch")}
</p>
)}
</form>
);
}
export function RecoveryKitCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const status = useRecoveryKit();
const invalidate = () =>
Promise.all([
queryClient.invalidateQueries({
queryKey: queryKeys.adminRecoveryKit(),
}),
queryClient.invalidateQueries({
queryKey: queryKeys.adminRecoveryReadiness(),
}),
]);
const confirm = useMutation({
mutationFn: (kit: RecoveryKit) =>
apiPost<RecoveryKit>(
"/api/admin/recovery-kit/confirm",
{
entries: kit.keys.map(({ kind, id, fingerprint }) => ({
kind,
id,
fingerprint,
})),
},
{ credentials: "include" },
),
onSuccess: async () => {
toast(t("admin.recoveryKit.confirmed"));
await invalidate();
},
onError: async (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
showErrorToast(t("admin.recoveryKit.stale"), { cause: err });
await invalidate();
return;
}
showErrorToast(err.message || t("admin.recoveryKit.confirmFailed"), {
cause: err,
});
},
});
if (!status.data) {
return (
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.recoveryKit.title")}
description={t("admin.recoveryKit.description")}
/>
{status.isError ? (
<QueryErrorRow
inset
error={status.error}
message={t("admin.recoveryKit.loadError")}
onRetry={() => void status.refetch()}
/>
) : (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
{t("admin.recoveryKit.loading")}
</div>
)}
</SettingsCard>
);
}
const kit = status.data;
const unconfirmedIds = kit.unconfirmed.map((k) => k.id).join(", ");
return (
<SettingsCard data-testid="recovery-kit-card">
<SettingsCardHeader
icon={KeyRound}
title={t("admin.recoveryKit.title")}
description={t("admin.recoveryKit.description")}
status={
kit.due ? (
<Badge variant="outline" className="border-warning/40 text-warning">
{t("admin.recoveryKit.badgeDue")}
</Badge>
) : (
<Badge variant="outline">{t("admin.recoveryKit.badgeDone")}</Badge>
)
}
/>
<p className="text-sm">{t("admin.recoveryKit.intro")}</p>
<RecoveryKitKeyList kit={kit} />
<p className="text-muted-foreground text-xs">
{t("admin.recoveryKit.fingerprintHint")}
</p>
<div className="space-y-2">
<p className="text-sm font-medium">
{t("admin.recoveryKit.whereTitle")}
</p>
<Tabs defaultValue={kit.platformHint}>
<TabsList className="flex-wrap">
{PLATFORMS.map((p) => (
<TabsTrigger key={p} value={p}>
{platformCopy(t, p).label}
</TabsTrigger>
))}
</TabsList>
{PLATFORMS.map((p) => (
<TabsContent key={p} value={p} className="text-sm">
{platformCopy(t, p).where}
</TabsContent>
))}
</Tabs>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">
{t("admin.recoveryKit.restoreTitle")}
</p>
<p className="text-sm">{t("admin.recoveryKit.restoreBody")}</p>
<InlineAction asChild size="sm">
<a
href={DISASTER_RECOVERY_GUIDE_URL}
target="_blank"
rel="noopener noreferrer"
>
<BookOpen aria-hidden="true" />
{t("admin.recoveryKit.restoreLink")}
</a>
</InlineAction>
</div>
<CheckMyCopy kit={kit} />
{!kit.due && kit.confirmedAt && (
<p className="text-muted-foreground text-sm">
{kit.confirmedBy?.email
? t("admin.recoveryKit.confirmedAtBy", {
date: fmt.dateTime(new Date(kit.confirmedAt)),
who: kit.confirmedBy.email,
})
: t("admin.recoveryKit.confirmedAt", {
date: fmt.dateTime(new Date(kit.confirmedAt)),
})}
</p>
)}
{kit.due && kit.confirmedAt && (
<p className="text-sm" data-testid="recovery-kit-due-again">
{t("admin.recoveryKit.dueAgain", {
date: fmt.dateTime(new Date(kit.confirmedAt)),
keys: unconfirmedIds,
})}
</p>
)}
{kit.due && (
<SettingsCardActions>
<Button
size="sm"
className="min-h-11"
onClick={() => confirm.mutate(kit)}
disabled={confirm.isPending}
>
{confirm.isPending && (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
)}
{t("admin.recoveryKit.confirmButton")}
</Button>
</SettingsCardActions>
)}
</SettingsCard>
);
}
+1 -24
View File
@@ -12,27 +12,17 @@
* don't re-fetch.
*/
import {
Clock,
Cog,
Database,
Globe,
KeyRound,
Loader2,
Server,
} from "lucide-react";
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { geoLookupState, StatusItem, useSystemStatus } from "./_shared";
import { useKeyBackupStatus } from "./use-key-backup-status";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError, error, refetch } = useSystemStatus();
const { data: keyBackup } = useKeyBackupStatus();
return (
<SettingsCard
@@ -72,19 +62,6 @@ export function SystemStatusSummary() {
label={t("admin.overview.snapshotStarted")}
value={formatDateTime(status.startTime)}
/>
{/* v1.40.0 — the encryption key backup step, warning while due. */}
{keyBackup && (
<StatusItem
icon={KeyRound}
label={t("admin.keyBackup.tileLabel")}
value={
keyBackup.due
? t("admin.keyBackup.tileDue")
: t("admin.keyBackup.tileDone")
}
tone={keyBackup.due ? "warning" : "success"}
/>
)}
{/* v1.4.27 — surface the offline-geo state so the maintainer
spots the missing GeoLite2 databases without crawling logs.
Read from the admin status, not the public /api/version. */}
@@ -1,36 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
export type KeyBackupPlatform =
"compose" | "truenas" | "coolify" | "unraid" | "portainer";
/** `GET /api/admin/encryption/key-backup`; key id and fingerprint only. */
export interface KeyBackupStatus {
due: boolean;
activeKeyId: string;
fingerprint: string;
confirmedAt: string | null;
confirmedKeyId: string | null;
confirmedFingerprint: string | null;
confirmedBy: { id: string; email: string | null } | null;
platformHint: KeyBackupPlatform;
}
/**
* The key backup step's state. `enabled` lets the dashboard banner ask only
* for an admin; the route itself is admin-only and cookie-only.
*/
export function useKeyBackupStatus(enabled = true) {
return useQuery({
queryKey: queryKeys.adminEncryptionKeyBackup(),
queryFn: () =>
apiGet<KeyBackupStatus>("/api/admin/encryption/key-backup", {
credentials: "include",
}),
enabled,
staleTime: 60_000,
});
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { RecoveryKit } from "@/lib/recovery/kit-rules";
export type {
KitKey,
KitKeyKind,
KitPlatform,
RecoveryKit,
} from "@/lib/recovery/kit-rules";
/**
* `GET /api/admin/recovery-kit`: key ids and fingerprints only. `enabled` lets
* the dashboard banner ask only for an admin; the route itself is admin-only
* and cookie-only.
*/
export function useRecoveryKit(enabled = true) {
return useQuery({
queryKey: queryKeys.adminRecoveryKit(),
queryFn: () =>
apiGet<RecoveryKit>("/api/admin/recovery-kit", {
credentials: "include",
}),
enabled,
staleTime: 60_000,
});
}
@@ -1,38 +1,39 @@
"use client";
/**
* Dashboard banner for an admin while the encryption key backup step is due.
* Not dismissable by design: it goes away when the step is done, and comes
* back when the key changes. Renders nothing for anyone who is not an admin,
* and asks the server nothing for them either.
* Dashboard banner for an admin while the recovery kit is due: never
* confirmed, or listing a key the confirmation does not cover (a rotation, a
* new backup key). Not dismissable by design: it goes away when the kit is
* confirmed, and comes back when a key changes. Renders nothing for anyone
* who is not an admin, and asks the server nothing for them either.
*/
import Link from "next/link";
import { KeyRound } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useKeyBackupStatus } from "@/components/admin/use-key-backup-status";
import { useRecoveryKit } from "@/components/admin/use-recovery-kit";
import { InlineAction } from "@/components/ui/inline-action";
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
export function RecoveryKitBanner({ isAdmin }: { isAdmin: boolean }) {
const { t } = useTranslations();
const { data } = useKeyBackupStatus(isAdmin);
const { data } = useRecoveryKit(isAdmin);
if (!isAdmin || !data?.due) return null;
return (
<div
role="status"
data-testid="key-backup-banner"
data-testid="recovery-kit-banner"
className="border-warning/40 bg-warning/10 flex flex-col gap-3 rounded-xl border p-4 sm:flex-row sm:items-center"
>
<KeyRound className="text-warning h-5 w-5 shrink-0" aria-hidden="true" />
<div className="flex-1 space-y-1">
<p className="text-sm font-semibold">
{t("dashboard.keyBackupBanner.title")}
{t("dashboard.recoveryKitBanner.title")}
</p>
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
<p className="text-sm">{t("dashboard.recoveryKitBanner.body")}</p>
</div>
<InlineAction asChild size="sm" className="min-h-11 font-medium">
<Link href="/admin/encryption">
{t("dashboard.keyBackupBanner.action")}
{t("dashboard.recoveryKitBanner.action")}
</Link>
</InlineAction>
</div>