mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'feat/v1462-auth' into release/v1.46.2
This commit is contained in:
commit
d0be3ae106
74 files changed
+3645
-901
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 346,
|
||||
"total": 112297,
|
||||
"total": 112215,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286,
|
||||
@@ -9,7 +9,7 @@
|
||||
"app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211,
|
||||
"app/api/auth/login/route.ts :: apiHandler(arg)": 262,
|
||||
"app/api/auth/me/route.ts :: apiHandler(arg)": 386,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281,
|
||||
"app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258,
|
||||
"app/api/auth/register/route.ts :: apiHandler(arg)": 268,
|
||||
"app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309,
|
||||
"app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230,
|
||||
@@ -217,7 +217,7 @@
|
||||
"components/settings/mood-reminder-card.tsx :: MoodReminderCard": 234,
|
||||
"components/settings/report-selection/selection-panel.tsx :: HealthRecordExportPanel": 370,
|
||||
"components/settings/score-section.tsx :: ScoreSection": 383,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 410,
|
||||
"components/settings/security-section/passkey-list-section.tsx :: PasskeyListSection": 390,
|
||||
"components/settings/security-section/security-keys-card.tsx :: SecurityKeysCard": 288,
|
||||
"components/settings/security-section/totp-card.tsx :: TotpCard": 533,
|
||||
"components/settings/security-sessions-card.tsx :: SecuritySessionsCard": 245,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "as never casts in tests, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
|
||||
"count": 524,
|
||||
"total": 5488,
|
||||
"count": 523,
|
||||
"total": 5483,
|
||||
"entries": {
|
||||
"__tests__/api/auth/login/mfa-branch.test.ts": 10,
|
||||
"__tests__/api/auth/mfa/verify.test.ts": 9,
|
||||
@@ -337,7 +337,6 @@
|
||||
"lib/auth/__tests__/mfa-enrollment.test.ts": 8,
|
||||
"lib/auth/__tests__/native-handoff-flow.test.ts": 10,
|
||||
"lib/auth/__tests__/oidc-native-handoff.test.ts": 13,
|
||||
"lib/auth/__tests__/oidc.test.ts": 5,
|
||||
"lib/auth/__tests__/passkey.test.ts": 7,
|
||||
"lib/auth/__tests__/profile-update-email-limit.test.ts": 5,
|
||||
"lib/auth/__tests__/profile-update.test.ts": 4,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2897,
|
||||
"total": 9236,
|
||||
"count": 2896,
|
||||
"total": 9235,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -2067,7 +2067,6 @@
|
||||
"lib/google-health/sync-metrics.ts": 1,
|
||||
"lib/i18n/__tests__/relative-time-updated.test.ts": 2,
|
||||
"lib/i18n/config.ts": 1,
|
||||
"lib/i18n/context.tsx": 1,
|
||||
"lib/i18n/relative-time.ts": 4,
|
||||
"lib/i18n/shared-resolve.ts": 1,
|
||||
"lib/i18n/source-labels.ts": 1,
|
||||
|
||||
@@ -679,6 +679,17 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["AlertDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/admin/delete-user-control.tsx",
|
||||
destroys:
|
||||
"another user's whole account, locked at once and erased when the deletion grace window ends (or at once when the window is 0)",
|
||||
triggers: ["components/admin/user-management-section.tsx"],
|
||||
// The undo route exists and the row offers it until the purge date
|
||||
// (`pending-deletion-controls.tsx`); with the window at 0 there is
|
||||
// nothing to undo, and the dialog says so.
|
||||
recovery: "restorable",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
|
||||
// ── Integrations ────────────────────────────────────────────────────────
|
||||
// Every connection renders the shared integration card, whose three-dot
|
||||
|
||||
@@ -145,15 +145,27 @@ describe("managed-profile contract", () => {
|
||||
path.join(root, "src/lib/sharing/grants.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const accountDeletion = await readFile(
|
||||
path.join(root, "src/app/api/settings/account/route.ts"),
|
||||
// Both account deletions (the account's own and an admin's) take the one
|
||||
// request path, and that path decides the Guardian invariant.
|
||||
const deletionRequest = await readFile(
|
||||
path.join(root, "src/lib/account-deletion/request.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const deletionRoutes = await Promise.all(
|
||||
[
|
||||
"src/app/api/settings/account/route.ts",
|
||||
"src/app/api/admin/users/[id]/route.ts",
|
||||
].map((rel) => readFile(path.join(root, rel), "utf8")),
|
||||
);
|
||||
|
||||
expect(lifecycle).toContain("pg_advisory_xact_lock");
|
||||
expect(lifecycle).toContain("clearManagedProfileMarker");
|
||||
expect(lifecycle).toContain("deleteManagedProfile");
|
||||
expect(grants).toContain("reduceManagedProfileGuardian");
|
||||
expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle");
|
||||
expect(deletionRequest).toContain("deleteGuardianAccountWithLifecycle(");
|
||||
for (const route of deletionRoutes) {
|
||||
expect(route).toContain("requestAccountDeletion(");
|
||||
expect(route).not.toContain("lockAccountForDeletion(");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["DELETE", "PUT"] },
|
||||
"/api/admin/users/{id}/force-logout": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
@@ -308,6 +308,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
// #1296 — the identity-provider re-proof start. A top-level navigation from
|
||||
// the web's re-proof dialog that ends at the provider's sign-in page; the
|
||||
// native client never re-proves this way (Bearer has its own step-up).
|
||||
"/api/auth/oidc/reproof": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
"/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] },
|
||||
// `/api/nightscout/connect` sat here as a `browserHandoff` and is published
|
||||
|
||||
@@ -111,6 +111,7 @@ const COOKIE_PROOF_ROUTES = [
|
||||
"app/api/admin/backups/upload/route.ts",
|
||||
"app/api/admin/data/route.ts",
|
||||
"app/api/admin/users/[id]/reset-password/route.ts",
|
||||
"app/api/admin/users/[id]/route.ts",
|
||||
"app/api/auth/reproof/route.ts",
|
||||
"app/api/tokens/documents/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
|
||||
@@ -102,8 +102,11 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// replayed callback redirects instead of minting a second identity, and
|
||||
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
||||
// as the claim routes refuse it.
|
||||
// And the identity-provider re-proof (#1296), which stamps only
|
||||
// the session the start bound into the sealed state, and only while the
|
||||
// browser still carries it.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
||||
"duplicate-callback probe, the claim's live-session refusal and the re-proof's session binding on an otherwise anonymous route",
|
||||
// Public liveness probe. The session read only decides whether the body
|
||||
// carries operational detail; the status code is computed without it.
|
||||
"app/api/health/route.ts": "admin-only detail on a public probe",
|
||||
|
||||
@@ -112,6 +112,7 @@ function discoverGatedRoutes(): string[] {
|
||||
*/
|
||||
const GATED_ROUTES: Record<string, string> = {
|
||||
"DELETE /api/admin/data": "web",
|
||||
"DELETE /api/admin/users/[id]": "web",
|
||||
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
|
||||
"DELETE /api/auth/passkeys/[id]": "web",
|
||||
"DELETE /api/managed-profiles/[id]": "web",
|
||||
|
||||
@@ -3,13 +3,21 @@ import {
|
||||
assertAnotherAdminStays,
|
||||
LastAdminError,
|
||||
} from "@/lib/auth/admin-roster";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
assertRecentCookieProof,
|
||||
requireAdmin,
|
||||
} from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { parseJsonBody } from "@/lib/api/body";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { requestAccountDeletion } from "@/lib/account-deletion/request";
|
||||
import { lockPendingAccount } from "@/lib/account-deletion/lifecycle";
|
||||
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { adminDeleteUserSchema } from "@/lib/validations/admin";
|
||||
|
||||
const updateUserSchema = z
|
||||
.object({
|
||||
@@ -122,3 +130,134 @@ export const PUT = apiHandler(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** A refusal decided under the account's row lock, inside the transaction. */
|
||||
class DeletionRefusal extends Error {
|
||||
constructor(readonly kind: "not_found" | "managed_profile" | "pending") {
|
||||
super(`admin deletion refused: ${kind}`);
|
||||
this.name = "DeletionRefusal";
|
||||
}
|
||||
}
|
||||
|
||||
function deletionRefusalResponse(kind: DeletionRefusal["kind"]): Response {
|
||||
if (kind === "not_found") return apiError("User not found", 404);
|
||||
if (kind === "managed_profile") {
|
||||
return apiError("A managed profile is deleted by its Guardians", 409, {
|
||||
errorCode: "admin.users.delete.managed_profile",
|
||||
});
|
||||
}
|
||||
return apiError("This account is already pending deletion", 409, {
|
||||
errorCode: "account.deletion.already_pending",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/{id} — an admin deletes another account.
|
||||
*
|
||||
* The same request the account's own deletion makes
|
||||
* (`requestAccountDeletion`): with a grace window the account is locked at
|
||||
* once (every credential revoked, sign-in refused) and erased by the purge job
|
||||
* when the window ends, and `POST /api/admin/users/{id}/undo-deletion` gives it
|
||||
* back until then; with `ACCOUNT_DELETION_GRACE_DAYS=0` it is erased here.
|
||||
*
|
||||
* Cookie-only (`requireAdmin`) behind a fresh proof (`assertRecentCookieProof`,
|
||||
* the gate in front of the other admin acts on someone's data), with the typed
|
||||
* confirmation the self-deletion asks for. Refused for the caller's own
|
||||
* account (that is the account settings' deletion, with its own step-up), for
|
||||
* the last admin who can sign in, for a managed profile (its Guardians delete
|
||||
* it), for an account already pending deletion, and for the last Guardian of
|
||||
* a managed profile. Audit-logged for the admin and on the account itself.
|
||||
*/
|
||||
export const DELETE = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) => {
|
||||
const { user: admin, session } = await requireAdmin();
|
||||
await assertRecentCookieProof(admin, session.id);
|
||||
|
||||
const { id } = await params;
|
||||
annotate({
|
||||
action: {
|
||||
name: "admin.users.delete",
|
||||
entity_type: "user",
|
||||
entity_id: id,
|
||||
},
|
||||
});
|
||||
|
||||
const parsed = await parseJsonBody(request, adminDeleteUserSchema, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (!parsed.ok) return parsed.error;
|
||||
|
||||
if (id === admin.id) {
|
||||
return apiError(
|
||||
"Delete your own account from your account settings",
|
||||
400,
|
||||
{ errorCode: "admin.users.delete.self" },
|
||||
);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { role: true, username: true },
|
||||
});
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
let purgeAt: Date | null;
|
||||
try {
|
||||
({ purgeAt } = await requestAccountDeletion({
|
||||
userId: id,
|
||||
targetIsAdmin: target.role === "ADMIN",
|
||||
requestedById: admin.id,
|
||||
inTransaction: async (tx, scheduledPurgeAt) => {
|
||||
const state = await lockPendingAccount(tx, id);
|
||||
if (!state) throw new DeletionRefusal("not_found");
|
||||
if (state.managedProfile)
|
||||
throw new DeletionRefusal("managed_profile");
|
||||
if (state.pending) throw new DeletionRefusal("pending");
|
||||
const details = {
|
||||
targetUserId: id,
|
||||
username: target.username,
|
||||
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
|
||||
};
|
||||
await auditLog("admin.user.delete", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details,
|
||||
client: tx,
|
||||
});
|
||||
// On the account itself too, so its own activity shows who asked.
|
||||
// An immediate erasure takes this row with the account's history.
|
||||
await auditLog("user.account.delete", {
|
||||
userId: id,
|
||||
actorUserId: admin.id,
|
||||
details: { ...details, requestedBy: "admin" },
|
||||
client: tx,
|
||||
});
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof DeletionRefusal) {
|
||||
return deletionRefusalResponse(error.kind);
|
||||
}
|
||||
if (error instanceof LastAdminError) {
|
||||
return apiError("The last admin account cannot be deleted", 400);
|
||||
}
|
||||
if (error instanceof LastManagedGuardianError) {
|
||||
return apiError(
|
||||
"This account is the last Guardian of a managed profile. Add another Guardian or delete the profile first",
|
||||
409,
|
||||
{ errorCode: "managed_profile.guardian.required" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
annotate({ meta: { deletion_scheduled: purgeAt !== null } });
|
||||
return apiSuccess({
|
||||
deleted: true,
|
||||
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -100,6 +100,7 @@ describe("GET /api/admin/users", () => {
|
||||
// BigInt in Prisma, plain number on the wire.
|
||||
documentQuotaBytes: 2_147_483_648,
|
||||
passkeyCount: 2,
|
||||
managedProfile: false,
|
||||
// Not pending deletion.
|
||||
deletion: null,
|
||||
});
|
||||
@@ -128,6 +129,7 @@ describe("GET /api/admin/users", () => {
|
||||
deletionRequestedAt: true,
|
||||
deletionPurgeAt: true,
|
||||
deletionRequestedById: true,
|
||||
managedProfileAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
});
|
||||
expect(args.select).not.toHaveProperty("passwordHash");
|
||||
|
||||
@@ -21,6 +21,7 @@ export const GET = apiHandler(async () => {
|
||||
deletionRequestedAt: true,
|
||||
deletionPurgeAt: true,
|
||||
deletionRequestedById: true,
|
||||
managedProfileAt: true,
|
||||
_count: { select: { passkeys: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -42,9 +43,12 @@ export const GET = apiHandler(async () => {
|
||||
documentQuotaBytes:
|
||||
u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes),
|
||||
passkeyCount: u._count.passkeys,
|
||||
// A record somebody else runs. Its Guardians delete it, so the list
|
||||
// offers no admin deletion on it.
|
||||
managedProfile: u.managedProfileAt != null,
|
||||
// An account pending deletion: when it was asked for, when the
|
||||
// purge job erases it, and whether the account asked itself or a
|
||||
// Guardian deleted the managed profile. Null on every other account.
|
||||
// purge job erases it, and who asked: the account itself, a Guardian
|
||||
// (a managed profile), or an admin. Null on every other account.
|
||||
// The list offers "Undo deletion" on these rows until `purgeAt`.
|
||||
deletion: u.deletionRequestedAt
|
||||
? {
|
||||
@@ -53,7 +57,9 @@ export const GET = apiHandler(async () => {
|
||||
requestedBy:
|
||||
u.deletionRequestedById === u.id
|
||||
? ("self" as const)
|
||||
: ("guardian" as const),
|
||||
: u.managedProfileAt != null
|
||||
? ("guardian" as const)
|
||||
: ("admin" as const),
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
|
||||
@@ -22,19 +22,21 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { countRemainingRecoveryCodes } from "@/lib/auth/mfa/recovery-codes";
|
||||
import { recentProofMethods } from "@/lib/auth/second-factor";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const [recoveryCodesRemaining, webauthn] = await Promise.all([
|
||||
const [recoveryCodesRemaining, webauthn, proofMethods] = await Promise.all([
|
||||
countRemainingRecoveryCodes(user.id),
|
||||
prisma.webauthnMfaCredential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { id: true, name: true, createdAt: true, lastUsedAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
recentProofMethods(user),
|
||||
]);
|
||||
|
||||
annotate({ action: { name: "auth.mfa.status" } });
|
||||
@@ -46,5 +48,9 @@ export const GET = apiHandler(async () => {
|
||||
// The security hub also drives the passkey-upgrade nudge from this one
|
||||
// status read, so it knows whether the user has already dismissed it.
|
||||
passkeyNudgeDismissed: Boolean(user.passkeyUpgradeNudgeDismissed),
|
||||
// The ways this account can confirm it is you, the list the recent-proof
|
||||
// refusal names. The passkey card offers exactly these before adding one,
|
||||
// so an account without a password is not handed a password field.
|
||||
proofMethods,
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,21 @@ const SERVER_FEATURES = {
|
||||
lifeCircumstances: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* v1.7.0 — patient-identity fields for the health-record export. The KVNR is
|
||||
* stored encrypted; decrypt fail-soft so a key-rotation gap on one row never
|
||||
* 500s the whole profile fetch (the field just reads null and the user
|
||||
* re-enters it).
|
||||
*/
|
||||
function readInsuranceNumber(encrypted: string | null | undefined) {
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return decrypt(encrypted);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const auth = await requireActorAuth();
|
||||
const { user } = auth;
|
||||
@@ -289,22 +304,14 @@ export const GET = apiHandler(async () => {
|
||||
sectionsOpen(sections, "cycle") &&
|
||||
isCycleEnabled(record?.gender ?? null, cycleProfile);
|
||||
|
||||
// v1.7.0 — patient-identity fields for the health-record export. The
|
||||
// KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on
|
||||
// one row never 500s the whole profile fetch (the field just reads
|
||||
// null and the user re-enters it).
|
||||
let insuranceNumber: string | null = null;
|
||||
if (identity?.insuranceNumberEncrypted) {
|
||||
try {
|
||||
insuranceNumber = decrypt(identity.insuranceNumberEncrypted);
|
||||
} catch {
|
||||
insuranceNumber = null;
|
||||
}
|
||||
}
|
||||
const insuranceNumber = readInsuranceNumber(
|
||||
identity?.insuranceNumberEncrypted,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
createdAt: user.createdAt,
|
||||
email: user.email,
|
||||
role: user.role ?? "USER",
|
||||
heightCm: user.heightCm,
|
||||
|
||||
@@ -47,6 +47,11 @@ import {
|
||||
} from "@/lib/managed-profiles/claim-sso";
|
||||
import { pendingDeletionOf } from "@/lib/account-deletion/pending";
|
||||
import { isHandoverUsable } from "@/lib/managed-profiles/handover";
|
||||
import {
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
|
||||
const LOGIN_ERROR_URL = "/auth/login";
|
||||
|
||||
@@ -103,6 +108,20 @@ function deletionPendingRedirect(purgeAt: Date | null): NextResponse {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* #1296 — a re-proof returns to the page it left, never to the sign-in page:
|
||||
* the person is still signed in, and the session they came from is the one
|
||||
* thing that leg may touch. `reason` null is the success.
|
||||
*/
|
||||
function reproofRedirect(next: string, reason: string | null): NextResponse {
|
||||
if (reason) annotate({ meta: { reason } });
|
||||
const response = NextResponse.redirect(
|
||||
reproofReturnUrl(next, reason ? "failed" : "done"),
|
||||
);
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state cookie is set with `path: "/api/auth/oidc"`; the delete must
|
||||
* repeat that path (RFC 6265 keys cookies by name+domain+path — a bare
|
||||
@@ -139,6 +158,10 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const stored = readStoredOidcState(req.cookies.get(OIDC_STATE_COOKIE)?.value);
|
||||
const isNative = stored?.native === true;
|
||||
const isClaim = stored?.intent === "claim";
|
||||
// #1296 — a re-proof returns to the page it left (`reproofRedirect`).
|
||||
const isReproof = stored?.intent === "reproof";
|
||||
const reproofBack = (reason: string | null) =>
|
||||
reproofRedirect(stored?.next ?? "/", reason);
|
||||
|
||||
// Native-aware error redirect: the custom scheme for a decrypted native flow,
|
||||
// the web login page otherwise. Deletes the single-use state cookie either
|
||||
@@ -158,6 +181,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
};
|
||||
|
||||
const failRedirect = (reason: string): NextResponse => {
|
||||
if (isReproof) return reproofBack(reason);
|
||||
if (isClaim) return claimRedirect(reason);
|
||||
if (isNative) {
|
||||
const response = NextResponse.redirect(
|
||||
@@ -199,42 +223,11 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// resolve to the web `oidc_state` fallback: the flow is fundamentally broken
|
||||
// and there is nothing safe to hand the app (spec §1 web-fallback).
|
||||
if (!code || !state) {
|
||||
if (isReproof) return reproofBack("oidc_state");
|
||||
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (!stored) {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
||||
// first callback the moment it succeeds. A second callback for the same
|
||||
// sign-in (a client/proxy retry, a double navigation, or the
|
||||
// service-worker double-request this release also fixes) therefore arrives
|
||||
// with the state cookie already gone — `stored` is null — and, because the
|
||||
// first callback set the session, it carries a live session cookie.
|
||||
// Redeeming the now-consumed authorization code again fails at the IdP and
|
||||
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
||||
// when a VALIDATED session is present, treat this as a duplicate of a
|
||||
// completed login and send the user into the app instead of erroring.
|
||||
//
|
||||
// Fail-closed is preserved for the real attack case: the code is NEVER
|
||||
// exchanged on this branch, and the session is resolved against the
|
||||
// database + its expiry (not merely "a cookie is present"). A forged,
|
||||
// replayed, or expired state from a caller with no valid session gets the
|
||||
// unchanged `oidc_state` error; a caller who already holds their own valid
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
const response = NextResponse.redirect(oidcAppUrl("/"));
|
||||
// The state cookie is already consumed; delete defensively so a stray
|
||||
// blob can never outlive this response.
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
if (!stored) return stateMissing(req);
|
||||
|
||||
// CSRF check — timing-safe, byte-for-byte, mirroring the Withings callback.
|
||||
if (
|
||||
@@ -242,9 +235,12 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
!timingSafeEqual(Buffer.from(state), Buffer.from(stored.state))
|
||||
) {
|
||||
annotate({ meta: { reason: "csrf" } });
|
||||
if (isReproof) return reproofBack("oidc_state");
|
||||
return isClaim ? claimRedirect("oidc_state") : errorRedirect("oidc_state");
|
||||
}
|
||||
|
||||
if (isReproof) return finishReproof(req, stored, config, ip, reproofBack);
|
||||
|
||||
// v1.43 (#959) — a claim is never finished over a live session (the claim
|
||||
// routes' rule: a Guardian finishing their own link would otherwise be
|
||||
// signed out of their account and into the person's), and is admitted only
|
||||
@@ -408,3 +404,90 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
return failRedirect("oidc_failed");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* #1296 — the re-proof leg. The browser must still carry the session the
|
||||
* start bound into the state, checked before the code is redeemed; then the
|
||||
* provider's answer must name this account's linked identity and an
|
||||
* authentication inside the gate's window. Only then is `reproofAt` stamped.
|
||||
* No session is created, and every refusal lands back on the page it left.
|
||||
*/
|
||||
async function finishReproof(
|
||||
req: NextRequest,
|
||||
stored: NonNullable<ReturnType<typeof readStoredOidcState>>,
|
||||
config: NonNullable<ReturnType<typeof getOidcConfig>>,
|
||||
ip: string,
|
||||
back: (reason: string | null) => NextResponse,
|
||||
): Promise<NextResponse> {
|
||||
const live = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (!isReproofSession(stored, live)) return back("session_mismatch");
|
||||
|
||||
try {
|
||||
const metadata = await discoverOidcMetadata(config);
|
||||
const tokens = await exchangeCodeForTokens({
|
||||
metadata,
|
||||
config,
|
||||
code: req.nextUrl.searchParams.get("code") as string,
|
||||
codeVerifier: stored.codeVerifier,
|
||||
redirectUri: getOidcRedirectUri(),
|
||||
});
|
||||
if (!tokens.id_token) return back("oidc_failed");
|
||||
const identity = await verifyIdToken({
|
||||
metadata,
|
||||
config,
|
||||
idToken: tokens.id_token,
|
||||
nonce: stored.nonce,
|
||||
});
|
||||
const outcome = await recordOidcReproof({
|
||||
userId: stored.reproofUserId as string,
|
||||
sessionId: stored.reproofSessionId as string,
|
||||
issuer: metadata.issuer,
|
||||
identity,
|
||||
ipAddress: ip,
|
||||
});
|
||||
return back(outcome.ok ? null : outcome.reason);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return back("exchange_or_verify_failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A callback that arrives without its state blob: the duplicate of a sign-in
|
||||
* that already completed, or a forged, replayed or expired one.
|
||||
*/
|
||||
async function stateMissing(req: NextRequest): Promise<NextResponse> {
|
||||
// Defence-in-depth idempotency for a duplicated callback. The single-use
|
||||
// state cookie is set with `path: "/api/auth/oidc"` and DELETED by the
|
||||
// first callback the moment it succeeds. A second callback for the same
|
||||
// sign-in (a client/proxy retry, a double navigation, or the
|
||||
// service-worker double-request this release also fixes) therefore arrives
|
||||
// with the state cookie already gone — `stored` is null — and, because the
|
||||
// first callback set the session, it carries a live session cookie.
|
||||
// Redeeming the now-consumed authorization code again fails at the IdP and
|
||||
// surfaces `?error=oidc_failed` on a sign-in that actually succeeded. So
|
||||
// when a VALIDATED session is present, treat this as a duplicate of a
|
||||
// completed login and send the user into the app instead of erroring.
|
||||
//
|
||||
// Fail-closed is preserved for the real attack case: the code is NEVER
|
||||
// exchanged on this branch, and the session is resolved against the
|
||||
// database + its expiry (not merely "a cookie is present"). A forged,
|
||||
// replayed, or expired state from a caller with no valid session gets the
|
||||
// unchanged `oidc_state` error; a caller who already holds their own valid
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
const response = NextResponse.redirect(oidcAppUrl("/"));
|
||||
// The state cookie is already consumed; delete defensively so a stray
|
||||
// blob can never outlive this response.
|
||||
deleteStateCookie(response);
|
||||
return response;
|
||||
}
|
||||
return errorRedirect("oidc_state");
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler, requireCookieAuth } from "@/lib/api-handler";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { s256Challenge } from "@/lib/mcp/oauth/pkce";
|
||||
import { recentProofMethods } from "@/lib/auth/second-factor";
|
||||
import { throttleReproof } from "@/lib/auth/existing-factor-proof";
|
||||
import {
|
||||
buildAuthorizationUrl,
|
||||
discoverOidcMetadata,
|
||||
getOidcConfig,
|
||||
getOidcRedirectUri,
|
||||
sanitizeOidcNextPath,
|
||||
} from "@/lib/auth/oidc";
|
||||
import {
|
||||
OIDC_STATE_COOKIE,
|
||||
OIDC_STATE_COOKIE_PATH,
|
||||
OIDC_STATE_TTL_MS,
|
||||
} from "@/lib/auth/oidc-cookie";
|
||||
import { OIDC_REPROOF_STAGE, reproofReturnUrl } from "@/lib/auth/oidc-reproof";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Start confirming it is you at the identity provider (#1296).
|
||||
*
|
||||
* A top-level navigation from the re-proof dialog, cookie session only: a
|
||||
* Bearer token never re-proves this way, because a stamp on a session row
|
||||
* means nothing to a token (`requireCookieAuth` reads the session cookie and
|
||||
* nothing else). Offered only where `recentProofMethods` lists `oidc`, which
|
||||
* is an account without a second factor whose identity is linked while a
|
||||
* provider is configured; anything else goes straight back with
|
||||
* `reproof=failed`.
|
||||
*
|
||||
* The round trip asks for a fresh authentication (`prompt=login`,
|
||||
* `max_age=0`) with its own state, nonce and PKCE verifier, sealed into the
|
||||
* same single-use cookie the sign-in uses, together with the account and the
|
||||
* session it confirms. The callback stamps that session's `reproofAt` and
|
||||
* returns to `next` (a same-origin path) with `reproof=done`. It never mints
|
||||
* a session.
|
||||
*
|
||||
* Draws on the account's shared re-proof budget, like every other proof.
|
||||
*/
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
annotate({ action: { name: "auth.oidc.reproof.start" } });
|
||||
const { user, session } = await requireCookieAuth();
|
||||
|
||||
const next = sanitizeOidcNextPath(
|
||||
req.nextUrl.searchParams.get("next"),
|
||||
req.url,
|
||||
);
|
||||
const back = (reason: string): NextResponse => {
|
||||
annotate({ meta: { reason } });
|
||||
return NextResponse.redirect(reproofReturnUrl(next, "failed"));
|
||||
};
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return back("oidc_disabled");
|
||||
|
||||
const methods = await recentProofMethods(user);
|
||||
if (!methods.includes("oidc")) return back("not_offered");
|
||||
|
||||
const limited = await throttleReproof(
|
||||
user.id,
|
||||
getClientIp(req),
|
||||
OIDC_REPROOF_STAGE,
|
||||
);
|
||||
if (limited) return back("rate_limited");
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await discoverOidcMetadata(config);
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
return back("oidc_failed");
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString("base64url");
|
||||
const nonce = randomBytes(32).toString("base64url");
|
||||
const codeVerifier = randomBytes(64).toString("base64url");
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
buildAuthorizationUrl({
|
||||
metadata,
|
||||
config,
|
||||
state,
|
||||
nonce,
|
||||
codeChallenge: s256Challenge(codeVerifier),
|
||||
redirectUri: getOidcRedirectUri(),
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
response.cookies.set(
|
||||
OIDC_STATE_COOKIE,
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier,
|
||||
next,
|
||||
intent: "reproof" as const,
|
||||
reproofUserId: user.id,
|
||||
reproofSessionId: session.id,
|
||||
}),
|
||||
),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: Math.floor(OIDC_STATE_TTL_MS / 1000),
|
||||
path: OIDC_STATE_COOKIE_PATH,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from "@/lib/auth/passkey";
|
||||
import { createMfaAuthenticationOptions } from "@/lib/auth/mfa/webauthn";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
REPROOF_REQUIRED_CODE,
|
||||
isSecondFactorProof,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
@@ -59,6 +61,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess({ ...result, reauth: true });
|
||||
}
|
||||
|
||||
// #1296 — an account whose only credential is its identity provider proves
|
||||
// itself there (`GET /api/auth/oidc/reproof`), which stamps
|
||||
// `Session.reproofAt`. `{ method: "oidc" }` asks this route to take that
|
||||
// stamp as the proof: only where `oidc` is on the account's list (no second
|
||||
// factor, a linked identity, a configured provider) and only inside the
|
||||
// five-minute window. Like a password, it clears the MFA stamp rather than
|
||||
// setting it.
|
||||
if (Object.keys(body).length === 1 && body.method === "oidc") {
|
||||
if (
|
||||
!(await recentProofMethods(user)).includes("oidc") ||
|
||||
!(await hasRecentReproof(session.id))
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.reproof.required" },
|
||||
meta: { stage: "passkey_enroll", method: "oidc" },
|
||||
});
|
||||
return apiError("Confirm it is you before adding a passkey", 401, {
|
||||
errorCode: REPROOF_REQUIRED_CODE,
|
||||
methods: await recentProofMethods(user),
|
||||
});
|
||||
}
|
||||
return issueRegistrationOptions(user, session.id, null);
|
||||
}
|
||||
|
||||
const parsed = stepUpMintSchema.safeParse(body);
|
||||
if (!parsed.success) return proofRequired();
|
||||
|
||||
@@ -101,23 +127,48 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
await refundReproof(user.id);
|
||||
|
||||
// A strong existing factor refreshes the session's MFA stamp. Password proof
|
||||
// deliberately clears it: registration is authorized by this single-use,
|
||||
// session-bound challenge but cannot upgrade password-only authentication.
|
||||
return issueRegistrationOptions(
|
||||
user,
|
||||
session.id,
|
||||
parsed.data.method === "password" ? null : new Date(),
|
||||
);
|
||||
});
|
||||
|
||||
/** A password or provider re-proof on this session inside the window. */
|
||||
async function hasRecentReproof(sessionId: string): Promise<boolean> {
|
||||
const row = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: { reproofAt: true },
|
||||
});
|
||||
return (
|
||||
row?.reproofAt != null &&
|
||||
Date.now() - row.reproofAt.getTime() <= RECENT_PROOF_MAX_AGE_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A strong existing factor refreshes the session's MFA stamp. A password or
|
||||
* provider proof deliberately clears it (`mfaVerifiedAt: null`): registration
|
||||
* is authorized by this single-use, session-bound challenge but cannot upgrade
|
||||
* first-factor authentication.
|
||||
*/
|
||||
async function issueRegistrationOptions(
|
||||
user: { id: string; email: string | null; username: string },
|
||||
sessionId: string,
|
||||
mfaVerifiedAt: Date | null,
|
||||
): Promise<Response> {
|
||||
await prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: {
|
||||
mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(),
|
||||
},
|
||||
where: { id: sessionId },
|
||||
data: { mfaVerifiedAt },
|
||||
});
|
||||
|
||||
const { options, challengeId } = await createRegistrationOptions(
|
||||
user.id,
|
||||
user.email ?? user.username,
|
||||
session.id,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
annotate({ action: { name: "auth.passkey.register-options" } });
|
||||
|
||||
return apiSuccess({ options, challengeId });
|
||||
});
|
||||
}
|
||||
@@ -8,24 +8,10 @@ import {
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
|
||||
import {
|
||||
deleteGuardianAccountWithLifecycle,
|
||||
LastManagedGuardianError,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import {
|
||||
accountDeletionGraceDays,
|
||||
accountDeletionPurgeAt,
|
||||
} from "@/lib/account-deletion/grace";
|
||||
import {
|
||||
eraseAccount,
|
||||
lockAccountForDeletion,
|
||||
} from "@/lib/account-deletion/lifecycle";
|
||||
import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle";
|
||||
import { requestAccountDeletion } from "@/lib/account-deletion/request";
|
||||
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
|
||||
import {
|
||||
assertAnotherAdminStays,
|
||||
LastAdminError,
|
||||
} from "@/lib/auth/admin-roster";
|
||||
import { LastAdminError } from "@/lib/auth/admin-roster";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -104,53 +90,27 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// the elevation would stay redeemable while the erasure was already running.
|
||||
await auth.commitElevation();
|
||||
|
||||
const requestedAt = new Date();
|
||||
const purgeAt = accountDeletionPurgeAt(
|
||||
requestedAt,
|
||||
accountDeletionGraceDays(),
|
||||
);
|
||||
|
||||
let purgeAt: Date | null;
|
||||
try {
|
||||
await deleteGuardianAccountWithLifecycle(
|
||||
({ purgeAt } = await requestAccountDeletion({
|
||||
userId,
|
||||
async (tx) => {
|
||||
// Checked again under the roster lock: two admins deleting at the
|
||||
// same moment must not each count the other.
|
||||
if (user.role === "ADMIN") await assertAnotherAdminStays(tx, userId);
|
||||
// The invariant has now passed, so this success event cannot survive
|
||||
// a refusal. On an immediate erasure it is purged with the rest of the
|
||||
// account's audit history; during a grace window it is the account's
|
||||
// own record of the request.
|
||||
await auditLog("user.account.delete", {
|
||||
targetIsAdmin: user.role === "ADMIN",
|
||||
requestedById: userId,
|
||||
// The invariants have passed by the time this runs, so this success
|
||||
// event cannot survive a refusal. On an immediate erasure it is purged
|
||||
// with the rest of the account's audit history; during a grace window
|
||||
// it is the account's own record of the request.
|
||||
inTransaction: (tx, scheduledPurgeAt) =>
|
||||
auditLog("user.account.delete", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
username,
|
||||
scheduledPurgeAt: purgeAt?.toISOString() ?? null,
|
||||
scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null,
|
||||
},
|
||||
client: tx,
|
||||
});
|
||||
|
||||
if (purgeAt) {
|
||||
// Locked, not erased: credentials revoked in this transaction,
|
||||
// the record untouched until the purge job reaches `purgeAt`.
|
||||
await lockAccountForDeletion(tx, {
|
||||
userId,
|
||||
requestedById: userId,
|
||||
requestedAt,
|
||||
purgeAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No window: erase now, inside the same record lifecycle transaction
|
||||
// so a refusal leaves every account state intact (sessions, the
|
||||
// account's audit history — GDPR Art. 17 — the off-host purge
|
||||
// request, then the row with everything cascading from it).
|
||||
await eraseAccount(tx, userId, "account_deleted");
|
||||
},
|
||||
{ erasing: purgeAt === null },
|
||||
);
|
||||
}),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof LastAdminError) {
|
||||
return apiError("The last admin account cannot be deleted", 400);
|
||||
@@ -165,8 +125,6 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!purgeAt) await kickOffhostPurge();
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.account.delete" },
|
||||
meta: { username, deletion_scheduled: purgeAt !== null },
|
||||
|
||||
@@ -30,6 +30,8 @@ const sampleUsers = [
|
||||
role: "ADMIN" as const,
|
||||
passkeyCount: 2,
|
||||
createdAt: "2026-04-01T08:00:00Z",
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
},
|
||||
{
|
||||
id: "u2",
|
||||
@@ -38,6 +40,8 @@ const sampleUsers = [
|
||||
role: "USER" as const,
|
||||
passkeyCount: 1,
|
||||
createdAt: "2026-04-15T08:00:00Z",
|
||||
managedProfile: false,
|
||||
deletion: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -53,6 +57,10 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/queries/use-privacy-summary", () => ({
|
||||
useAccountDeletionGrace: () => ({ days: 14, pending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
@@ -115,6 +123,15 @@ describe("UserManagementSection — mobile responsive", () => {
|
||||
expect(mobileSlice).toContain("Force logout");
|
||||
});
|
||||
|
||||
it("offers deleting every account but the admin's own", () => {
|
||||
const html = render();
|
||||
// Two rows, each in the desktop table and the mobile list; only the
|
||||
// other account carries the control.
|
||||
const deletes = html.match(/data-slot="admin-user-delete"/g) ?? [];
|
||||
expect(deletes).toHaveLength(2);
|
||||
expect(html).toContain('aria-label="Delete account"');
|
||||
});
|
||||
|
||||
it("uses smaller card padding on mobile (p-4 md:p-6)", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/);
|
||||
|
||||
@@ -34,15 +34,17 @@ export interface AdminUser {
|
||||
// the instance-wide default applies.
|
||||
documentQuotaBytes: number | null;
|
||||
passkeyCount: number;
|
||||
/** A record its Guardians run; they delete it, an admin does not. */
|
||||
managedProfile: boolean;
|
||||
/**
|
||||
* Set while the account is pending deletion: when the purge job
|
||||
* erases it, and whether the account asked itself or a Guardian deleted
|
||||
* the managed profile. The row offers "Undo deletion" until then.
|
||||
* Set while the account is pending deletion: when the purge job erases
|
||||
* it, and who asked (the account itself, a Guardian of the managed
|
||||
* profile, or an admin). The row offers "Undo deletion" until then.
|
||||
*/
|
||||
deletion: {
|
||||
requestedAt: string;
|
||||
purgeAt: string | null;
|
||||
requestedBy: "self" | "guardian";
|
||||
requestedBy: "self" | "guardian" | "admin";
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAccountDeletionGrace } from "@/lib/queries/use-privacy-summary";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import type { AdminUser } from "./_shared";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whether the row offers deleting the account: never the admin's own (that is
|
||||
* the account settings' deletion), never a managed profile (its Guardians
|
||||
* delete it), and not one already pending deletion (the row offers the undo).
|
||||
*/
|
||||
export function canAdminDelete(user: AdminUser, currentUserId: string | null) {
|
||||
return (
|
||||
user.id !== currentUserId && !user.managedProfile && user.deletion === null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day the purge job would erase an account deleted now, or null when the
|
||||
* window is 0 and the erasure happens at once. The server stamps the real
|
||||
* date; this is the one the dialog names before asking.
|
||||
*/
|
||||
export function deletionDateFrom(graceDays: number, now: Date): Date | null {
|
||||
return graceDays > 0 ? new Date(now.getTime() + graceDays * DAY_MS) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Delete account" on a row of the admin users list
|
||||
* (`DELETE /api/admin/users/{id}`), for the rows `canAdminDelete` allows.
|
||||
*/
|
||||
export function DeleteUserControl({
|
||||
user,
|
||||
currentUserId,
|
||||
}: {
|
||||
user: AdminUser;
|
||||
currentUserId: string | null;
|
||||
}) {
|
||||
if (!canAdminDelete(user, currentUserId)) return null;
|
||||
return <DeleteUserButton user={user} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dialog names the date the account is erased and that it can be undone
|
||||
* here until then, or, with no grace window, that it is erased at once. It
|
||||
* waits for the window before it offers anything, so the sentence is never a
|
||||
* guess. The fresh proof the route asks for is collected by the shared
|
||||
* re-proof dialog.
|
||||
*/
|
||||
function DeleteUserButton({ user }: { user: AdminUser }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const grace = useAccountDeletionGrace();
|
||||
const recentProof = useRecentProof();
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () =>
|
||||
recentProof.run(() =>
|
||||
apiDelete<{ scheduledPurgeAt: string | null }>(
|
||||
`/api/admin/users/${user.id}`,
|
||||
{ confirm: "DELETE_ACCOUNT" },
|
||||
),
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.adminUsers() });
|
||||
toastWrittenOutcome(
|
||||
"success",
|
||||
result.scheduledPurgeAt
|
||||
? t("admin.section.users.deleteUserScheduled", {
|
||||
name: user.username,
|
||||
date: formatDate(result.scheduledPurgeAt),
|
||||
})
|
||||
: t("admin.section.users.deleteUserDone", { name: user.username }),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.section.users.deleteUserFailed"),
|
||||
);
|
||||
if (message) showErrorToast(message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
const purgeOn =
|
||||
grace.days === null ? null : deletionDateFrom(grace.days, new Date());
|
||||
|
||||
return (
|
||||
<>
|
||||
{recentProof.dialog}
|
||||
<ConfirmButton
|
||||
slot="admin-user-delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 px-2 text-xs"
|
||||
icon={<Trash2 className="h-3 w-3" aria-hidden="true" />}
|
||||
label=""
|
||||
ariaLabel={t("admin.section.users.deleteUser")}
|
||||
disabled={grace.days === null}
|
||||
title={t("admin.section.users.deleteUserTitle", {
|
||||
name: user.username,
|
||||
})}
|
||||
body={
|
||||
purgeOn
|
||||
? t("admin.section.users.deleteUserBodyGrace", {
|
||||
name: user.username,
|
||||
date: formatDate(purgeOn),
|
||||
})
|
||||
: t("admin.section.users.deleteUserBodyNow", {
|
||||
name: user.username,
|
||||
})
|
||||
}
|
||||
confirmLabel={t("admin.section.users.deleteUserConfirm")}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
destructive
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,13 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { AdminUser } from "./_shared";
|
||||
|
||||
/** The undo dialog's sentence, by who asked for the deletion. */
|
||||
const UNDO_BODY_KEYS = {
|
||||
self: "admin.section.users.undoDeletionBody",
|
||||
guardian: "admin.section.users.undoDeletionBodyGuardian",
|
||||
admin: "admin.section.users.undoDeletionBodyAdmin",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* An account pending deletion, in the admin users list: the date the purge
|
||||
* job erases it, and the one action that stops it.
|
||||
@@ -64,12 +71,9 @@ export function PendingDeletionControls({ user }: { user: AdminUser }) {
|
||||
title={t("admin.section.users.undoDeletionTitle", {
|
||||
name: user.username,
|
||||
})}
|
||||
body={t(
|
||||
user.deletion.requestedBy === "guardian"
|
||||
? "admin.section.users.undoDeletionBodyGuardian"
|
||||
: "admin.section.users.undoDeletionBody",
|
||||
{ name: user.username },
|
||||
)}
|
||||
body={t(UNDO_BODY_KEYS[user.deletion.requestedBy], {
|
||||
name: user.username,
|
||||
})}
|
||||
confirmLabel={t("admin.section.users.undoDeletion")}
|
||||
pending={undo.isPending}
|
||||
onConfirm={() => undo.mutate()}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { type AdminUser, PasswordInput } from "./_shared";
|
||||
import { PendingDeletionControls } from "./pending-deletion-controls";
|
||||
import { DeleteUserControl } from "./delete-user-control";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
@@ -55,6 +56,13 @@ import {
|
||||
*/
|
||||
type UserFilter = "all" | "admin" | "user";
|
||||
|
||||
/** The quota override as the edit form's gigabyte field shows it. */
|
||||
function quotaGbInput(bytes: number | null): string {
|
||||
if (bytes === null) return "";
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
|
||||
return String(Math.round((bytes / 1_073_741_824) * 10) / 10);
|
||||
}
|
||||
|
||||
export function UserManagementSection() {
|
||||
const { t } = useTranslations();
|
||||
const currentUserId = useAuth().user?.id ?? "";
|
||||
@@ -160,12 +168,7 @@ export function UserManagementSection() {
|
||||
setEditingUser(u);
|
||||
setEditUsername(u.username);
|
||||
setEditEmail(u.email ?? "");
|
||||
setEditQuotaGb(
|
||||
u.documentQuotaBytes === null
|
||||
? ""
|
||||
: // eslint-disable-next-line healthlog/no-unlocalised-decimal -- value of a type="number" input, which only takes a full stop
|
||||
String(Math.round((u.documentQuotaBytes / 1_073_741_824) * 10) / 10),
|
||||
);
|
||||
setEditQuotaGb(quotaGbInput(u.documentQuotaBytes));
|
||||
}
|
||||
|
||||
function startReset(u: AdminUser) {
|
||||
@@ -229,7 +232,7 @@ export function UserManagementSection() {
|
||||
{u.mfaEnforced ? t("admin.mfaEnforcedOn") : t("admin.mfaEnforcedOff")}
|
||||
<span className="sr-only">, {u.username}</span>
|
||||
</Button>
|
||||
{/* The three icon tools travel as one group: on a phone the row wraps
|
||||
{/* The icon tools travel as one group: on a phone the row wraps
|
||||
between the labelled toggles and the tools, never leaving the
|
||||
sign-out icon alone on a line. */}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
@@ -272,6 +275,7 @@ export function UserManagementSection() {
|
||||
>
|
||||
<LogOut className="h-3 w-3" aria-hidden="true" />
|
||||
</Button>
|
||||
<DeleteUserControl user={u} currentUserId={currentUserId} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { dropParams, writeUrlState } from "@/lib/url-state";
|
||||
|
||||
/**
|
||||
* The outcome a confirmation at the identity provider came back with
|
||||
* (`?reproof=done|failed`, set by the OIDC callback). Pure, so the parsing is
|
||||
* testable without a browser.
|
||||
*/
|
||||
export function readReproofReturn(search: string): "done" | "failed" | null {
|
||||
const raw = new URLSearchParams(search).get("reproof");
|
||||
return raw === "done" || raw === "failed" ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Says once how the confirmation at the identity provider went, then takes the
|
||||
* marker off the address so a reload does not say it again. The browser lands
|
||||
* on the page it left, which is where the person repeats what they were
|
||||
* about to do.
|
||||
*/
|
||||
export function OidcReproofReturnBridge() {
|
||||
const { t } = useTranslations();
|
||||
useEffect(() => {
|
||||
const outcome = readReproofReturn(window.location.search);
|
||||
if (!outcome) return;
|
||||
writeUrlState(dropParams("reproof"), { mode: "replace", state: "keep" });
|
||||
toastWrittenOutcome(
|
||||
outcome === "done" ? "success" : "failed",
|
||||
outcome === "done"
|
||||
? t("settings.reproof.oidcDone")
|
||||
: t("settings.reproof.oidcFailed"),
|
||||
);
|
||||
// A later run (a locale switch) finds no marker and does nothing.
|
||||
}, [t]);
|
||||
return null;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { DashboardHeader } from "../dashboard-header";
|
||||
import { DashboardHeader, greetingLine } from "../dashboard-header";
|
||||
import {
|
||||
CAPTURE_KIND_ORDER,
|
||||
visibleCaptureKinds,
|
||||
@@ -58,12 +58,73 @@ function renderSSR(node: React.ReactElement, locale: "en" | "de" = "de") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("greetingLine", () => {
|
||||
const now = Date.parse("2026-10-29T09:00:00Z");
|
||||
const base = { greeting: "Hello", nowMs: now };
|
||||
|
||||
it("welcomes an account in its first day, without a 'back'", () => {
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: true,
|
||||
name: "tester",
|
||||
createdAt: "2026-10-29T08:50:00Z",
|
||||
}),
|
||||
).toEqual({
|
||||
key: "dashboard.welcomeNewWithName",
|
||||
values: { name: "tester" },
|
||||
});
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: true,
|
||||
name: null,
|
||||
createdAt: "2026-10-28T10:00:00Z",
|
||||
}).key,
|
||||
).toBe("dashboard.welcomeNew");
|
||||
});
|
||||
|
||||
it("welcomes back an account older than a day", () => {
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: true,
|
||||
name: "tester",
|
||||
createdAt: "2026-10-20T08:00:00Z",
|
||||
}),
|
||||
).toEqual({
|
||||
key: "dashboard.welcomeBackWithName",
|
||||
values: { greeting: "Hello", name: "tester" },
|
||||
});
|
||||
});
|
||||
|
||||
it("welcomes back when an older server does not say when the account began", () => {
|
||||
expect(
|
||||
greetingLine({ ...base, known: true, name: null, createdAt: undefined })
|
||||
.key,
|
||||
).toBe("dashboard.welcomeBack");
|
||||
});
|
||||
|
||||
it("says only the greeting while the account is not known", () => {
|
||||
expect(
|
||||
greetingLine({
|
||||
...base,
|
||||
known: false,
|
||||
name: "tester",
|
||||
createdAt: "2026-10-29T08:50:00Z",
|
||||
}),
|
||||
).toEqual({ key: "dashboard.greetingOnly", values: { greeting: "Hello" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DashboardHeader> — greeting", () => {
|
||||
it("renders the greeting line under the title", () => {
|
||||
const html = renderSSR(<DashboardHeader onQuickEntry={() => undefined} />);
|
||||
expect(html).toContain('data-slot="dashboard-header-greeting"');
|
||||
// SSR pass: name-less fallback (personalises post-hydration only).
|
||||
expect(html).toContain("willkommen zurück.");
|
||||
// SSR pass: the time-of-day greeting alone. Nothing says "welcome
|
||||
// back" before the client knows the account is not new.
|
||||
expect(html).toContain("Hallo.");
|
||||
expect(html).not.toContain("willkommen zurück");
|
||||
// Reserved line box keeps the header height stable through the
|
||||
// post-hydration personalisation swap.
|
||||
expect(html).toMatch(/data-slot="dashboard-header-greeting"[^>]*>/);
|
||||
|
||||
@@ -42,6 +42,43 @@ import {
|
||||
} from "@/components/layout/capture-picker";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
|
||||
const FIRST_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The greeting line, as an i18n key and its values.
|
||||
*
|
||||
* Until the account is known (the SSR pass and the hydration render) it is the
|
||||
* time-of-day greeting alone, so nothing claims "welcome back" before the
|
||||
* client knows it is true. An account in its first day (`createdAt` from
|
||||
* `/api/auth/me`) is welcomed, not welcomed back: the setup flow lands a new
|
||||
* account here minutes after it was created. Any other account, and one whose
|
||||
* server does not say when it was created, is welcomed back.
|
||||
*/
|
||||
export function greetingLine(input: {
|
||||
greeting: string;
|
||||
known: boolean;
|
||||
name: string | null;
|
||||
createdAt: string | null | undefined;
|
||||
/** The clock, for tests; the moment of the call otherwise. */
|
||||
nowMs?: number;
|
||||
}): { key: string; values: Record<string, string> } {
|
||||
const { greeting, name } = input;
|
||||
const nowMs = input.nowMs ?? Date.now();
|
||||
if (!input.known) {
|
||||
return { key: "dashboard.greetingOnly", values: { greeting } };
|
||||
}
|
||||
const created = input.createdAt ? Date.parse(input.createdAt) : Number.NaN;
|
||||
const isNew = Number.isFinite(created) && nowMs - created < FIRST_DAY_MS;
|
||||
if (isNew) {
|
||||
return name
|
||||
? { key: "dashboard.welcomeNewWithName", values: { name } }
|
||||
: { key: "dashboard.welcomeNew", values: {} };
|
||||
}
|
||||
return name
|
||||
? { key: "dashboard.welcomeBackWithName", values: { greeting, name } }
|
||||
: { key: "dashboard.welcomeBack", values: { greeting } };
|
||||
}
|
||||
|
||||
export function DashboardHeader({
|
||||
onQuickEntry,
|
||||
}: {
|
||||
@@ -103,22 +140,22 @@ export function DashboardHeader({
|
||||
: hour >= 12 && hour < 18
|
||||
? t("dashboard.greeting.day")
|
||||
: t("dashboard.greeting.evening");
|
||||
const welcomeText =
|
||||
mounted && user?.username && user.username.trim().length > 0
|
||||
? t("dashboard.welcomeBackWithName", {
|
||||
greeting: timeGreeting,
|
||||
name: user.username,
|
||||
})
|
||||
: t("dashboard.welcomeBack", { greeting: timeGreeting });
|
||||
const greeting = greetingLine({
|
||||
greeting: timeGreeting,
|
||||
known: mounted && user != null,
|
||||
name: user?.username?.trim() ? user.username : null,
|
||||
createdAt: user?.createdAt,
|
||||
});
|
||||
const welcomeText = t(greeting.key, greeting.values);
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
title={t("dashboard.title")}
|
||||
description={
|
||||
/* `min-h-5` reserves the greeting's line box from the SSR
|
||||
pass on: the text personalises (name appended) on the first
|
||||
client re-render after hydration, and the reserved line
|
||||
keeps the header from collapsing/growing around that swap. */
|
||||
pass on: the text personalises (name and welcome appended) on
|
||||
the first client re-render after hydration, and the reserved
|
||||
line keeps the header from collapsing/growing around that swap. */
|
||||
<span
|
||||
data-slot="dashboard-header-greeting"
|
||||
className="block min-h-5 truncate"
|
||||
|
||||
@@ -895,7 +895,7 @@ export interface SendCoachMessageParams {
|
||||
conversationId?: string;
|
||||
message: string;
|
||||
prefill?: string;
|
||||
locale?: "en" | "de";
|
||||
locale?: import("@/lib/i18n/config").Locale;
|
||||
/**
|
||||
* v1.4.20.1 — optional snapshot scope (per-source toggles + window)
|
||||
* driven by the sources-rail picker. When omitted the server falls
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* The language reaches the account only when there is an account.
|
||||
*
|
||||
* The provider used to PUT the locale on every mount, signed in or not. A
|
||||
* signed-out page got a 401, the once-per-tab marker was never set, and every
|
||||
* signed-out page load sent the refused write again. The write now belongs to
|
||||
* `LocaleServerSyncBridge`, which waits for the account payload without
|
||||
* fetching it and then sends the choice once.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { authUser, renderWithApp } from "../../../../tests/browser/render";
|
||||
import { stubFetch } from "../../../../tests/browser/fetch-stub";
|
||||
import { LocaleServerSyncBridge } from "../locale-server-sync-bridge";
|
||||
|
||||
const LOCALE_PUT = "/api/auth/me/locale";
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
describe("LocaleServerSyncBridge", () => {
|
||||
it("sends nothing while nobody is signed in, and never asks who is", async () => {
|
||||
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
|
||||
await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: null,
|
||||
locale: "de",
|
||||
});
|
||||
await settle();
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
|
||||
expect(fetchStub.calls("GET", "/api/auth/me")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends the choice made while signed out once an account appears", async () => {
|
||||
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
|
||||
const { queryClient } = await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: null,
|
||||
locale: "de",
|
||||
});
|
||||
await settle();
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(0);
|
||||
|
||||
queryClient.setQueryData(
|
||||
queryKeys.authMe(),
|
||||
authUser({ id: "account-signed-in-later" }),
|
||||
);
|
||||
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)[0].body).toEqual({
|
||||
locale: "de",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes once per tab for the same account and language", async () => {
|
||||
const fetchStub = stubFetch().on("PUT", LOCALE_PUT, { data: null });
|
||||
const first = await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: { id: "account-already-synced" },
|
||||
locale: "en",
|
||||
});
|
||||
await expect.poll(() => fetchStub.calls("PUT", LOCALE_PUT).length).toBe(1);
|
||||
await first.screen.unmount();
|
||||
|
||||
await renderWithApp(<LocaleServerSyncBridge />, {
|
||||
user: { id: "account-already-synced" },
|
||||
locale: "en",
|
||||
});
|
||||
await settle();
|
||||
expect(fetchStub.calls("PUT", LOCALE_PUT)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { fetchMe } from "@/hooks/use-auth";
|
||||
import { persistLocaleToServer, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* Keeps `User.locale` in step with the language on screen, for a signed-in
|
||||
* account only, so the proactive Coach nudge and the other cookie-blind
|
||||
* background paths write in the language the person reads.
|
||||
*
|
||||
* It watches the account payload without fetching it (`enabled: false`): the
|
||||
* shell already reads `/api/auth/me` on every signed-in page, and a signed-out
|
||||
* page has no account to write to. Fetching here would trade the refused
|
||||
* locale PUT for a refused account read on every signed-out load. Same key
|
||||
* and same fetcher as `useAuth`, so the cache entry is shared, not shadowed.
|
||||
*
|
||||
* Fires when an account appears (right after signing in, carrying the choice
|
||||
* made on the sign-in page) and on every switch; `persistLocaleToServer`
|
||||
* skips a value this tab already wrote for this account.
|
||||
*/
|
||||
export function LocaleServerSyncBridge() {
|
||||
const { locale } = useTranslations();
|
||||
const { data: account } = useQuery({
|
||||
queryKey: queryKeys.authMe(),
|
||||
queryFn: fetchMe,
|
||||
enabled: false,
|
||||
});
|
||||
const accountId = account?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) persistLocaleToServer(locale, accountId);
|
||||
}, [locale, accountId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { VersionPoller } from "@/components/version-poller";
|
||||
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
||||
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
||||
import { RecordSessionFenceBridge } from "@/components/layout/record-session-fence-bridge";
|
||||
import { OidcReproofReturnBridge } from "@/components/auth/oidc-reproof-return";
|
||||
import { LocaleServerSyncBridge } from "@/components/layout/locale-server-sync-bridge";
|
||||
import { useAuth, type AccountAccessStatus } from "@/hooks/use-auth";
|
||||
import { useRecordSessionTransition } from "@/hooks/use-record-session-transition";
|
||||
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
||||
@@ -432,6 +434,8 @@ export function Providers({
|
||||
in its own account, rather than asking every call site to. */}
|
||||
<SharedRecordGrantLossBridge />
|
||||
<RecordSessionFenceBridge />
|
||||
<OidcReproofReturnBridge />
|
||||
<LocaleServerSyncBridge />
|
||||
{children}
|
||||
<AppToaster />
|
||||
<KeyboardInsetBridge />
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* The web side of confirming at the identity provider (#1296): the dialogs
|
||||
* offer it when the server names it, the round trip starts from and returns
|
||||
* to the page the person is on, and the outcome marker is read once.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { oidcReproofStartUrl } from "../existing-factor-reauth-dialog";
|
||||
import { passkeyProofChoices } from "../passkey-list-section";
|
||||
import { createRecentProofFlow } from "../recent-proof-flow";
|
||||
import { readReproofReturn } from "@/components/auth/oidc-reproof-return";
|
||||
|
||||
describe("passkeyProofChoices", () => {
|
||||
it("offers only what the account can give once its list is known", () => {
|
||||
expect(passkeyProofChoices(["oidc"])).toEqual(["oidc"]);
|
||||
expect(passkeyProofChoices(["password", "oidc", "passkey"])).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
expect(passkeyProofChoices(["totp", "something-new"])).toEqual(["totp"]);
|
||||
});
|
||||
|
||||
it("falls back to the four body proofs while the list is loading", () => {
|
||||
expect(passkeyProofChoices(undefined)).toEqual([
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("oidcReproofStartUrl", () => {
|
||||
it("names the current page as the way back, without an old outcome", () => {
|
||||
const href = oidcReproofStartUrl({
|
||||
pathname: "/settings/api",
|
||||
search: "?tab=mcp&reproof=failed",
|
||||
});
|
||||
const url = new URL(href, "https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/api/auth/oidc/reproof");
|
||||
expect(url.searchParams.get("next")).toBe("/settings/api?tab=mcp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readReproofReturn", () => {
|
||||
it("reads a known outcome off the query string", () => {
|
||||
expect(readReproofReturn("?tab=mcp&reproof=done")).toBe("done");
|
||||
expect(readReproofReturn("?reproof=failed")).toBe("failed");
|
||||
});
|
||||
|
||||
it("ignores an address without a known outcome", () => {
|
||||
expect(readReproofReturn("")).toBeNull();
|
||||
expect(readReproofReturn("?reproof=maybe")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof dialog flow", () => {
|
||||
it("opens with the identity provider when that is the account's only way", async () => {
|
||||
const setMethods = vi.fn();
|
||||
const flow = createRecentProofFlow({
|
||||
view: { setMethods, setPending: vi.fn(), setError: vi.fn() },
|
||||
submit: vi.fn(),
|
||||
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
|
||||
});
|
||||
void flow
|
||||
.run(async () => {
|
||||
throw new ApiError("Confirm it is you", 401, {
|
||||
errorCode: "auth.reproof.required",
|
||||
methods: ["oidc"],
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
await vi.waitFor(() => expect(setMethods).toHaveBeenCalledWith(["oidc"]));
|
||||
flow.cancel();
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,30 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
export type ReauthMethod = "password" | "totp" | "passkey" | "webauthn";
|
||||
export type ReauthMethod =
|
||||
"password" | "oidc" | "totp" | "passkey" | "webauthn";
|
||||
|
||||
/**
|
||||
* Where the browser goes to confirm at the identity provider: the re-proof
|
||||
* start, told to come back to the page it is on now. A `reproof` outcome left
|
||||
* on the current address from an earlier round trip is dropped, so the page
|
||||
* does not come back announcing the old one.
|
||||
*/
|
||||
export function oidcReproofStartUrl(current: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
}): string {
|
||||
const params = new URLSearchParams(current.search);
|
||||
params.delete("reproof");
|
||||
const query = params.toString();
|
||||
const next = `${current.pathname}${query ? `?${query}` : ""}`;
|
||||
return `/api/auth/oidc/reproof?${new URLSearchParams({ next }).toString()}`;
|
||||
}
|
||||
|
||||
/** Leave for the identity provider. The page unloads; nothing resolves. */
|
||||
export function startOidcReproof(): void {
|
||||
window.location.assign(oidcReproofStartUrl(window.location));
|
||||
}
|
||||
|
||||
/** The body the enrollment routes accept as a fresh existing-factor proof. */
|
||||
export type ExistingFactorProof =
|
||||
@@ -46,23 +69,19 @@ export function isReproofRequired(err: unknown): boolean {
|
||||
|
||||
const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
/**
|
||||
* The refusal says the account holds nothing it could re-prove here: an
|
||||
* account that signs in through an identity provider only, with no password,
|
||||
* passkey or second factor. A dialog would only offer it a password it does
|
||||
* not have, so the callers say "sign in again" instead — a fresh sign-in is
|
||||
* the proof such an account can give (`Session.createdAt` within five
|
||||
* minutes).
|
||||
* The refusal says the account holds nothing it could re-prove here: no
|
||||
* password, passkey or second factor, and no identity provider to confirm at
|
||||
* (an account whose provider the operator has since switched off). A dialog
|
||||
* would only offer it a password it does not have, so the callers say "sign
|
||||
* in again" instead — a fresh sign-in is the proof such an account can give
|
||||
* (`Session.createdAt` within five minutes).
|
||||
*/
|
||||
export function nothingToReprove(err: unknown): boolean {
|
||||
return (
|
||||
@@ -72,6 +91,11 @@ export function nothingToReprove(err: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs the refusal says this account can give, or null when it names
|
||||
* none. On an account with a second factor the server leaves the password out,
|
||||
* so the dialog does not offer a choice that can only be refused.
|
||||
*/
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
@@ -87,7 +111,9 @@ export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
* new factor is added. A passkey or security-key assertion begins at the
|
||||
* cookie-only `/api/auth/passkey/register-options` re-proof arm, which issues
|
||||
* an assertion challenge and nothing else; the resulting proof is handed to
|
||||
* `onProof`, which retries the enrollment call with it.
|
||||
* `onProof`, which retries the enrollment call with it. The identity provider
|
||||
* is not a proof in the body: choosing it leaves the page for the provider and
|
||||
* comes back to it, and the action is then repeated by hand.
|
||||
*/
|
||||
export function ExistingFactorReauthDialog({
|
||||
open,
|
||||
@@ -117,6 +143,11 @@ export function ExistingFactorReauthDialog({
|
||||
|
||||
async function submit() {
|
||||
setAssertionError(null);
|
||||
if (method === "oidc") {
|
||||
setAsserting(true);
|
||||
startOidcReproof();
|
||||
return;
|
||||
}
|
||||
if (method === "password") {
|
||||
onProof({ method, password: secret });
|
||||
return;
|
||||
@@ -152,6 +183,7 @@ export function ExistingFactorReauthDialog({
|
||||
|
||||
const labels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
oidc: t("settings.passkeyReauth.methods.oidc"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
@@ -223,6 +255,10 @@ export function ExistingFactorReauthDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{method === "oidc" && (
|
||||
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
|
||||
)}
|
||||
|
||||
{shown && (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -253,7 +289,9 @@ export function ExistingFactorReauthDialog({
|
||||
{busy && (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("settings.passkeyReauth.verifyAndContinue")}
|
||||
{method === "oidc"
|
||||
? t("settings.passkeyReauth.continueToProvider")
|
||||
: t("settings.passkeyReauth.verifyAndContinue")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface MfaStatus {
|
||||
recoveryCodesRemaining: number;
|
||||
webauthn: WebauthnKeyInfo[];
|
||||
passkeyNudgeDismissed: boolean;
|
||||
proofMethods: string[];
|
||||
}
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -71,7 +72,10 @@ export function SecuritySection() {
|
||||
totpEnabled={status.totp.enabled}
|
||||
/>
|
||||
|
||||
<PasskeyListSection isAuthenticated={isAuthenticated} />
|
||||
<PasskeyListSection
|
||||
isAuthenticated={isAuthenticated}
|
||||
proofMethods={status.proofMethods}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import {
|
||||
isReproofRequired,
|
||||
startOidcReproof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
interface PasskeyInfo {
|
||||
@@ -56,17 +61,102 @@ interface PasskeyInfo {
|
||||
lastUsedAt: string | null;
|
||||
}
|
||||
|
||||
type ReauthMethod = "password" | "passkey" | "totp" | "webauthn";
|
||||
|
||||
type ExistingFactorProof =
|
||||
| { method: "password"; password: string }
|
||||
| { method: "totp"; code: string }
|
||||
| { method: "oidc" }
|
||||
| {
|
||||
method: "passkey" | "webauthn";
|
||||
challengeId: string;
|
||||
credential: unknown;
|
||||
};
|
||||
|
||||
/** What the card offers while the account's own list has not arrived. */
|
||||
const FALLBACK_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
|
||||
/**
|
||||
* The proofs to offer before adding a passkey: the account's own list when the
|
||||
* status read has it, so an account without a password is not handed a
|
||||
* password field, and the four body proofs otherwise.
|
||||
*/
|
||||
export function passkeyProofChoices(
|
||||
proofMethods: readonly string[] | undefined,
|
||||
): ReauthMethod[] {
|
||||
if (!proofMethods) return [...FALLBACK_METHODS];
|
||||
const known: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"passkey",
|
||||
"webauthn",
|
||||
];
|
||||
return proofMethods.filter((m): m is ReauthMethod =>
|
||||
known.includes(m as ReauthMethod),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove an existing factor, then run the registration ceremony. The proof
|
||||
* finishes before the first registration challenge is requested, so a
|
||||
* cancelled or failed proof leaves no enrollable challenge behind.
|
||||
*/
|
||||
async function addPasskey(method: ReauthMethod, secret: string): Promise<void> {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
let freshFactor: ExistingFactorProof;
|
||||
|
||||
if (method === "oidc") {
|
||||
// No secret: the proof is a provider re-proof already on the session.
|
||||
freshFactor = { method };
|
||||
} else if (method === "password") {
|
||||
freshFactor = { method, password: secret };
|
||||
} else if (method === "totp") {
|
||||
freshFactor = { method, code: secret };
|
||||
} else {
|
||||
const assertionChallenge = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: assertionChallenge.options,
|
||||
});
|
||||
freshFactor = {
|
||||
method,
|
||||
challengeId: assertionChallenge.challengeId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
let begun: {
|
||||
options: Parameters<typeof webauthn.startRegistration>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
};
|
||||
try {
|
||||
begun = await apiPost("/api/auth/passkey/register-options", freshFactor);
|
||||
} catch (err) {
|
||||
// No provider re-proof on the session yet: go and get one. The page
|
||||
// unloads and comes back here, so the mutation stays pending rather
|
||||
// than flashing an error on the way out.
|
||||
if (method !== "oidc" || !isReproofRequired(err)) throw err;
|
||||
startOidcReproof();
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
const { options, challengeId } = begun;
|
||||
const credential = await webauthn.startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary-passkey management: list (name, device, backup, created, last used),
|
||||
* add (registration ceremony), rename, and delete. The passwordless-primary
|
||||
@@ -74,8 +164,11 @@ type ExistingFactorProof =
|
||||
*/
|
||||
export function PasskeyListSection({
|
||||
isAuthenticated,
|
||||
proofMethods,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
/** `proofMethods` from `GET /api/auth/me/mfa`. */
|
||||
proofMethods?: readonly string[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -83,7 +176,10 @@ export function PasskeyListSection({
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [reauthOpen, setReauthOpen] = useState(false);
|
||||
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>("password");
|
||||
const choices = passkeyProofChoices(proofMethods);
|
||||
const [reauthMethod, setReauthMethod] = useState<ReauthMethod>(
|
||||
choices[0] ?? "password",
|
||||
);
|
||||
const [reauthSecret, setReauthSecret] = useState("");
|
||||
|
||||
const { data: passkeys } = useQuery({
|
||||
@@ -93,56 +189,18 @@ export function PasskeyListSection({
|
||||
});
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: async ({
|
||||
mutationFn: ({
|
||||
method,
|
||||
secret,
|
||||
}: {
|
||||
method: ReauthMethod;
|
||||
secret: string;
|
||||
}) => {
|
||||
const webauthn = await import("@simplewebauthn/browser");
|
||||
let freshFactor: ExistingFactorProof;
|
||||
|
||||
if (method === "password") {
|
||||
freshFactor = { method, password: secret };
|
||||
} else if (method === "totp") {
|
||||
freshFactor = { method, code: secret };
|
||||
} else {
|
||||
const assertionChallenge = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startAuthentication
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", { method });
|
||||
const credential = await webauthn.startAuthentication({
|
||||
optionsJSON: assertionChallenge.options,
|
||||
});
|
||||
freshFactor = {
|
||||
method,
|
||||
challengeId: assertionChallenge.challengeId,
|
||||
credential,
|
||||
};
|
||||
}
|
||||
|
||||
const { options, challengeId } = await apiPost<{
|
||||
options: Parameters<
|
||||
typeof webauthn.startRegistration
|
||||
>[0]["optionsJSON"];
|
||||
challengeId: string;
|
||||
}>("/api/auth/passkey/register-options", freshFactor);
|
||||
const credential = await webauthn.startRegistration({
|
||||
optionsJSON: options,
|
||||
});
|
||||
await apiPost("/api/auth/passkey/register-verify", {
|
||||
challengeId,
|
||||
credential,
|
||||
});
|
||||
},
|
||||
}) => addPasskey(method, secret),
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
setReauthOpen(false);
|
||||
setReauthSecret("");
|
||||
setReauthMethod("password");
|
||||
setReauthMethod(choices[0] ?? "password");
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
toastWrittenOutcome("success", t("settings.passkeyAddedToast"));
|
||||
},
|
||||
@@ -195,6 +253,14 @@ export function PasskeyListSection({
|
||||
},
|
||||
});
|
||||
|
||||
const methodLabels: Record<ReauthMethod, string> = {
|
||||
password: t("settings.passkeyReauth.methods.password"),
|
||||
oidc: t("settings.passkeyReauth.methods.oidc"),
|
||||
totp: t("settings.passkeyReauth.methods.totp"),
|
||||
passkey: t("settings.passkeyReauth.methods.passkey"),
|
||||
webauthn: t("settings.passkeyReauth.methods.webauthn"),
|
||||
};
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
singleDevice: t("settings.singleDevice"),
|
||||
multiDevice: t("settings.multiDevice"),
|
||||
@@ -350,7 +416,14 @@ export function PasskeyListSection({
|
||||
onOpenChange={(open) => {
|
||||
if (add.isPending) return;
|
||||
setReauthOpen(open);
|
||||
if (open) setMsg(null);
|
||||
if (open) {
|
||||
setMsg(null);
|
||||
// The choice the account's list leads with, in case the list
|
||||
// arrived after the card first rendered.
|
||||
if (!choices.includes(reauthMethod)) {
|
||||
setReauthMethod(choices[0] ?? "password");
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SettingsCardActions>
|
||||
@@ -384,14 +457,7 @@ export function PasskeyListSection({
|
||||
role="group"
|
||||
aria-label={t("settings.passkeyReauth.methodLabel")}
|
||||
>
|
||||
{(
|
||||
[
|
||||
["password", t("settings.passkeyReauth.methods.password")],
|
||||
["totp", t("settings.passkeyReauth.methods.totp")],
|
||||
["passkey", t("settings.passkeyReauth.methods.passkey")],
|
||||
["webauthn", t("settings.passkeyReauth.methods.webauthn")],
|
||||
] satisfies Array<[ReauthMethod, string]>
|
||||
).map(([method, label]) => (
|
||||
{choices.map((method) => (
|
||||
<Button
|
||||
key={method}
|
||||
type="button"
|
||||
@@ -407,11 +473,15 @@ export function PasskeyListSection({
|
||||
setMsg(null);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{methodLabels[method]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{reauthMethod === "oidc" && (
|
||||
<p className="text-sm">{t("settings.passkeyReauth.oidcHint")}</p>
|
||||
)}
|
||||
|
||||
{reauthMethod === "password" && (
|
||||
<Input
|
||||
type="password"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"oidc",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
@@ -93,7 +94,9 @@ export function createRecentProofFlow(deps: {
|
||||
if (!isProofRefusal(err)) throw err;
|
||||
const offered = methodsFrom(err as ApiError);
|
||||
// Nothing to re-prove in place: an account that signs in through an
|
||||
// identity provider only. A fresh sign-in is the proof it can give.
|
||||
// identity provider the instance no longer offers. A fresh sign-in is
|
||||
// the proof it can give. One that can still reach its provider is
|
||||
// offered `oidc` instead, which leaves the page and comes back to it.
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(deps.messages().signInAgain, 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
|
||||
@@ -65,6 +65,11 @@ export type AuthTourProgress = TourProgress;
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
/**
|
||||
* When the account was created (ISO). Absent from an older server image,
|
||||
* which every reader treats as an established account.
|
||||
*/
|
||||
createdAt?: string;
|
||||
email: string | null;
|
||||
role: string;
|
||||
heightCm: number | null;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Asking for an account to be deleted: the one path the account's own
|
||||
* deletion (`DELETE /api/settings/account`) and an admin's deletion of
|
||||
* another account (`DELETE /api/admin/users/{id}`) both take.
|
||||
*
|
||||
* With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`) the account is locked:
|
||||
* every credential is revoked at once, the record stays exactly as it is, and
|
||||
* the purge job erases it when the window ends; an admin can undo it until
|
||||
* then. With the window at 0 it is erased here, in the same transaction.
|
||||
*
|
||||
* The Guardian invariant (an account may not leave a managed profile without
|
||||
* a Guardian) and the admin roster (the last admin who can sign in stays) are
|
||||
* decided inside the transaction, under their locks, and refuse by throwing
|
||||
* `LastManagedGuardianError` or `LastAdminError`.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { kickOffhostPurge } from "@/lib/jobs/offhost-purge";
|
||||
import { deleteGuardianAccountWithLifecycle } from "@/lib/managed-profiles/lifecycle";
|
||||
import { assertAnotherAdminStays } from "@/lib/auth/admin-roster";
|
||||
import {
|
||||
accountDeletionGraceDays,
|
||||
accountDeletionPurgeAt,
|
||||
} from "@/lib/account-deletion/grace";
|
||||
import {
|
||||
eraseAccount,
|
||||
lockAccountForDeletion,
|
||||
} from "@/lib/account-deletion/lifecycle";
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
export async function requestAccountDeletion(input: {
|
||||
userId: string;
|
||||
/** Whether the account being deleted is an admin (the roster check). */
|
||||
targetIsAdmin: boolean;
|
||||
/** Who asked: the account itself, or the admin acting on it. */
|
||||
requestedById: string;
|
||||
/**
|
||||
* Runs first inside the transaction, before anything is written: the place
|
||||
* for a refusal that must be decided under the same locks (throw), and for
|
||||
* the audit rows, which then cannot outlive a refusal.
|
||||
*/
|
||||
inTransaction: (tx: Tx, purgeAt: Date | null) => Promise<void>;
|
||||
}): Promise<{ purgeAt: Date | null }> {
|
||||
const requestedAt = new Date();
|
||||
const purgeAt = accountDeletionPurgeAt(
|
||||
requestedAt,
|
||||
accountDeletionGraceDays(),
|
||||
);
|
||||
|
||||
await deleteGuardianAccountWithLifecycle(
|
||||
input.userId,
|
||||
async (tx) => {
|
||||
// Checked again under the roster lock: two admins deleting at the same
|
||||
// moment must not each count the other.
|
||||
if (input.targetIsAdmin) await assertAnotherAdminStays(tx, input.userId);
|
||||
await input.inTransaction(tx, purgeAt);
|
||||
|
||||
if (purgeAt) {
|
||||
// Locked, not erased: credentials revoked in this transaction, the
|
||||
// record untouched until the purge job reaches `purgeAt`.
|
||||
await lockAccountForDeletion(tx, {
|
||||
userId: input.userId,
|
||||
requestedById: input.requestedById,
|
||||
requestedAt,
|
||||
purgeAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No window: erase now, inside the same record lifecycle transaction so
|
||||
// a refusal leaves every account state intact (sessions, the account's
|
||||
// audit history — GDPR Art. 17 — the off-host purge request, then the
|
||||
// row with everything cascading from it).
|
||||
await eraseAccount(tx, input.userId, "account_deleted");
|
||||
},
|
||||
{ erasing: purgeAt === null },
|
||||
);
|
||||
|
||||
if (!purgeAt) await kickOffhostPurge();
|
||||
return { purgeAt };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The chat request bodies take every language the app ships.
|
||||
*
|
||||
* The Coach chat, the document chat and the fenced chat declared `locale` as
|
||||
* the pair de/en, so a body naming any other app language (French, Korean,
|
||||
* Brazilian Portuguese, …) was refused with 422 before the reply language was
|
||||
* ever resolved, although `resolveServerLocale` and the prompts downstream
|
||||
* carry every locale.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
documentChatRequestSchema,
|
||||
fencedChatRequestSchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
const SCHEMAS = {
|
||||
coach: (locale: string) =>
|
||||
coachChatRequestSchema.safeParse({ message: "Hello", locale }),
|
||||
document: (locale: string) =>
|
||||
documentChatRequestSchema.safeParse({ message: "Hello", locale }),
|
||||
fenced: (locale: string) =>
|
||||
fencedChatRequestSchema.safeParse({ message: "Hello", locale }),
|
||||
};
|
||||
|
||||
describe.each(Object.entries(SCHEMAS))("%s chat request", (_name, parse) => {
|
||||
it.each([...locales])("accepts %s", (locale) => {
|
||||
const parsed = parse(locale);
|
||||
expect(parsed.success).toBe(true);
|
||||
expect(parsed.data?.locale).toBe(locale);
|
||||
});
|
||||
|
||||
it("refuses a language the app does not ship", () => {
|
||||
expect(parse("xx").success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
* can pass messages through with minimal translation.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
|
||||
import { COACH_SCOPE_SOURCES } from "./scope-sources";
|
||||
|
||||
@@ -85,15 +86,14 @@ export const coachChatRequestSchema = z.object({
|
||||
conversationId: z.string().min(1).max(64).optional(),
|
||||
message: z.string().min(1).max(4000),
|
||||
prefill: z.string().max(2000).optional(),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
locale: z.enum(locales).optional(),
|
||||
scope: coachScopeSchema.optional(),
|
||||
/**
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question
|
||||
* this message answers. The question bubble is client-side only
|
||||
* (never persisted), so without this context the model would see a
|
||||
* bare answer ("since 2019, with medication") and could not react
|
||||
* to it. Rides the prompt as delimited user-provided context; the
|
||||
* persisted user turn stays the answer alone.
|
||||
* v1.16.6 — guided clarifying-questions flow: the pending question this
|
||||
* message answers. The question bubble is client-side only (never persisted),
|
||||
* so without this context the model would see a bare answer ("since 2019,
|
||||
* with medication") and could not react to it. Rides the prompt as delimited
|
||||
* user-provided context; the persisted user turn stays the answer alone.
|
||||
*/
|
||||
guidedQuestion: z.string().min(1).max(500).optional(),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Confirming at the identity provider (#1296): which accounts are offered it,
|
||||
* what the callback accepts as fresh, whose identity counts, and what the
|
||||
* sealed state must carry.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
findUser: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
updateSession: vi.fn<(args: unknown) => Promise<unknown>>(),
|
||||
countKeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
countPasskeys: vi.fn<(args: unknown) => Promise<number>>(),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: db.findUser },
|
||||
session: { update: db.updateSession },
|
||||
webauthnMfaCredential: { count: db.countKeys },
|
||||
passkey: { count: db.countPasskeys },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
refundRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: vi.fn((s: string) => s),
|
||||
encrypt: vi.fn((s: string) => s),
|
||||
}));
|
||||
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { refundRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import {
|
||||
isFreshAuthTime,
|
||||
isLinkedIdentity,
|
||||
isReproofSession,
|
||||
recordOidcReproof,
|
||||
reproofReturnUrl,
|
||||
} from "@/lib/auth/oidc-reproof";
|
||||
import { readStoredOidcState } from "@/lib/auth/oidc-callback/state";
|
||||
|
||||
const ISSUER = "https://idp.example.test";
|
||||
const OIDC_ENV = {
|
||||
OIDC_ISSUER_URL: ISSUER,
|
||||
OIDC_CLIENT_ID: "client",
|
||||
OIDC_CLIENT_SECRET: "secret",
|
||||
};
|
||||
|
||||
function accountRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
passwordHash: null,
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: "sub-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(process.env, OIDC_ENV);
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://healthlog.example.test";
|
||||
db.countKeys.mockResolvedValue(0);
|
||||
db.countPasskeys.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of Object.keys(OIDC_ENV)) delete process.env[key];
|
||||
});
|
||||
|
||||
describe("recentProofMethods offers the identity provider", () => {
|
||||
const user = { id: "u1", totpConfirmedAt: null };
|
||||
|
||||
it("to an account without a second factor whose identity is linked", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual(["oidc"]);
|
||||
});
|
||||
|
||||
it("beside the password and passkey the account also holds", async () => {
|
||||
db.countPasskeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow({ passwordHash: "hash" }));
|
||||
expect(await recentProofMethods(user, false)).toEqual([
|
||||
"password",
|
||||
"oidc",
|
||||
"passkey",
|
||||
]);
|
||||
});
|
||||
|
||||
it("not to an account without a linked identity", async () => {
|
||||
db.findUser.mockResolvedValue(
|
||||
accountRow({ oidcIssuer: null, oidcSub: null }),
|
||||
);
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not when the instance has no provider configured", async () => {
|
||||
delete process.env.OIDC_CLIENT_SECRET;
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("not to an account with a second factor, where it could not clear the gate", async () => {
|
||||
db.countKeys.mockResolvedValue(1);
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
expect(await recentProofMethods(user, true)).toEqual(["webauthn"]);
|
||||
});
|
||||
|
||||
it("and never counts it as a second factor", () => {
|
||||
expect(isFirstFactorOnlyProof("oidc")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("password")).toBe(true);
|
||||
expect(isFirstFactorOnlyProof("passkey")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFreshAuthTime", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const seconds = (ms: number) => Math.floor(ms / 1000);
|
||||
|
||||
it("accepts an authentication inside the five-minute window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 10_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now - 300_000), now)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses one older than the window", () => {
|
||||
expect(isFreshAuthTime(seconds(now - 301_000), now)).toBe(false);
|
||||
expect(isFreshAuthTime(seconds(now - 3_600_000), now)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a token that does not say when", () => {
|
||||
expect(isFreshAuthTime(null, now)).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates a provider clock a minute ahead, and no more", () => {
|
||||
expect(isFreshAuthTime(seconds(now + 30_000), now)).toBe(true);
|
||||
expect(isFreshAuthTime(seconds(now + 120_000), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLinkedIdentity", () => {
|
||||
it("needs both the issuer and the subject to match", () => {
|
||||
const account = { oidcIssuer: ISSUER, oidcSub: "sub-1" };
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-1")).toBe(true);
|
||||
expect(isLinkedIdentity(account, ISSUER, "sub-2")).toBe(false);
|
||||
expect(isLinkedIdentity(account, "https://other.test", "sub-1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isLinkedIdentity({ oidcIssuer: null, oidcSub: null }, ISSUER, "sub-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReproofSession", () => {
|
||||
const bound = { reproofUserId: "u1", reproofSessionId: "s1" };
|
||||
|
||||
it("holds only for the session and account the start bound", () => {
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s2" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReproofSession(bound, { session: { id: "s1" }, user: { id: "u2" } }),
|
||||
).toBe(false);
|
||||
expect(isReproofSession(bound, null)).toBe(false);
|
||||
expect(
|
||||
isReproofSession({}, { session: { id: "s1" }, user: { id: "u1" } }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordOidcReproof", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
const args = {
|
||||
userId: "u1",
|
||||
sessionId: "s1",
|
||||
issuer: ISSUER,
|
||||
ipAddress: "203.0.113.5",
|
||||
nowMs: now,
|
||||
};
|
||||
|
||||
it("stamps reproofAt only, refunds the attempt and audits the success", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 5 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: true });
|
||||
expect(db.updateSession).toHaveBeenCalledWith({
|
||||
where: { id: "s1" },
|
||||
data: { reproofAt: new Date(now) },
|
||||
});
|
||||
expect(refundRateLimit).toHaveBeenCalledWith("auth:step-up:u1");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.reproof.succeeded",
|
||||
expect.objectContaining({ details: { method: "oidc" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a stale auth_time without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "sub-1", authTime: Math.floor(now / 1000) - 3600 },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "stale_auth_time" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"auth.mfa.failed",
|
||||
expect.objectContaining({
|
||||
details: {
|
||||
stage: "oidc_reproof",
|
||||
method: "oidc",
|
||||
reason: "stale_auth_time",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a different subject without stamping", async () => {
|
||||
db.findUser.mockResolvedValue(accountRow());
|
||||
const outcome = await recordOidcReproof({
|
||||
...args,
|
||||
identity: { sub: "someone-else", authTime: Math.floor(now / 1000) },
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, reason: "identity_mismatch" });
|
||||
expect(db.updateSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the re-proof return and state", () => {
|
||||
it("returns to the page it left with the outcome attached", () => {
|
||||
const url = reproofReturnUrl("/settings/api?tab=mcp", "done");
|
||||
expect(url.origin).toBe("https://healthlog.example.test");
|
||||
expect(url.pathname).toBe("/settings/api");
|
||||
expect(url.searchParams.get("tab")).toBe("mcp");
|
||||
expect(url.searchParams.get("reproof")).toBe("done");
|
||||
});
|
||||
|
||||
it("reads a reproof state only with both bindings and no other intent's fields", () => {
|
||||
const base = { state: "s", nonce: "n", codeVerifier: "v", next: "/" };
|
||||
const ok = readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
}),
|
||||
);
|
||||
expect(ok?.intent).toBe("reproof");
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({ ...base, intent: "reproof", reproofUserId: "u1" }),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(
|
||||
JSON.stringify({
|
||||
...base,
|
||||
intent: "reproof",
|
||||
reproofUserId: "u1",
|
||||
reproofSessionId: "s1",
|
||||
native: true,
|
||||
appCodeChallenge: "c",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readStoredOidcState(JSON.stringify({ ...base, intent: "other" })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,15 @@ const ENV_KEYS = [
|
||||
] as const;
|
||||
const ORIGINAL_ENV: Record<string, string | undefined> = {};
|
||||
|
||||
/**
|
||||
* A verification result carrying only what `verifyIdToken` reads: the claims
|
||||
* and the header `jose` returns beside them.
|
||||
*/
|
||||
type VerifyResult = Awaited<ReturnType<typeof mockJwtVerify>>;
|
||||
function verified(payload: Record<string, unknown>): VerifyResult {
|
||||
return { payload, protectedHeader: { alg: "RS256" } } as VerifyResult;
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||
return {
|
||||
ok,
|
||||
@@ -270,6 +279,25 @@ describe("buildAuthorizationUrl", () => {
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.has("max_age")).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for a fresh, timed authentication for a re-proof", () => {
|
||||
configureEnv();
|
||||
const url = new URL(
|
||||
buildAuthorizationUrl({
|
||||
metadata: METADATA_DOC,
|
||||
config: getOidcConfig()!,
|
||||
state: "state-1",
|
||||
nonce: "nonce-1",
|
||||
codeChallenge: "challenge-1",
|
||||
redirectUri: "https://healthlog.example.com/api/auth/oidc/callback",
|
||||
reauthenticate: true,
|
||||
}),
|
||||
);
|
||||
expect(url.searchParams.get("prompt")).toBe("login");
|
||||
expect(url.searchParams.get("max_age")).toBe("0");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,16 +360,15 @@ describe("verifyIdToken", () => {
|
||||
beforeEach(() => configureEnv());
|
||||
|
||||
it("returns the identity claims on success", async () => {
|
||||
// Test payloads deliberately omit the full JWTVerifyResult shape.
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
email: "person@example.com",
|
||||
email_verified: true,
|
||||
name: "Person",
|
||||
nonce: "nonce-1",
|
||||
},
|
||||
} as never);
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const identity = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -354,13 +381,43 @@ describe("verifyIdToken", () => {
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
name: "Person",
|
||||
authTime: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns auth_time when the provider states it, and null otherwise", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({
|
||||
sub: "user-sub-1",
|
||||
nonce: "nonce-1",
|
||||
auth_time: 1_700_000_000,
|
||||
}),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
const stated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(stated.authTime).toBe(1_700_000_000);
|
||||
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1", auth_time: "recent" }),
|
||||
);
|
||||
const unstated = await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
config,
|
||||
idToken: "id-token",
|
||||
nonce: "nonce-1",
|
||||
});
|
||||
expect(unstated.authTime).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a nonce mismatch", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "wrong-nonce" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "wrong-nonce" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -373,9 +430,7 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("rejects a token missing sub", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(verified({ nonce: "nonce-1" }));
|
||||
const config = getOidcConfig()!;
|
||||
await expect(
|
||||
verifyIdToken({
|
||||
@@ -388,9 +443,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("verifies with a closed asymmetric algorithm allowlist and bounded clock tolerance", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
@@ -411,9 +466,9 @@ describe("verifyIdToken", () => {
|
||||
});
|
||||
|
||||
it("pins the JWKS fetch to the issuer's host with a bounded timeout", async () => {
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue({
|
||||
payload: { sub: "user-sub-1", nonce: "nonce-1" },
|
||||
} as never);
|
||||
vi.mocked(mockJwtVerify).mockResolvedValue(
|
||||
verified({ sub: "user-sub-1", nonce: "nonce-1" }),
|
||||
);
|
||||
const config = getOidcConfig()!;
|
||||
await verifyIdToken({
|
||||
metadata: METADATA_DOC,
|
||||
|
||||
@@ -56,6 +56,21 @@ beforeEach(() => {
|
||||
] as never);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate locale", () => {
|
||||
it.each(["fr", "pt-BR", "ko"])(
|
||||
"stores %s, a language the app ships",
|
||||
async (locale) => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale });
|
||||
expect(result.ok).toBe(true);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ locale }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate timezone validation", () => {
|
||||
it("accepts a valid IANA zone", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
@@ -271,7 +286,7 @@ describe("applyProfileUpdate partial-field save", () => {
|
||||
it("writes nothing and reports every rejected field when the whole body is invalid", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
gender: "male",
|
||||
locale: "fr",
|
||||
locale: "xx",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
@@ -290,15 +305,15 @@ describe("applyProfileUpdate top-level error message", () => {
|
||||
// sharpest regression check available for "does the raw Zod default
|
||||
// ever reach the top-level string a person reads". Zod v4's default
|
||||
// enum message is shaped like:
|
||||
// Invalid option: expected one of "de"|"en"
|
||||
// Invalid option: expected one of "de"|"en"|"fr"|…
|
||||
const RAW_ZOD_ENUM_PATTERN = /expected one of/i;
|
||||
|
||||
it("never lets the raw Zod enum message reach the top-level `message`", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN);
|
||||
expect(result.message).not.toContain('"de"|"en"');
|
||||
expect(result.message).not.toContain('"de"|"en"|"fr"');
|
||||
// Relocated, not deleted: the same detail is still available for
|
||||
// whoever needs it (the iOS `details.issues` contract), just not
|
||||
// in the string a person reads first.
|
||||
@@ -318,7 +333,7 @@ describe("applyProfileUpdate top-level error message", () => {
|
||||
});
|
||||
|
||||
it("carries a stable errorCode so the web client can localize the sentence", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "xx" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errorCode).toBe("profile.update.nothingSaved");
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
recentProofMethods,
|
||||
type RecentProofMethod,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
@@ -171,7 +172,7 @@ export async function recordReproofFailure(
|
||||
userId: string,
|
||||
ipAddress: string | null,
|
||||
stage: string,
|
||||
method: ExistingFactorProof["method"],
|
||||
method: RecentProofMethod,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await auditLog("auth.mfa.failed", {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import {
|
||||
hasSecondFactorEnrolled,
|
||||
isFirstFactorOnlyProof,
|
||||
recentProofMethods,
|
||||
} from "@/lib/auth/second-factor";
|
||||
import { meterAccountWrite } from "@/lib/auth/write-meter";
|
||||
@@ -80,9 +81,10 @@ export async function requireFreshMfa(
|
||||
// does, so the browser can open the re-proof dialog instead of printing a
|
||||
// sentence with nowhere to act on it. Only the possession proofs: a
|
||||
// `POST /api/auth/reproof` with one of them stamps `mfaVerifiedAt`, a
|
||||
// password stamps `reproofAt` only and would leave this gate shut.
|
||||
// password or a provider sign-in stamps `reproofAt` only and would leave
|
||||
// this gate shut.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
(method) => !isFirstFactorOnlyProof(method),
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
|
||||
@@ -27,8 +27,17 @@ export interface StoredOidcState {
|
||||
* names instead of signing in, and only when the cookie still carries this
|
||||
* exact binding. Web only: never together with `native`.
|
||||
*/
|
||||
intent?: "claim";
|
||||
intent?: "claim" | "reproof";
|
||||
claimBinding?: string;
|
||||
/**
|
||||
* Set only for `intent: "reproof"` (`GET /api/auth/oidc/reproof`): the
|
||||
* signed-in account and the session row the round trip confirms. The
|
||||
* callback stamps nothing unless the browser still carries exactly this
|
||||
* session and the provider names exactly this account's linked identity.
|
||||
* Web only, like the claim.
|
||||
*/
|
||||
reproofUserId?: string;
|
||||
reproofSessionId?: string;
|
||||
}
|
||||
|
||||
function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
@@ -50,11 +59,24 @@ function isStoredOidcState(value: unknown): value is StoredOidcState {
|
||||
if (typeof v.appCodeChallenge !== "string") return false;
|
||||
}
|
||||
if (v.intent !== undefined) {
|
||||
if (v.intent !== "claim") return false;
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
if (v.native !== undefined) return false;
|
||||
if (v.intent === "claim") {
|
||||
if (typeof v.claimBinding !== "string" || v.claimBinding.length === 0) {
|
||||
return false;
|
||||
}
|
||||
} else if (v.intent === "reproof") {
|
||||
if (
|
||||
typeof v.reproofUserId !== "string" ||
|
||||
v.reproofUserId.length === 0 ||
|
||||
typeof v.reproofSessionId !== "string" ||
|
||||
v.reproofSessionId.length === 0 ||
|
||||
v.claimBinding !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
if (v.native !== undefined) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Confirming it is you through the identity provider.
|
||||
*
|
||||
* An account created by single sign-on holds no password, passkey or second
|
||||
* factor, so the recent-proof gate (token minting, exports, share links, the
|
||||
* admin data actions) had nothing to ask it for. This is the proof such an
|
||||
* account can give: the browser goes back to the provider with
|
||||
* `prompt=login` and `max_age=0`, and the callback counts the round trip only
|
||||
* when the ID token says the provider authenticated the person inside the
|
||||
* gate's own window and names the identity linked to the signed-in account.
|
||||
*
|
||||
* It records the proof exactly as a password re-proof does, `Session.reproofAt`
|
||||
* and nothing else, so it never stands in for a second factor. It creates no
|
||||
* session and switches none: the callback stamps the session row the start
|
||||
* bound into the sealed state, and only while the browser still carries it.
|
||||
*
|
||||
* Start: `GET /api/auth/oidc/reproof?next=<path>`. Finish: the shared
|
||||
* `GET /api/auth/oidc/callback` with `intent: "reproof"` in the state blob.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { oidcAppUrl, type OidcIdentity } from "@/lib/auth/oidc";
|
||||
import {
|
||||
RECENT_PROOF_MAX_AGE_MS,
|
||||
recordReproofFailure,
|
||||
refundReproof,
|
||||
} from "@/lib/auth/existing-factor-proof";
|
||||
|
||||
/** The query parameter the browser comes back with, and its two values. */
|
||||
export const OIDC_REPROOF_RESULT_PARAM = "reproof";
|
||||
export type OidcReproofResult = "done" | "failed";
|
||||
|
||||
/** The stage name the shared re-proof bucket and the audit rows carry. */
|
||||
export const OIDC_REPROOF_STAGE = "oidc_reproof";
|
||||
|
||||
/**
|
||||
* How far in the future `auth_time` may sit and still count: the clock
|
||||
* tolerance `verifyIdToken` already grants `iat` and `exp`, for an identity
|
||||
* provider on another box whose clock runs a little ahead.
|
||||
*/
|
||||
const AUTH_TIME_FUTURE_TOLERANCE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Did the provider authenticate the person inside the gate's window (the five
|
||||
* minutes every recent-proof surface uses)? A token
|
||||
* without `auth_time` is refused: with `max_age` in the request the provider
|
||||
* owes the claim (OpenID Connect Core 2), and without it nothing says the
|
||||
* person signed in now rather than an hour ago.
|
||||
*/
|
||||
export function isFreshAuthTime(
|
||||
authTime: number | null,
|
||||
nowMs: number,
|
||||
maxAgeMs: number = RECENT_PROOF_MAX_AGE_MS,
|
||||
): boolean {
|
||||
if (authTime === null) return false;
|
||||
const at = authTime * 1000;
|
||||
return nowMs - at <= maxAgeMs && at - nowMs <= AUTH_TIME_FUTURE_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the identity the provider named the one linked to this account? Both
|
||||
* halves, compared exactly: the subject is only unique per issuer.
|
||||
*/
|
||||
export function isLinkedIdentity(
|
||||
account: { oidcIssuer: string | null; oidcSub: string | null },
|
||||
issuer: string,
|
||||
sub: string,
|
||||
): boolean {
|
||||
return (
|
||||
account.oidcIssuer !== null &&
|
||||
account.oidcSub !== null &&
|
||||
account.oidcIssuer === issuer &&
|
||||
account.oidcSub === sub
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the browser still carry the session the round trip was started from?
|
||||
* Checked before the code is redeemed, so a mismatch spends nothing at the
|
||||
* provider.
|
||||
*/
|
||||
export function isReproofSession(
|
||||
bound: { reproofUserId?: string; reproofSessionId?: string },
|
||||
live: { session: { id: string }; user: { id: string } } | null,
|
||||
): boolean {
|
||||
return (
|
||||
live !== null &&
|
||||
typeof bound.reproofSessionId === "string" &&
|
||||
typeof bound.reproofUserId === "string" &&
|
||||
live.session.id === bound.reproofSessionId &&
|
||||
live.user.id === bound.reproofUserId
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the browser lands: the page it left, with the outcome attached. */
|
||||
export function reproofReturnUrl(next: string, result: OidcReproofResult): URL {
|
||||
const url = oidcAppUrl(next);
|
||||
url.searchParams.set(OIDC_REPROOF_RESULT_PARAM, result);
|
||||
return url;
|
||||
}
|
||||
|
||||
export type OidcReproofOutcome =
|
||||
{ ok: true } | { ok: false; reason: "identity_mismatch" | "stale_auth_time" };
|
||||
|
||||
/**
|
||||
* Check the verified ID token against the account and stamp the session.
|
||||
* Every refusal is audited like a failed password re-proof; a success gives
|
||||
* back the attempt the start charged to the shared bucket.
|
||||
*/
|
||||
export async function recordOidcReproof(args: {
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
issuer: string;
|
||||
identity: Pick<OidcIdentity, "sub" | "authTime">;
|
||||
ipAddress: string | null;
|
||||
nowMs?: number;
|
||||
}): Promise<OidcReproofOutcome> {
|
||||
const { userId, sessionId, issuer, identity, ipAddress } = args;
|
||||
const nowMs = args.nowMs ?? Date.now();
|
||||
|
||||
const account = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { oidcIssuer: true, oidcSub: true },
|
||||
});
|
||||
let refusal: "identity_mismatch" | "stale_auth_time" | null = null;
|
||||
if (!account || !isLinkedIdentity(account, issuer, identity.sub)) {
|
||||
refusal = "identity_mismatch";
|
||||
} else if (!isFreshAuthTime(identity.authTime, nowMs)) {
|
||||
refusal = "stale_auth_time";
|
||||
}
|
||||
if (refusal) {
|
||||
await recordReproofFailure(
|
||||
userId,
|
||||
ipAddress,
|
||||
OIDC_REPROOF_STAGE,
|
||||
"oidc",
|
||||
refusal,
|
||||
);
|
||||
return { ok: false, reason: refusal };
|
||||
}
|
||||
|
||||
await prisma.session.update({
|
||||
where: { id: sessionId },
|
||||
data: { reproofAt: new Date(nowMs) },
|
||||
});
|
||||
await refundReproof(userId);
|
||||
await auditLog("auth.reproof.succeeded", {
|
||||
userId,
|
||||
ipAddress,
|
||||
details: { method: "oidc" },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.reproof.succeeded" },
|
||||
meta: { method: "oidc" },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
+20
-1
@@ -244,6 +244,13 @@ export function buildAuthorizationUrl(params: {
|
||||
* keyboard now, not a session someone else left open in this browser.
|
||||
*/
|
||||
forceLogin?: boolean;
|
||||
/**
|
||||
* Ask the provider to authenticate afresh AND to say when it did: `prompt=login`
|
||||
* plus `max_age=0` (OpenID Connect Core 3.1.2.1). With `max_age` in the
|
||||
* request the ID token must carry `auth_time`, which is what the re-proof
|
||||
* leg checks before it counts the round trip as a proof.
|
||||
*/
|
||||
reauthenticate?: boolean;
|
||||
}): string {
|
||||
const url = new URL(params.metadata.authorization_endpoint);
|
||||
url.search = new URLSearchParams({
|
||||
@@ -255,7 +262,8 @@ export function buildAuthorizationUrl(params: {
|
||||
nonce: params.nonce,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
...(params.forceLogin ? { prompt: "login" } : {}),
|
||||
...(params.forceLogin || params.reauthenticate ? { prompt: "login" } : {}),
|
||||
...(params.reauthenticate ? { max_age: "0" } : {}),
|
||||
}).toString();
|
||||
return url.toString();
|
||||
}
|
||||
@@ -304,6 +312,12 @@ export interface OidcIdentity {
|
||||
email: string | null;
|
||||
emailVerified: boolean | undefined;
|
||||
name: string | null;
|
||||
/**
|
||||
* When the provider last authenticated the person (`auth_time`, seconds
|
||||
* since the epoch), or null when the token does not say. Only the re-proof
|
||||
* leg reads it, and it refuses a null.
|
||||
*/
|
||||
authTime: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,6 +364,11 @@ export async function verifyIdToken(params: {
|
||||
? payload.email_verified
|
||||
: undefined,
|
||||
name: typeof payload.name === "string" ? payload.name : null,
|
||||
authTime:
|
||||
typeof payload.auth_time === "number" &&
|
||||
Number.isFinite(payload.auth_time)
|
||||
? payload.auth_time
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
|
||||
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import {
|
||||
REPROOF_FAILED_CODE,
|
||||
REPROOF_REQUIRED_CODE,
|
||||
@@ -26,9 +27,11 @@ import {
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
// Trimmed first; "" (or only spaces) clears the name to null below.
|
||||
displayName: optionalDisplayNameSchema.nullable().optional(),
|
||||
// Every language the app ships: the stored language is what background
|
||||
// work (the Coach nudge, the notification texts) writes in.
|
||||
locale: z
|
||||
.enum(["de", "en"], {
|
||||
error: "Locale must be de or en.",
|
||||
.enum(locales, {
|
||||
error: "Locale must be one of the languages the app ships.",
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||
|
||||
/**
|
||||
* Does this account have a SECOND FACTOR at all?
|
||||
@@ -40,11 +41,25 @@ export async function hasSecondFactorEnrolled(user: {
|
||||
}
|
||||
|
||||
/**
|
||||
* A proof the recent-proof gate can ask for. `password` and `passkey` on an
|
||||
* account without a second factor; `totp`, `webauthn` and `passkey` on one
|
||||
* with a second factor, where a password alone has never been enough.
|
||||
* A proof the recent-proof gate can ask for. `password`, `oidc` and `passkey`
|
||||
* on an account without a second factor; `totp`, `webauthn` and `passkey` on
|
||||
* one with a second factor, where a password alone has never been enough.
|
||||
*
|
||||
* `oidc` is a fresh sign-in at the account's linked identity provider
|
||||
* (`GET /api/auth/oidc/reproof`), not a body proof: it stamps
|
||||
* `Session.reproofAt` exactly as a password does, and like a password it never
|
||||
* stands in for a second factor.
|
||||
*/
|
||||
export type RecentProofMethod = "password" | "totp" | "webauthn" | "passkey";
|
||||
export type RecentProofMethod =
|
||||
"password" | "oidc" | "totp" | "webauthn" | "passkey";
|
||||
|
||||
/**
|
||||
* The proofs that stamp only `Session.reproofAt` and so never clear a gate that
|
||||
* asks for a second factor (`requireFreshMfa`).
|
||||
*/
|
||||
export function isFirstFactorOnlyProof(method: RecentProofMethod): boolean {
|
||||
return method === "password" || method === "oidc";
|
||||
}
|
||||
|
||||
/**
|
||||
* The proofs that would satisfy `assertRecentCookieProof` for this
|
||||
@@ -63,15 +78,21 @@ export async function recentProofMethods(
|
||||
prisma.passkey.count({ where: { userId: user.id } }),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { passwordHash: true },
|
||||
select: { passwordHash: true, oidcIssuer: true, oidcSub: true },
|
||||
}),
|
||||
]);
|
||||
const methods: RecentProofMethod[] = [];
|
||||
if (hasSecond) {
|
||||
if (user.totpConfirmedAt) methods.push("totp");
|
||||
if (keys > 0) methods.push("webauthn");
|
||||
} else if (credential?.passwordHash) {
|
||||
methods.push("password");
|
||||
} else {
|
||||
if (credential?.passwordHash) methods.push("password");
|
||||
// A linked identity is only a way to confirm while the instance still
|
||||
// talks to a provider: with the provider group unset the round trip has
|
||||
// nowhere to go.
|
||||
if (credential?.oidcIssuer && credential.oidcSub && isOidcConfigured()) {
|
||||
methods.push("oidc");
|
||||
}
|
||||
}
|
||||
if (passkeys > 0) methods.push("passkey");
|
||||
return methods;
|
||||
|
||||
@@ -63,8 +63,11 @@ describe("first paint carries the resolved locale, not a default", () => {
|
||||
/prev\.locale === locale \? \{ locale, messages: loaded \} : prev/,
|
||||
);
|
||||
|
||||
// And the persistence effect keys on the locale alone, so one mount
|
||||
// with one locale means one write.
|
||||
expect(src).toMatch(/persistLocaleToServer\(locale\);\s*\}, \[locale\]\);/);
|
||||
// And the provider writes nothing to the server itself: the mirror lives
|
||||
// in `LocaleServerSyncBridge`, keyed on the locale and the signed-in
|
||||
// account, so a signed-out mount sends nothing and one account with one
|
||||
// locale means one write (its browser test pins both).
|
||||
expect(src).toMatch(/export function persistLocaleToServer\(/);
|
||||
expect(src).not.toMatch(/persistLocaleToServer\(locale/);
|
||||
});
|
||||
});
|
||||
+12
-14
@@ -125,16 +125,23 @@ function persistLocale(newLocale: Locale) {
|
||||
// the server-set cookie the route emits in reply: Safari's ITP caps the
|
||||
// script-written copy at 7 days, and a Set-Cookie is exempt.
|
||||
//
|
||||
// Once per tab session and per locale, not on every mount: every page load
|
||||
// mounts the provider, and every authenticated write is charged to the
|
||||
// Once per tab session, account and locale, not on every mount: every page
|
||||
// load mounts the provider, and every authenticated write is charged to the
|
||||
// account's shared write bucket, so a mount-time PUT spent one write per page
|
||||
// view on a value that had not changed. A switch still writes at once (the
|
||||
// locale differs from the one recorded), and a new tab writes once.
|
||||
//
|
||||
// Only for a signed-in account. The route answers 401 to nobody, the marker
|
||||
// is then never set, and every signed-out page load used to repeat the
|
||||
// refused PUT. Signed out, the choice stays in localStorage and the cookie;
|
||||
// `LocaleServerSyncBridge` calls this once an account is known, so the
|
||||
// choice made before signing in reaches the column right after.
|
||||
const LOCALE_SYNCED_KEY = "healthlog-locale-synced";
|
||||
|
||||
function persistLocaleToServer(newLocale: Locale) {
|
||||
export function persistLocaleToServer(newLocale: Locale, accountId: string) {
|
||||
const marker = `${accountId}:${newLocale}`;
|
||||
try {
|
||||
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === newLocale) return;
|
||||
if (sessionStorage.getItem(LOCALE_SYNCED_KEY) === marker) return;
|
||||
} catch {
|
||||
// Storage unavailable: write as before.
|
||||
}
|
||||
@@ -148,7 +155,7 @@ function persistLocaleToServer(newLocale: Locale) {
|
||||
.then((res) => {
|
||||
if (!res.ok) return;
|
||||
try {
|
||||
sessionStorage.setItem(LOCALE_SYNCED_KEY, newLocale);
|
||||
sessionStorage.setItem(LOCALE_SYNCED_KEY, marker);
|
||||
} catch {
|
||||
// Storage unavailable: the next mount writes again.
|
||||
}
|
||||
@@ -238,15 +245,6 @@ export function I18nProvider({
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
// v1.25.0 — keep `User.locale` in step with the committed UI locale. Runs on
|
||||
// the first mount of a tab session (backfilling the column for users who set
|
||||
// their language before this landed) and on every switch, so the proactive
|
||||
// Coach nudge and other cookie-blind background paths render in the language
|
||||
// the user reads.
|
||||
useEffect(() => {
|
||||
persistLocaleToServer(locale);
|
||||
}, [locale]);
|
||||
|
||||
const setLocale = useCallback((newLocale: Locale) => {
|
||||
if (!(locales as readonly string[]).includes(newLocale)) return;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* to the rounding precision, the chart-token scrub, or the cache-row
|
||||
* shape lands once rather than seven times.
|
||||
*
|
||||
* `SupportedLocale` is the full six-locale UI union. The status prompts
|
||||
* `SupportedLocale` is the full UI locale union. The status prompts
|
||||
* compose one of two reviewed instruction bodies (de / en) but name the
|
||||
* reader's own language in an explicit output directive, so the reader's
|
||||
* locale must survive the whole pipeline rather than being collapsed to a
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from "@/lib/ai/safety/outbound-screen";
|
||||
|
||||
/**
|
||||
* The locales the assessment pipeline carries end-to-end — all six the UI
|
||||
* The locales the assessment pipeline carries end-to-end — every one the UI
|
||||
* ships. Aliased to the i18n `Locale` so adding a UI locale widens the
|
||||
* pipeline in one place rather than needing a second edit here.
|
||||
*/
|
||||
@@ -108,7 +108,7 @@ export function normalizeSummaryText(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an arbitrary locale string against the six the UI ships.
|
||||
* Validate an arbitrary locale string against every locale the UI ships.
|
||||
*
|
||||
* A recognised locale passes through UNCHANGED — that is the whole point:
|
||||
* the former `value === "de" ? "de" : "en"` binary erased a French reader's
|
||||
|
||||
@@ -53,13 +53,54 @@ describe("crisisResourcesForLocale", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a Dutch reader with no region 112, then the Dutch and Belgian lines by name", () => {
|
||||
for (const locale of ["nl", "nl-SR"]) {
|
||||
const set = crisisResourcesForLocale(locale);
|
||||
expect(set.emergencyNumber, locale).toBe("112");
|
||||
expect(set.resources).toEqual([
|
||||
{
|
||||
id: "zelfmoordpreventie113",
|
||||
contacts: ["113", "0800-0113", "113.nl"],
|
||||
},
|
||||
{ id: "zelfmoordlijn1813", contacts: ["1813", "zelfmoord1813.be"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
]);
|
||||
}
|
||||
const { t } = getServerTranslator("nl");
|
||||
expect(
|
||||
t("mentalHealth.crisisResource.zelfmoordpreventie113.name"),
|
||||
).toContain("Nederland");
|
||||
expect(t("mentalHealth.crisisResource.zelfmoordlijn1813.name")).toContain(
|
||||
"België",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives an explicit Dutch region 113 and an explicit Belgian one 1813", () => {
|
||||
for (const locale of ["nl-NL", "nl_NL"]) {
|
||||
const ids = crisisResourcesForLocale(locale).resources.map((r) => r.id);
|
||||
expect(ids, locale).toEqual(["zelfmoordpreventie113", "findahelpline"]);
|
||||
}
|
||||
for (const locale of ["nl-BE", "nl_BE", "be"]) {
|
||||
const ids = crisisResourcesForLocale(locale).resources.map((r) => r.id);
|
||||
expect(ids, locale).toEqual(["zelfmoordlijn1813", "findahelpline"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("names every crisis resource in every language", () => {
|
||||
// History re-derives the set from the row's stored locale and renders it
|
||||
// in whatever language the reader uses now.
|
||||
const ids = new Set(
|
||||
["pt-BR", "pt-PT", "br", "de", "de-AT", "de-CH", "en-US", "fr"].flatMap(
|
||||
(l) => crisisResourcesForLocale(l).resources.map((r) => r.id),
|
||||
),
|
||||
[
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"br",
|
||||
"de",
|
||||
"de-AT",
|
||||
"de-CH",
|
||||
"en-US",
|
||||
"fr",
|
||||
"nl",
|
||||
].flatMap((l) => crisisResourcesForLocale(l).resources.map((r) => r.id)),
|
||||
);
|
||||
for (const locale of locales) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
@@ -149,6 +149,51 @@ const PT_BR_AMBIGUOUS: CrisisResourceSet = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The Netherlands (explicit "nl-NL" / "nl_NL"). 112 is the emergency number.
|
||||
* 113 Zelfmoordpreventie answers by phone on 113 or the free 0800-0113 and by
|
||||
* chat on 113.nl, anonymously.
|
||||
*/
|
||||
const NL: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
{
|
||||
id: "zelfmoordpreventie113",
|
||||
contacts: ["113", "0800-0113", "113.nl"],
|
||||
},
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Belgium (explicit "nl-BE" / "nl_BE", or the bare region "be"). 112 is the
|
||||
* emergency number. The Zelfmoordlijn answers 1813 around the clock and runs
|
||||
* a chat in the evenings (zelfmoord1813.be).
|
||||
*/
|
||||
const BE: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
{ id: "zelfmoordlijn1813", contacts: ["1813", "zelfmoord1813.be"] },
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Dutch with no region signal. The app ships one Dutch bundle, `nl`, read in
|
||||
* the Netherlands and in Flanders alike, and the account keeps no country to
|
||||
* tell them apart. Following DE_AT_CH, the set leads with 112 (the emergency
|
||||
* number in both countries) and names each country's line with the country
|
||||
* in its label, so nothing reads as if it reached everyone.
|
||||
*/
|
||||
const NL_BE: CrisisResourceSet = {
|
||||
emergencyNumber: "112",
|
||||
resources: [
|
||||
...NL.resources.filter((r) => r.id !== "findahelpline"),
|
||||
...BE.resources.filter((r) => r.id !== "findahelpline"),
|
||||
{ id: "findahelpline", contacts: ["findahelpline.com"] },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a crisis-resource set from the user's locale. Region detection is
|
||||
* coarse (locale prefix) and defaults to the international set — the goal is to
|
||||
@@ -156,8 +201,9 @@ const PT_BR_AMBIGUOUS: CrisisResourceSet = {
|
||||
*
|
||||
* A region-qualified tag ("de-AT", "de_CH", "pt-PT", …) resolves to the exact
|
||||
* country set when the caller has one to give us. The web stores the app
|
||||
* locale, which is a bare "de" or the folded "pt-BR", so it lands on the
|
||||
* combined sets: never assume Germany or Brazil when the region is unknown.
|
||||
* locale, which is a bare "de", "nl" or the folded "pt-BR", so it lands on the
|
||||
* combined sets: never assume Germany, the Netherlands or Brazil when the
|
||||
* region is unknown.
|
||||
*/
|
||||
export function crisisResourcesForLocale(
|
||||
locale: string | null | undefined,
|
||||
@@ -173,5 +219,9 @@ export function crisisResourcesForLocale(
|
||||
// PT_BR_AMBIGUOUS. Any other Portuguese tag (pt, pt-AO, pt-MZ) lands here
|
||||
// too, and still gets 112 and the worldwide directory first.
|
||||
if (lc === "pt" || lc.startsWith("pt-")) return PT_BR_AMBIGUOUS;
|
||||
if (lc === "nl-nl") return NL;
|
||||
if (lc === "nl-be" || lc === "be") return BE;
|
||||
// The bare "nl" the web stores, and any other Dutch tag: both countries.
|
||||
if (lc === "nl" || lc.startsWith("nl-")) return NL_BE;
|
||||
return INTERNATIONAL;
|
||||
}
|
||||
@@ -40,8 +40,13 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes";
|
||||
*/
|
||||
export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
{
|
||||
account: ["account.deletion.no_guardian", "account.deletion.not_pending"],
|
||||
account: [
|
||||
"account.deletion.already_pending",
|
||||
"account.deletion.no_guardian",
|
||||
"account.deletion.not_pending",
|
||||
],
|
||||
activity: ["activity.calendar.future_date", "activity.calendar.too_old"],
|
||||
admin: ["admin.users.delete.managed_profile", "admin.users.delete.self"],
|
||||
ai: [
|
||||
"ai.budget.exceeded",
|
||||
"ai.provider.none",
|
||||
|
||||
@@ -508,6 +508,9 @@ const accountPayload = z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
createdAt: isoDateTime().describe(
|
||||
"When the signed-in account was created. The web greets an account in its first day as new rather than welcoming it back.",
|
||||
),
|
||||
// Enumerated because clients cut days and convert glucose by them: the
|
||||
// day key and the unit must be the server's, not the device's.
|
||||
timezone: z
|
||||
|
||||
@@ -342,6 +342,11 @@ const mfaStatusResponse = z
|
||||
recoveryCodesRemaining: z.number().int(),
|
||||
webauthn: z.array(webauthnCredentialInfo),
|
||||
passkeyNudgeDismissed: z.boolean(),
|
||||
proofMethods: z
|
||||
.array(z.enum(["password", "oidc", "totp", "webauthn", "passkey"]))
|
||||
.describe(
|
||||
"The ways this account can confirm it is you, the same list a recent-proof refusal names in `meta.methods`. `password` and `oidc` only on an account without a second factor; `oidc` is a browser round trip to the identity provider (`GET /api/auth/oidc/reproof`) and is never offered to a token.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MfaStatusResponse" });
|
||||
|
||||
@@ -599,6 +604,14 @@ const changePasswordRequest = changePasswordSchema.meta({
|
||||
"Password rotation. `newPassword` and `confirmPassword` must match, and `newPassword` must differ from the current one. The strength floor is 12 characters plus a scored check against the account's own username and email.",
|
||||
});
|
||||
|
||||
const passkeyProviderProofRequest = z
|
||||
.object({ method: z.literal("oidc") })
|
||||
.meta({
|
||||
id: "PasskeyProviderProofRequest",
|
||||
description:
|
||||
"Take the session's recent identity-provider re-proof (GET /api/auth/oidc/reproof) as the proof. Accepted only for an account whose `proofMethods` list `oidc` and only within five minutes of that re-proof; otherwise 401 `auth.reproof.required` with `meta.methods`. Web only.",
|
||||
});
|
||||
|
||||
const passkeyReauthRequest = z
|
||||
.object({
|
||||
method: z
|
||||
@@ -1130,7 +1143,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago. A `totp`, `webauthn` or `passkey` proof here also clears the routes that ask for a fresh second factor (the managed-profile acts, the second-factor and passkey changes, the password change, account deletion, the data wipe and key rotation): on a browser session those answer 401 `auth.stepup.required` with `meta.methods` naming the possession proofs the account holds, and the web re-proves here and retries.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. An account without a second factor whose identity is linked to the configured identity provider is also offered `oidc` in `meta.methods`; that proof is not posted here but given by the browser round trip GET /api/auth/oidc/reproof, which stamps the session exactly as a password proof does. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -2454,7 +2467,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" +
|
||||
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
|
||||
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" +
|
||||
'**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. An account confirmed at its identity provider (GET /api/auth/oidc/reproof, within five minutes) sends `{ method: "oidc" }` instead, which takes that re-proof as the proof and, like a password, clears the second-factor stamp. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n' +
|
||||
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
|
||||
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
|
||||
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
|
||||
@@ -2462,7 +2475,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.union([passkeyReauthRequest, stepUpMintSchema]),
|
||||
schema: z.union([
|
||||
passkeyReauthRequest,
|
||||
passkeyProviderProofRequest,
|
||||
stepUpMintSchema,
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { recoveryEmptySchema } from "../recovery-empty";
|
||||
import { trendSentiment, trendVerdict } from "../trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { measurementSourceEnum, measurementTypeEnum } from "../shared";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
@@ -45,9 +46,11 @@ export const metricStatusQuery = z
|
||||
"HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.",
|
||||
),
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.enum(locales)
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
.describe(
|
||||
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MetricStatusQuery" });
|
||||
|
||||
@@ -792,9 +795,11 @@ export const glp1PlateauResponse = z
|
||||
export const insightStatusQuery = z
|
||||
.object({
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.enum(locales)
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
.describe(
|
||||
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "InsightStatusQuery" });
|
||||
|
||||
@@ -856,9 +861,11 @@ export const biomarkerAssessmentQuery = z
|
||||
"User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).",
|
||||
),
|
||||
locale: z
|
||||
.enum(["de", "en"])
|
||||
.enum(locales)
|
||||
.optional()
|
||||
.describe("Optional UI-locale override; defaults to the session locale."),
|
||||
.describe(
|
||||
"Optional UI-locale override, any language the app ships; defaults to the session locale.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "BiomarkerAssessmentQuery" });
|
||||
|
||||
|
||||
@@ -554,7 +554,7 @@ export const profileUpdateRequest = z
|
||||
.describe(
|
||||
"Trimmed before its bound is checked. Null, an empty string or only spaces clears it; an omitted field is left untouched.",
|
||||
),
|
||||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||||
locale: z.enum(locales).nullable().optional(),
|
||||
timezone: z.string().min(1).max(64).optional(),
|
||||
timeFormat: z
|
||||
.enum(["AUTO", "H12", "H24"])
|
||||
|
||||
@@ -106,3 +106,11 @@ export const adminSettingsSchema = z
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Body of DELETE /api/admin/users/{id}: the typed confirmation the account's
|
||||
* own deletion asks for, so a stray request cannot lock an account.
|
||||
*/
|
||||
export const adminDeleteUserSchema = z
|
||||
.object({ confirm: z.literal("DELETE_ACCOUNT") })
|
||||
.strict();
|
||||
@@ -24,6 +24,7 @@
|
||||
* it is always narrowed from the session.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import {
|
||||
DOCUMENT_BULK_MAX_IDS,
|
||||
DOCUMENT_CONTENT_INDEX_SOURCES,
|
||||
@@ -944,14 +945,13 @@ export const DOCUMENT_CHAT_MESSAGE_MAX = 4000;
|
||||
* Request body for `POST /api/documents/inbound/{id}/chat`. No `userId` and no
|
||||
* `documentId` field — the user is narrowed from the session and the document id
|
||||
* comes from the path. `conversationId` continues an existing thread scoped to
|
||||
* this document; omitting it starts a new one. `locale` is the reply language
|
||||
* (the document chat, like the Coach, replies in de/en).
|
||||
* this document; omitting it starts a new one. `locale` is the reply language.
|
||||
*/
|
||||
export const documentChatRequestSchema = z
|
||||
.object({
|
||||
conversationId: z.string().trim().min(1).max(64).optional(),
|
||||
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
locale: z.enum(locales).optional(),
|
||||
})
|
||||
.meta({ id: "DocumentChatRequest" });
|
||||
|
||||
@@ -990,7 +990,7 @@ export const fencedChatRequestSchema = z
|
||||
.object({
|
||||
conversationId: z.string().trim().min(1).max(64).optional(),
|
||||
message: z.string().trim().min(1).max(DOCUMENT_CHAT_MESSAGE_MAX),
|
||||
locale: z.enum(["en", "de"]).optional(),
|
||||
locale: z.enum(locales).optional(),
|
||||
attachmentIds: z
|
||||
.array(z.string().trim().min(1).max(64))
|
||||
.min(1)
|
||||
|
||||
Reference in new issue
Block a user