Merge remote-tracking branch 'origin/main' into feat/v140-fixes

# Conflicts:
#	docs/api/openapi.yaml
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 20:06:16 +02:00
commit cd3c0b7de2
74 files changed
+4140 -508

No files matched your search

@@ -0,0 +1,73 @@
/**
* POST /api/admin/encryption/key-backup/confirm { keyId, fingerprint }
*
* The admin states that the active encryption key is backed up. The body
* names the key the admin was shown; when that is no longer the active key
* (rotated, or the server restarted with another key since the page loaded)
* the answer is 409 `encryption.keyBackup.stale` and nothing is recorded.
* No step-up: confirming is a low-risk write that reveals nothing.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
confirmKeyBackup,
KEY_BACKUP_STALE_CODE,
} from "@/lib/crypto/key-backup";
export const dynamic = "force-dynamic";
const confirmSchema = z
.object({
keyId: z.string().min(1).max(32),
fingerprint: z.string().regex(/^[0-9a-f]{12}$/),
})
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.confirm" } });
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = confirmSchema.safeParse(body.data);
if (!parsed.success) return returnAllZodIssues(parsed.error);
const result = await confirmKeyBackup({
keyId: parsed.data.keyId,
fingerprint: parsed.data.fingerprint,
userId: user.id,
});
if (!result.ok) {
annotate({ meta: { key_backup_stale: true } });
return apiError(
"The encryption key changed since this page was loaded. Reload and confirm the backup of the current key.",
409,
{
errorCode: KEY_BACKUP_STALE_CODE,
activeKeyId: result.activeKeyId,
fingerprint: result.fingerprint,
},
);
}
await auditLog("encryption.keyBackup.confirmed", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
keyId: result.status.activeKeyId,
fingerprint: result.status.fingerprint,
},
});
return apiSuccess(result.status);
});
@@ -0,0 +1,22 @@
/**
* GET /api/admin/encryption/key-backup
*
* Whether the "Back up your encryption key" step is due, for the admin step,
* the overview tile and the dashboard banner. A light read (one settings row),
* separate from `/status`, which walks the whole encrypted corpus. Key id and
* fingerprint only, never key material. Cookie-only admin.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.status" } });
const status = await readKeyBackupStatus();
annotate({ meta: { key_backup_due: status.due } });
return apiSuccess(status);
});
@@ -0,0 +1,64 @@
/**
* POST /api/admin/encryption/key-backup/verify { encryptionKey }
*
* "Check my copy": the admin pastes the backed-up key and learns whether it
* is the active key. The value is compared in memory (constant time on the
* decoded bytes) and dropped. It is never stored, never logged, never echoed:
* the Zod issue list is not returned for this body (a validation failure is a
* plain 422), and the field name is on the observability denylist.
*
* A boolean answer is no oracle against a 256-bit key; the 5 per minute limit
* per admin is there to keep the route from being anyone's busy loop. Over
* plain HTTP the value crosses the network in clear, as the admin's password
* does; the client says so before sending.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { candidateMatchesKey, getActiveKeyId } from "@/lib/crypto";
export const dynamic = "force-dynamic";
const KEY_BACKUP_VERIFY_LIMIT = 5;
const KEY_BACKUP_VERIFY_WINDOW_MS = 60 * 1000;
const verifySchema = z
.object({ encryptionKey: z.string().min(1).max(256) })
.strict();
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.encryption.keyBackup.verify" } });
const limit = await checkRateLimit(
`key-backup-verify:${user.id}`,
KEY_BACKUP_VERIFY_LIMIT,
KEY_BACKUP_VERIFY_WINDOW_MS,
);
if (!limit.allowed) {
return apiError("Too many checks. Try again in a minute.", 429);
}
const body = await safeJson(request, { maxBytes: 1024 });
if (body.error) return body.error;
const parsed = verifySchema.safeParse(body.data);
if (!parsed.success) {
// Deliberately not `returnAllZodIssues`: nothing derived from this body
// goes back over the wire.
return apiError("Paste the key as 64 hex characters.", 422);
}
const activeKeyId = getActiveKeyId();
const matches = candidateMatchesKey(parsed.data.encryptionKey, activeKeyId);
annotate({ meta: { key_backup_verify_matches: matches } });
await auditLog("encryption.keyBackup.verified", {
userId: user.id,
details: { keyId: activeKeyId, matches },
});
return apiSuccess({ matches, keyId: activeKeyId });
});
@@ -17,6 +17,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readKeyBackupStatus } from "@/lib/crypto/key-backup";
import { annotate } from "@/lib/logging/context";
import { getConfiguredKeyIds } from "@/lib/crypto";
import {
@@ -213,6 +214,7 @@ export const GET = apiHandler(async () => {
const rotation = await readRotationState();
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
const keyBackup = await readKeyBackupStatus();
annotate({
meta: {
@@ -233,6 +235,8 @@ export const GET = apiHandler(async () => {
columns: scan.columns,
rotation,
backups,
// The "Back up your encryption key" step, bound to the active key.
keyBackup,
// Every row rotated AND no backup still needs a retired key: the one
// state in which dropping an old key loses nothing. Copies that recorded
// no key ids count against it, because they may hold any key that
+8
View File
@@ -21,6 +21,7 @@ import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
@@ -65,6 +66,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// A browser page on plain http:// with Secure-only cookies cannot keep the
// session. Refuse before the password is verified (the account throttle
// never counts it) and before an MFA ticket is minted.
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
+6
View File
@@ -41,6 +41,7 @@ import { mfaVerifySchema } from "@/lib/validations/mfa";
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const dynamic = "force-dynamic";
@@ -65,6 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// Before the ticket is loaded, an attempt reserved, or a one-time code
// spent: a sign-in whose cookie cannot stick must not consume either.
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = mfaVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
@@ -39,6 +39,7 @@ import { mfaWebauthnLoginVerifySchema } from "@/lib/validations/mfa";
import { mintTrustedDevice } from "@/lib/auth/trusted-device";
import { coarseDeviceLabel } from "@/lib/auth/device-fingerprint";
import { setMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const dynamic = "force-dynamic";
@@ -63,6 +64,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
// Before the ticket is loaded or an attempt reserved (see mfa/verify).
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
const parsed = mfaWebauthnLoginVerifySchema.safeParse(body);
if (!parsed.success) {
return apiValidationError(
+19
View File
@@ -5,6 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { encrypt } from "@/lib/crypto";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { transportVerdict } from "@/lib/auth/client-transport";
import { s256Challenge, isValidChallenge } from "@/lib/mcp/oauth/pkce";
import { buildNativeCallbackUrl } from "@/lib/auth/oidc-native-handoff";
import {
@@ -54,6 +55,24 @@ export const GET = apiHandler(async (req: NextRequest) => {
return loginError("oidc_disabled");
}
// The SSO button states the page's transport. A browser on plain http://
// with Secure-only cookies would come back from the provider and drop the
// session cookie, so it is stopped here instead of after the round trip.
if (!isNative) {
const clientProto = req.nextUrl.searchParams.get("client_proto");
const clientHost = req.nextUrl.searchParams.get("client_host") ?? "";
if (
(clientProto === "http" || clientProto === "https") &&
transportVerdict(shouldEmitSecureCookie(), {
protocol: clientProto,
host: clientHost,
}) === "blocked"
) {
annotate({ meta: { reason: "insecure_transport" } });
return loginError("insecure_transport");
}
}
const rl = await checkAuthSurfaceRateLimit(
req,
"auth:oidc:login",
@@ -1,10 +1,11 @@
import { createAuthenticationOptions } from "@/lib/auth/passkey";
import { apiSuccess, apiError } from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isOidcOnly } from "@/lib/auth/oidc";
import { refuseWhenCookieCannotStick } from "@/lib/auth/transport-refusal";
export const POST = apiHandler(async (request: Request) => {
// OIDC_ONLY must block passkey login too, not just password login.
@@ -31,6 +32,15 @@ export const POST = apiHandler(async (request: Request) => {
);
}
// The body is optional (the web client sends its page transport; nothing
// else needs to send anything). A missing or unreadable body is no body, as
// before; only a stated plain-http page refuses, before a challenge exists.
if (request.headers.get("content-type")?.includes("application/json")) {
const { data: body } = await safeJson(request, { maxBytes: 4 * 1024 });
const transportRefusal = refuseWhenCookieCannotStick(body);
if (transportRefusal) return transportRefusal;
}
const { options, challengeId } = await createAuthenticationOptions();
annotate({ action: { name: "auth.passkey.login-options" } });
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
export const dynamic = "force-dynamic";
@@ -15,10 +16,16 @@ export const GET = apiHandler(async () => {
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
// Whether this server issues `Secure` session cookies. The login page
// compares it with its own scheme before any credential is sent.
sessionCookieSecure: shouldEmitSecureCookie(),
});
} catch {
// Fail closed on backend errors.
annotate({ action: { name: "auth.registration-status" } });
return apiSuccess({ registrationEnabled: false });
return apiSuccess({
registrationEnabled: false,
sessionCookieSecure: shouldEmitSecureCookie(),
});
}
});
@@ -117,3 +117,26 @@ describe("GET /api/health process topology", () => {
expect(ready.body).toEqual({ status: "ok" });
});
});
describe("GET /api/health while the encryption key does not match", () => {
it("answers 503 with the reason, for everyone, even with every component up", async () => {
const { setKeyMismatchState } =
await import("@/lib/boot/key-mismatch-state");
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
setKeyMismatchState({ keyIds: ["v1"], detectedAt: "2026-10-03T00:00:00Z" });
try {
const { response, body } = await health();
expect(response.status).toBe(503);
expect(body).toEqual({
status: "degraded",
reason: "encryption_key_mismatch",
});
} finally {
setKeyMismatchState(null);
}
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
});
+12 -1
View File
@@ -6,6 +6,10 @@ import { getSession } from "@/lib/auth/session";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
isKeyMismatch,
KEY_MISMATCH_HEALTH_REASON,
} from "@/lib/boot/key-mismatch-state";
export const dynamic = "force-dynamic";
@@ -22,7 +26,13 @@ export const GET = apiHandler(async () => {
const worker = getWorkerStatus();
const producerReady = getGlobalBoss() !== null;
// A process whose encryption key cannot open the database refuses every
// other route; the probe says so in a word every caller may see, because
// it is the one place a catalog install's status page reads.
const keyMismatch = isKeyMismatch();
const reason = keyMismatch ? KEY_MISMATCH_HEALTH_REASON : undefined;
const status =
!keyMismatch &&
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
@@ -40,6 +50,7 @@ export const GET = apiHandler(async () => {
return NextResponse.json(
{
status,
...(reason ? { reason } : {}),
timestamp: new Date().toISOString(),
database: dbOk ? "connected" : "disconnected",
worker: worker.running ? "running" : "stopped",
@@ -52,7 +63,7 @@ export const GET = apiHandler(async () => {
}
return NextResponse.json(
{ status },
{ status, ...(reason ? { reason } : {}) },
{ status: statusCode, headers: cacheHeaders },
);
});
+68 -15
View File
@@ -25,6 +25,14 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
import { sanitizeSameOriginPath } from "@/lib/url-safety";
import {
InsecureTransportBanner,
useClientTransport,
} from "@/components/auth/insecure-transport-banner";
import { transportVerdict } 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 { loadDocument } from "@/lib/navigation/load-document";
export default function LoginPage() {
@@ -44,7 +52,8 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(() => {
const oidcError = searchParams.get("error");
if (!oidcError) return null;
// The SSO start refused a page on plain http://; the banner says why.
if (!oidcError || oidcError === "insecure_transport") return null;
const key: Record<string, string> = {
oidc_denied: "auth.oidc.errorDenied",
oidc_no_email: "auth.oidc.errorNoEmail",
@@ -71,22 +80,55 @@ export default function LoginPage() {
// standalone alert detached from the form.
const errorId = useId();
const errorDescriptor = error ? errorId : undefined;
const { data: registrationEnabled } = useQuery({
const { data: registrationStatus } = useQuery({
queryKey: queryKeys.authRegistrationStatus(),
queryFn: async () => {
try {
const data = await apiGet<{ registrationEnabled?: boolean }>(
"/api/auth/registration-status",
{ cache: "no-store" },
);
return Boolean(data?.registrationEnabled ?? true);
const data = await apiGet<{
registrationEnabled?: boolean;
sessionCookieSecure?: boolean;
}>("/api/auth/registration-status", { cache: "no-store" });
return {
registrationEnabled: Boolean(data?.registrationEnabled ?? true),
sessionCookieSecure: data?.sessionCookieSecure === true,
};
} catch {
// Fail open, exactly as the raw `if (!res.ok) return true` did.
return true;
// Fail open, exactly as the raw `if (!res.ok) return true` did; and
// no transport gate without the server's word on its cookies.
return { registrationEnabled: true, sessionCookieSecure: false };
}
},
staleTime: 60 * 1000,
});
const registrationEnabled = registrationStatus?.registrationEnabled;
// #1097 — a server that sets its sign-in cookie only over https:// cannot
// sign anyone in on a plain http:// page: the browser drops the cookie. Say
// so before a credential leaves the browser, and stop the sign-in controls.
// The server refuses the same requests (409) if they are sent anyway, and
// the SSO start sends the browser back here with `insecure_transport`.
const clientTransport = useClientTransport();
const [transportRefused, setTransportRefused] = useState(
() => searchParams.get("error") === "insecure_transport",
);
const verdict = transportRefused
? "blocked"
: transportVerdict(
registrationStatus?.sessionCookieSecure === true,
clientTransport,
);
const signInBlocked = verdict === "blocked";
function noteRefusal(err: unknown): boolean {
if (
err instanceof ApiError &&
err.meta?.errorCode === INSECURE_TRANSPORT_CODE
) {
setTransportRefused(true);
return true;
}
return false;
}
const { data: oidcStatus } = useQuery({
queryKey: queryKeys.authOidcStatus(),
@@ -106,6 +148,10 @@ export default function LoginPage() {
function handleOidcLogin() {
const params = new URLSearchParams({ next: getRedirectTarget() });
if (clientTransport) {
params.set("client_proto", clientTransport.protocol);
params.set("client_host", clientTransport.host);
}
loadDocument(`/api/auth/oidc/login?${params}`);
}
@@ -176,7 +222,7 @@ export default function LoginPage() {
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
}>("/api/auth/passkey/login-options", { clientTransport });
const credential = await startAuthentication({ optionsJSON: options });
@@ -189,6 +235,7 @@ export default function LoginPage() {
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
// Route rejections carry the envelope error verbatim; everything
// else (WebAuthn ceremony failures) keeps the descriptive mapping.
if (err instanceof ApiError) {
@@ -220,7 +267,7 @@ export default function LoginPage() {
>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, clientTransport }),
});
if (meta?.mfaRequired && meta.mfaTicket) {
@@ -235,6 +282,7 @@ export default function LoginPage() {
// navigates; the shell refetches `["auth","me"]` fresh on mount.
navigateAfterLogin();
} catch (err) {
if (noteRefusal(err)) return;
setError(
err instanceof ApiError && err.message
? err.message
@@ -289,6 +337,9 @@ export default function LoginPage() {
useEffect(() => {
if (oidcStatus === undefined) return;
if (oidcStatus.only === true) return;
// Wait for the server's word on its cookies, and never arm the browser's
// credential prompt for a sign-in that cannot keep its cookie.
if (registrationStatus === undefined || signInBlocked) return;
if (autofillStarted.current) return;
autofillStarted.current = true;
let cancelled = false;
@@ -302,7 +353,7 @@ export default function LoginPage() {
const { options, challengeId } = await apiPost<{
options: Parameters<typeof startAuthentication>[0]["optionsJSON"];
challengeId: string;
}>("/api/auth/passkey/login-options");
}>("/api/auth/passkey/login-options", { clientTransport });
const credential = await startAuthentication({
optionsJSON: options,
@@ -327,7 +378,7 @@ export default function LoginPage() {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [oidcStatus]);
}, [oidcStatus, registrationStatus, signInBlocked]);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
@@ -380,12 +431,14 @@ export default function LoginPage() {
data-slot="login-actions"
className="mt-8 space-y-4"
>
<InsecureTransportBanner verdict={verdict} />
{oidcStatus?.enabled && (
<Button
onClick={handleOidcLogin}
variant={oidcStatus.only ? "default" : "outline"}
className="min-h-11 w-full"
size="lg"
disabled={signInBlocked}
>
<Shield className="h-4 w-4" />
{oidcStatus.buttonLabel || t("auth.oidc.signInDefault")}
@@ -414,7 +467,7 @@ export default function LoginPage() {
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
disabled={loading || signInBlocked}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -491,7 +544,7 @@ export default function LoginPage() {
data-testid="login-password-submit"
className="min-h-11 w-full"
size="lg"
disabled={loading}
disabled={loading || signInBlocked}
>
{loading && mode === "password" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
+43
View File
@@ -0,0 +1,43 @@
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
/**
* What every page renders while the boot key check has found that the
* configured encryption key cannot open this database (see
* `src/lib/crypto/canary.ts`). A static server-rendered page with no client
* code and no data reads: the app behind it would only fail on every value.
* The API answers 503 with `encryption.key_mismatch` at the same time, and
* `/api/health` names the reason.
*/
export function KeyMismatchPage({ locale }: { locale: Locale }) {
const { t } = getServerTranslator(locale);
return (
<main className="bg-background text-foreground flex min-h-dvh items-center justify-center px-4 py-10">
<div className="border-border bg-card w-full max-w-xl space-y-4 rounded-xl border p-4 md:p-6">
<h1 className="text-xl font-semibold">
{t("admin.keyMismatch.title")}
</h1>
<p className="text-sm">{t("admin.keyMismatch.body")}</p>
<div className="space-y-2">
<h2 className="text-base font-semibold">
{t("admin.keyMismatch.fixTitle")}
</h2>
<ol className="list-decimal space-y-2 ps-5 text-sm">
<li>{t("admin.keyMismatch.fixRestore")}</li>
<li>{t("admin.keyMismatch.fixDatabase")}</li>
<li>{t("admin.keyMismatch.fixFresh")}</li>
</ol>
</div>
<p className="text-muted-foreground text-sm">
{t("admin.keyMismatch.restartNote")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.keyMismatch.codes", {
code: "encryption.key_mismatch",
reason: "encryption_key_mismatch",
})}
</p>
</div>
</main>
);
}
+16 -8
View File
@@ -8,6 +8,8 @@ import { AuthShell } from "@/components/layout/auth-shell";
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter";
import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale";
import { isKeyMismatch } from "@/lib/boot/key-mismatch-state";
import { KeyMismatchPage } from "./key-mismatch-page";
const inter = Inter({
variable: "--font-sans",
@@ -131,17 +133,23 @@ export default async function RootLayout({
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
{isKeyMismatch() ? (
// The boot key check refused this process: no app shell, no data
// reads, one page that names the fix.
<KeyMismatchPage locale={initialLocale} />
) : (
<Providers initialLocale={initialLocale}>
<MonitoringBootstrap />
<WebVitalsReporter />
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
block mutations. Resolve it here (server component) and
thread the boolean into the client shell so the demo
banner can render without a client-side detection path. */}
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
{children}
</AuthShell>
</Providers>
)}
</body>
</html>
);
+2
View File
@@ -50,6 +50,7 @@ import {
useDashboardChartReveal,
} from "@/components/dashboard/chart-reveal";
import { DashboardHeader } from "@/components/dashboard/dashboard-header";
import { KeyBackupBanner } from "@/components/dashboard/key-backup-banner";
import { TodayHero } from "@/components/daily/today-hero";
import { TodayHeroSkeleton } from "@/components/daily/today-hero-skeleton";
import {
@@ -913,6 +914,7 @@ export default function DashboardPageClient({
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
<KeyBackupBanner isAdmin={user?.role === "ADMIN"} />
{/* Records other people share with you are reached from the account
switcher in the top bar and the sidebar, not from a card here. The