diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 9745f6a41..3b1c86bd9 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -434,6 +434,41 @@ describe("PUT /api/admin/settings", () => { expect(args?.update).toEqual({ defaultUserTimezone: null }); }); + // Every shipped locale is a valid server default, not only de/en. + it("persists any shipped locale as the default language", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "fr", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + defaultUserTimezone: null, + } as never); + const res = await PUT(jsonReq({ defaultLocale: "fr" })); + expect(res.status).toBe(200); + const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(args?.update).toEqual({ defaultLocale: "fr" }); + }); + + it("returns 422 for a locale the app does not ship", async () => { + const res = await PUT(jsonReq({ defaultLocale: "xx" })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => { const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" })); expect(res.status).toBe(422); diff --git a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx new file mode 100644 index 000000000..2043f5049 --- /dev/null +++ b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// "Safe to drop the legacy key" needs a legacy key to drop. With a single +// configured key the server's `safeToDropRetiredKeys` is vacuously true, and +// the coverage card used to name a key that does not exist. + +const status = { + current: {} as Record, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: status.current, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null })); + +import { I18nProvider } from "@/lib/i18n/context"; +import { EncryptionSection } from "../encryption-section"; + +function statusWith(configuredKeyCount: number) { + return { + activeKeyId: "v2", + configuredKeyCount, + rotationComplete: true, + totalRows: 10, + activeRows: 10, + staleRows: 0, + columns: [], + rotation: { + state: "idle", + lastRequestedAt: null, + lastCompletedAt: null, + lastResult: null, + }, + backups: { + stored: [], + unrecorded: { copies: 0, oldestAt: null }, + offhost: [], + offhostExpirationDays: null, + retiredKeysStillNeeded: [], + }, + safeToDropRetiredKeys: true, + }; +} + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("encryption coverage — the legacy-key badge", () => { + beforeEach(() => { + status.current = {}; + }); + + it("offers the drop only when a retired key is configured", () => { + status.current = statusWith(2); + expect(render()).toContain("Safe to drop the legacy key"); + }); + + it("says nothing about a legacy key when only one key exists", () => { + status.current = statusWith(1); + const html = render(); + expect(html).not.toContain("Safe to drop the legacy key"); + expect(html).not.toContain('data-slot="encryption-backups-need-keys"'); + }); + + it("names the per-column table as a focusable scroll region", () => { + status.current = statusWith(1); + expect(render()).toMatch( + /role="region"[^>]*tabindex="0"|tabindex="0"[^>]*role="region"/, + ); + }); +}); diff --git a/src/components/admin/__tests__/sections.test.tsx b/src/components/admin/__tests__/sections.test.tsx index ec549c123..bc6ee08a0 100644 --- a/src/components/admin/__tests__/sections.test.tsx +++ b/src/components/admin/__tests__/sections.test.tsx @@ -97,7 +97,7 @@ describe("admin sections — SSR smoke", () => { it(" renders", () => { const html = render(); - expect(html).toContain("Medication Reminders"); + expect(html).toContain("Medication reminders"); }); it(" renders with filter pills", () => { @@ -115,7 +115,7 @@ describe("admin sections — SSR smoke", () => { it(" renders", () => { const html = render(); - expect(html).toContain("Login Overview"); + expect(html).toContain("Sign-in events"); }); it(" renders heading + run-now button", () => { diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index f9b92a01d..8f23a5bc4 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -119,27 +119,16 @@ export function AppLogPreviewSection() { refetch()} - disabled={isFetching} - aria-label={t("admin.section.app-logs.refresh")} - > - - - } />

{t("admin.section.app-logs.processDetail")}

-
+ {/* Filters and the refresh share one toolbar row above the table; the + header status slot carries status, not actions (design standards + §12). */} +
setLevel(v as LogLevel | "__all__")} > - + @@ -174,7 +166,10 @@ export function AppLogPreviewSection() { +
{isLoading ? ( diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index 0d8edc724..e2755e3af 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -43,6 +43,7 @@ import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { getApiErrorMessage } from "./_shared"; import { KeyBackupCard } from "./key-backup-card"; +import { useKeyBackupStatus } from "./use-key-backup-status"; interface ColumnScan { model: string; @@ -88,6 +89,9 @@ export function EncryptionSection() { const fmt = useFormatters(); const queryClient = useQueryClient(); + // 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 statusQuery = useQuery({ queryKey: queryKeys.adminEncryptionStatus(), queryFn: async (): Promise => { @@ -129,7 +133,19 @@ 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. - if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) { + // Until both reads land, only the key-backup 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) { + return ( +
+ +
+ ); + } + + if (statusQuery.isError || !statusQuery.data) { return (
@@ -143,16 +159,9 @@ export function EncryptionSection() { {t("admin.section.encryption.coverageDetail")}

- {statusQuery.isLoading ? ( -
- - {t("admin.section.encryption.loading")} -
- ) : ( -

- {t("admin.section.encryption.loadError")} -

- )} +

+ {t("admin.section.encryption.loadError")} +

); @@ -160,6 +169,7 @@ export function EncryptionSection() { const s = statusQuery.data; const running = s.rotation.state === "running" || rotate.isPending; + const hasRetiredKey = s.configuredKeyCount > 1; // 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); @@ -174,6 +184,9 @@ export function EncryptionSection() { title={t("admin.section.encryption.coverageTitle")} description={t("admin.section.encryption.coverageDescription")} /> +

+ {t("admin.section.encryption.coverageDetail")} +

- {s.safeToDropRetiredKeys ? ( + {/* "Safe to drop the legacy key" needs a legacy key to drop. With a + single configured key the server's flag is vacuously true, and + the badge named a key that does not exist. */} + {hasRetiredKey && s.safeToDropRetiredKeys ? ( {t("admin.section.encryption.safeToDropLegacy")} ) : s.rotationComplete ? ( // Every row is on the active key, and that is not the whole // answer: a backup keeps the key its content was written under. - - {s.backups.retiredKeysStillNeeded.length > 0 - ? t("admin.section.encryption.backupsStillNeed", { - keys: s.backups.retiredKeysStillNeeded.join(", "), - }) - : t("admin.section.encryption.backupsUnrecordedBadge")} - + !hasRetiredKey && + s.backups.retiredKeysStillNeeded.length === 0 ? null : ( + + {s.backups.retiredKeysStillNeeded.length > 0 + ? t("admin.section.encryption.backupsStillNeed", { + keys: s.backups.retiredKeysStillNeeded.join(", "), + }) + : t("admin.section.encryption.backupsUnrecordedBadge")} + + ) ) : ( {t("admin.section.encryption.rotationIncomplete", { @@ -341,7 +360,15 @@ export function EncryptionSection() { title={t("admin.section.encryption.columnsTitle")} description={t("admin.section.encryption.columnsDescription")} /> -
+ {/* Focusable, named scroll region: on a phone the table scrolls + sideways, and a keyboard user has to be able to reach it + (axe scrollable-region-focusable). */} +
diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 28ad68a1d..e1b86c4bd 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -1,5 +1,6 @@ "use client"; +import { localeLabels, locales } from "@/lib/i18n/config"; import { useMemo, useState } from "react"; import { Settings } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -142,8 +143,13 @@ export function GeneralSettingsSection() { disabled={updateSettings.isPending} className="self-end sm:w-auto sm:self-auto" > - - + {/* Every shipped locale, not a hand-kept pair: a new language + that ships without being offered here was the drift. */} + {locales.map((loc) => ( + + ))} diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 611860584..c233ac3aa 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -157,7 +157,6 @@ export function GlitchtipSection() {
updateSettings.mutate({ glitchtipEnabled: checked }) @@ -188,7 +187,7 @@ export function GlitchtipSection() { ) : null}
-
-
- {t("admin.users")} + {t("admin.userColumn")} {t("admin.action")} diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index 91648eb22..1220638f5 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -95,11 +95,7 @@ export function ModuleAvailabilitySection() { return ( - +

{t("admin.modules.detail")}

@@ -133,7 +129,7 @@ export function ModuleAvailabilitySection() { href="/admin/coach" className="text-primary text-xs underline-offset-4 hover:underline" > - {t("admin.assistant.title")} + {t("admin.section.coach.title")}
); diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 2a6d892d3..aea3fb7e7 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -215,16 +215,14 @@ export function RemindersSection() { />
-
-
-