mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user