mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge remote-tracking branch 'origin/main' into feat/v140-fixes
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
cd3c0b7de2
74 files changed
+4140
-508
No files matched your search
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import { KeyBackupCard } from "./key-backup-card";
|
||||
|
||||
interface ColumnScan {
|
||||
model: string;
|
||||
@@ -130,27 +131,30 @@ export function EncryptionSection() {
|
||||
// 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")}
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
<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>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +166,7 @@ export function EncryptionSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<KeyBackupCard />
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"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 { 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) {
|
||||
toast.error(t("admin.keyBackup.stale"));
|
||||
await invalidate();
|
||||
return;
|
||||
}
|
||||
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
toast.error(err.message || t("admin.keyBackup.checkFailed"));
|
||||
},
|
||||
});
|
||||
|
||||
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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -12,18 +12,28 @@
|
||||
* don't re-fetch.
|
||||
*/
|
||||
|
||||
import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react";
|
||||
import {
|
||||
Clock,
|
||||
Cog,
|
||||
Database,
|
||||
Globe,
|
||||
KeyRound,
|
||||
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 { StatusItem, usePublicVersion, useSystemStatus } from "./_shared";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError, refetch } = useSystemStatus();
|
||||
const { data: version } = usePublicVersion();
|
||||
const { data: keyBackup } = useKeyBackupStatus();
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
@@ -63,6 +73,19 @@ 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 R5 — surface the offline-geo state so the maintainer
|
||||
spots the missing MAXMIND_LICENSE_KEY without crawling logs.
|
||||
The field is undefined on legacy responses; the row only
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The login page's transport notice (#1097): the blocked banner names both
|
||||
* operator fixes, the localhost note is soft, and "ok" renders nothing.
|
||||
* Mutation check: render the banner for `soft` and the second case goes red.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsecureTransportBanner } from "@/components/auth/insecure-transport-banner";
|
||||
import type { TransportVerdict } from "@/lib/auth/client-transport";
|
||||
|
||||
function render(verdict: TransportVerdict, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<InsecureTransportBanner verdict={verdict} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("InsecureTransportBanner", () => {
|
||||
it("blocked: an alert naming SESSION_COOKIE_SECURE and https", () => {
|
||||
const html = render("blocked");
|
||||
expect(html).toContain('data-testid="insecure-transport-banner"');
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("SESSION_COOKIE_SECURE=false");
|
||||
expect(html).toContain("https://");
|
||||
});
|
||||
|
||||
it("soft: a note on localhost, no alert", () => {
|
||||
const html = render("soft");
|
||||
expect(html).toContain('data-testid="insecure-transport-note"');
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("ok: nothing", () => {
|
||||
expect(render("ok")).toBe("");
|
||||
});
|
||||
|
||||
it("speaks German", () => {
|
||||
expect(render("blocked", "de")).toContain("https://");
|
||||
expect(render("blocked", "de")).not.toContain(
|
||||
"This server only sets its sign-in cookie",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Login-page notice for a page whose session cookie cannot stick (#1097).
|
||||
*
|
||||
* `blocked`: the server sets its sign-in cookie only over https:// and this
|
||||
* page is plain http:// on a network address. The browser would drop the
|
||||
* cookie, so the sign-in controls are disabled and the two operator fixes are
|
||||
* named. `soft`: the same on localhost, where most browsers accept the cookie;
|
||||
* the form stays usable.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
ClientTransport,
|
||||
TransportVerdict,
|
||||
} from "@/lib/auth/client-transport";
|
||||
|
||||
const noopSubscribe = () => () => {};
|
||||
|
||||
/**
|
||||
* The page's transport, read after hydration only (the server render has no
|
||||
* `location`), so the first client render matches the server HTML.
|
||||
*/
|
||||
export function useClientTransport(): ClientTransport | undefined {
|
||||
const raw = useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
() => `${window.location.protocol}//${window.location.host}`,
|
||||
() => null,
|
||||
);
|
||||
if (!raw) return undefined;
|
||||
const [scheme, host] = raw.split("//");
|
||||
return { protocol: scheme === "https:" ? "https" : "http", host };
|
||||
}
|
||||
|
||||
export function InsecureTransportBanner({
|
||||
verdict,
|
||||
}: {
|
||||
verdict: TransportVerdict;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (verdict === "ok") return null;
|
||||
if (verdict === "soft") {
|
||||
return (
|
||||
<p
|
||||
data-testid="insecure-transport-note"
|
||||
className="text-muted-foreground rounded-lg border p-3 text-xs"
|
||||
>
|
||||
{t("auth.insecureTransport.localhostNote")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="insecure-transport-banner"
|
||||
className="border-warning/40 bg-warning/10 space-y-2 rounded-lg border p-3 text-sm"
|
||||
>
|
||||
<p className="flex items-start gap-2 font-semibold">
|
||||
<TriangleAlert
|
||||
className="text-warning mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("auth.insecureTransport.title")}
|
||||
</p>
|
||||
<p>{t("auth.insecureTransport.body")}</p>
|
||||
<ul className="list-disc space-y-1 ps-5">
|
||||
<li>{t("auth.insecureTransport.fixLan")}</li>
|
||||
<li>{t("auth.insecureTransport.fixHttps")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { currentClientTransport } from "@/lib/auth/client-transport";
|
||||
|
||||
/** The server's refusal for a page whose sign-in cookie cannot stick. */
|
||||
const INSECURE_TRANSPORT_CODE = "auth.session.insecure_transport";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type MfaMethod = "totp" | "recovery" | "webauthn";
|
||||
@@ -61,13 +65,17 @@ export function MfaLoginStep({
|
||||
method: useRecovery ? "recovery" : "totp",
|
||||
code: code.trim(),
|
||||
rememberDevice: useRecovery ? false : rememberDevice,
|
||||
clientTransport: currentClientTransport(),
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.mfa.verifyFailed"),
|
||||
err instanceof ApiError &&
|
||||
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
|
||||
? t("auth.insecureTransport.title")
|
||||
: err instanceof ApiError && err.message
|
||||
? err.message
|
||||
: t("auth.mfa.verifyFailed"),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -89,6 +97,7 @@ export function MfaLoginStep({
|
||||
challengeId,
|
||||
credential,
|
||||
rememberDevice,
|
||||
clientTransport: currentClientTransport(),
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"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.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export function KeyBackupBanner({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useKeyBackupStatus(isAdmin);
|
||||
if (!isAdmin || !data?.due) return null;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="key-backup-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")}
|
||||
</p>
|
||||
<p className="text-sm">{t("dashboard.keyBackupBanner.body")}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/encryption"
|
||||
className="text-primary inline-flex min-h-11 items-center text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("dashboard.keyBackupBanner.action")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user