mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
47 files changed
+2646
-553
No files matched your search
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [1.43.3] — 2026-10-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every action behind a second-factor check opens the confirmation
|
||||
dialog.** Handing over a managed profile, creating, editing or deleting
|
||||
one, adding or removing a guardian, turning off TOTP, regenerating recovery
|
||||
codes, removing a security key or passkey, changing the password, deleting
|
||||
data or the account and the admin key rotation only showed a sentence when
|
||||
the second factor needed confirming. They now open the confirmation dialog
|
||||
and retry once confirmed, also after a single sign-on login (Refs #959).
|
||||
The refusal names the methods the account has in `meta.methods`.
|
||||
- **A passkey counts as a second factor for managed profiles.** An account
|
||||
whose only strong sign-in is a passkey was told to set up a second factor
|
||||
before it could create, edit, hand over or delete a managed profile or
|
||||
manage its guardians. A passkey sign-in, or confirming the passkey in the
|
||||
dialog, is now enough; the admin key rotation follows the same rule.
|
||||
Password sign-in, the native app and the actions that ask for a fresh
|
||||
factor only from accounts with TOTP or a security key are unchanged.
|
||||
- An account that signs in only through single sign-on is no longer offered
|
||||
a password field it cannot fill when it sets up a second factor; it is
|
||||
asked to sign in again.
|
||||
|
||||
## [1.43.2] — 2026-10-16
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 355,
|
||||
"/insights/page": 304,
|
||||
@@ -8,6 +8,6 @@
|
||||
"/settings/[section]/page": 307
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 4975,
|
||||
"totalClientKbGz": 5000,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+15
-8
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.43.2
|
||||
version: 1.43.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -2333,7 +2333,11 @@ paths:
|
||||
/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.
|
||||
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.
|
||||
|
||||
|
||||
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
|
||||
@@ -21681,10 +21685,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ManagedProfileEnvelope"
|
||||
"401":
|
||||
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all
|
||||
(`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a
|
||||
second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives
|
||||
here too, including for a Bearer caller, whose transport this gate does not read."
|
||||
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the
|
||||
proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode:
|
||||
auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor
|
||||
a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only
|
||||
strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401
|
||||
arrives here too, including for a Bearer caller, whose transport this gate does not read."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -34613,8 +34619,9 @@ components:
|
||||
Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an
|
||||
unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated
|
||||
(`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent
|
||||
management relationship, and an account with no second factor enrolled is refused rather than waved through.
|
||||
Rate-limited to ten an hour per caller."
|
||||
management relationship, and an account with no second factor enrolled is refused rather than waved through. A
|
||||
primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof
|
||||
stamps the session. Rate-limited to ten an hour per caller."
|
||||
UpdateManagedProfileRequest:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -43,12 +43,12 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
retained.
|
||||
3. **Re-encrypt the stored data under the new key.** Open **Admin →
|
||||
Encryption** and press **Rotate now**. The button needs an admin account
|
||||
with a second factor, confirmed recently; it queues one background run
|
||||
with a second factor or a passkey, confirmed recently; it queues one background run
|
||||
(`POST /api/admin/encryption/rotate`) on the app's own worker, so it
|
||||
works on the published image with nothing else installed. The same page
|
||||
shows the per-column counts and the result of the last run.
|
||||
|
||||
Without a second factor on any admin account, run the rotation script
|
||||
Without a second factor or a passkey on any admin account, run the rotation script
|
||||
instead. It imports the application code and the generated database
|
||||
client, so it runs from a source checkout of the same release, not
|
||||
inside the production container (the image has no package manager and
|
||||
|
||||
@@ -189,9 +189,11 @@ no password, no passkey and no e-mail address, and it is never presented
|
||||
as something to log in as.
|
||||
|
||||
- It is created from **Settings → Shared access**, under a fresh second
|
||||
factor. An account with no second factor enrolled is refused rather than
|
||||
waved through, because the act creates a permanent management
|
||||
relationship over a person who cannot object to it.
|
||||
factor. A passkey counts as one: a guardian who signs in with a passkey,
|
||||
or confirms it in the dialog the app opens, gets through. An account with
|
||||
neither a second factor nor a passkey is refused rather than waved
|
||||
through, because the act creates a permanent management relationship
|
||||
over a person who cannot object to it.
|
||||
- Creation takes a display name, optionally a real date of birth, and the
|
||||
language and timezone the record's own days and reminders are measured
|
||||
in. A year on its own is never turned into the first of January. The
|
||||
@@ -356,10 +358,11 @@ Four operational notes:
|
||||
and no address to look it up by.
|
||||
- **The second-factor requirement is not the same on both features, and
|
||||
the difference is deliberate.** Creating a managed profile, deleting
|
||||
one and inviting a second guardian require a second factor
|
||||
unconditionally: an account with none enrolled is refused and told to
|
||||
set one up, because those acts create or end a permanent relationship
|
||||
over somebody who cannot object. Offering manage access on an ordinary
|
||||
invitation asks for a fresh factor only from an account that has one, so
|
||||
one, handing it over and inviting a second guardian require a second
|
||||
factor unconditionally, and a passkey counts as one there: an account
|
||||
with neither is refused and told to set one up, because those acts
|
||||
create or end a permanent relationship over somebody who cannot object.
|
||||
Offering manage access on an ordinary invitation asks for a fresh factor
|
||||
only from an account with TOTP or a security key, so
|
||||
an instance where nobody has enrolled a factor can still use every level
|
||||
of ordinary sharing. All of them are browser-only either way.
|
||||
+114
-22
@@ -52,7 +52,10 @@ import {
|
||||
* floor advanced on every accept (`src/lib/auth/mfa/verify-factor.ts`), so
|
||||
* a code already spent is refused even inside its own 30-second life;
|
||||
* - the sensitive action once the step-up stamp has aged out — 401 with
|
||||
* `meta.errorCode = "auth.stepup.required"`, and the card's prompt;
|
||||
* `meta.errorCode = "auth.stepup.required"` naming the proofs it would
|
||||
* take, and the re-proof dialog in place of the rotation. Cancelling it
|
||||
* sends nothing more and changes nothing; proving the factor there runs
|
||||
* the rotation once more, and only then does it go through;
|
||||
* - a recovery code presented a second time — 401. The matched row is burned
|
||||
* on first use;
|
||||
* - one login past the documented per-IP cap — 429, with the bucket's own
|
||||
@@ -125,6 +128,20 @@ async function alignToStepBoundary(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(nextBoundary - now);
|
||||
}
|
||||
|
||||
function stepOf(atMs: number): number {
|
||||
return Math.floor(atMs / (TOTP_PERIOD_SECONDS * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Park until the TOTP step after `step` has begun, so a code minted for now is
|
||||
* above the replay floor that step's code left. Bounded by one period.
|
||||
*/
|
||||
async function alignPastStep(page: Page, step: number): Promise<void> {
|
||||
const nextBoundary = (step + 1) * TOTP_PERIOD_SECONDS * 1000;
|
||||
const wait = nextBoundary - Date.now();
|
||||
if (wait > 0) await page.waitForTimeout(wait + 250);
|
||||
}
|
||||
|
||||
async function sessionCookie(context: BrowserContext) {
|
||||
const cookies = await context.cookies();
|
||||
return cookies.find((c) => c.name === "healthlog_session");
|
||||
@@ -170,17 +187,14 @@ async function submitFactor(page: Page, code: string): Promise<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the regeneration dialog, confirm it, and return the route's answer.
|
||||
* Open the regeneration dialog and confirm it, without waiting for the end.
|
||||
*
|
||||
* The confirming click calls `preventDefault()` so the dialog survives the
|
||||
* request in flight; the mutation takes it down again when the request
|
||||
* settles, whatever the answer was. That matters on a REFUSAL, where the card
|
||||
* behind the dialog is the only thing that says why — on a success the
|
||||
* fresh-codes panel replaces the whole branch the dialog lives in and it would
|
||||
* unmount either way. So the dialog being gone here is a product assertion,
|
||||
* not housekeeping: nothing in this helper dismisses it.
|
||||
* request in flight, and it stays up for as long as the rotation is pending —
|
||||
* which, when the step-up has aged out, includes the re-proof dialog that opens
|
||||
* above it. Returns the route's first answer.
|
||||
*/
|
||||
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
|
||||
async function confirmRegeneration(page: Page): Promise<Response> {
|
||||
await page.getByTestId("recovery-regenerate").click();
|
||||
const answer = page.waitForResponse(
|
||||
(res) =>
|
||||
@@ -188,11 +202,37 @@ async function regenerateRecoveryCodes(page: Page): Promise<Response> {
|
||||
res.request().method() === "POST",
|
||||
);
|
||||
await page.getByTestId("recovery-regenerate-confirm").click();
|
||||
const settled = await answer;
|
||||
return answer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the regeneration dialog, confirm it, and return the route's answer.
|
||||
*
|
||||
* The mutation takes the dialog down again when the request settles; on a
|
||||
* success the fresh-codes panel replaces the whole branch the dialog lives in.
|
||||
* So the dialog being gone here is a product assertion, not housekeeping:
|
||||
* nothing in this helper dismisses it.
|
||||
*/
|
||||
async function regenerateRecoveryCodes(page: Page): Promise<Response> {
|
||||
const settled = await confirmRegeneration(page);
|
||||
await expect(page.getByTestId("recovery-regenerate-dialog")).toBeHidden();
|
||||
return settled;
|
||||
}
|
||||
|
||||
/** The first refusal of an aged-out rotation: what the server said. */
|
||||
async function expectStepUpRefusal(answer: Response): Promise<void> {
|
||||
expect(answer.status()).toBe(401);
|
||||
const body = (await answer.json()) as {
|
||||
data: null;
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("auth.stepup.required");
|
||||
// The proofs that clear it. A password never stands in for the second
|
||||
// factor, so it is not offered.
|
||||
expect(body.meta?.methods).toEqual(["totp"]);
|
||||
}
|
||||
|
||||
/** The codes currently on screen, gated on the panel being painted. */
|
||||
async function visibleRecoveryCodes(page: Page): Promise<string[]> {
|
||||
const panel = page.getByTestId("recovery-codes");
|
||||
@@ -259,6 +299,7 @@ test.describe("second factor", () => {
|
||||
let secret = "";
|
||||
let spentCode = "";
|
||||
let liveRecoveryCodes: string[] = [];
|
||||
let enrolmentStep = 0;
|
||||
|
||||
await test.step("signs in with a password alone while no factor exists", async () => {
|
||||
const answer = await submitPassword(page, account);
|
||||
@@ -292,9 +333,11 @@ test.describe("second factor", () => {
|
||||
// up enrolled against different secrets.
|
||||
expect(setupBody.data.otpauthUri).toContain(secret);
|
||||
|
||||
const enrolledAt = Date.now();
|
||||
enrolmentStep = stepOf(enrolledAt);
|
||||
await page
|
||||
.getByTestId("totp-confirm-code")
|
||||
.fill(totpCodeAt(secret, Date.now()));
|
||||
.fill(totpCodeAt(secret, enrolledAt));
|
||||
const confirm = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/api/auth/me/mfa/totp/confirm") &&
|
||||
@@ -320,20 +363,69 @@ test.describe("second factor", () => {
|
||||
liveRecoveryCodes = rotated;
|
||||
});
|
||||
|
||||
await test.step("refuses the same rotation once the step-up has aged out", async () => {
|
||||
await test.step("asks for the factor again once the step-up has aged out", async () => {
|
||||
await page.getByTestId("recovery-codes-dismiss").click();
|
||||
await expireStepUp(account);
|
||||
|
||||
const answer = await regenerateRecoveryCodes(page);
|
||||
expect(answer.status()).toBe(401);
|
||||
const body = (await answer.json()) as {
|
||||
data: null;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.meta?.errorCode).toBe("auth.stepup.required");
|
||||
// And the card says so, on a card the closed dialog no longer covers.
|
||||
await expect(page.getByTestId("totp-error")).toBeVisible();
|
||||
const regenerateDialog = page.getByTestId("recovery-regenerate-dialog");
|
||||
const reproofDialog = page.getByTestId("factor-reauth-dialog");
|
||||
const regenerateCalls: string[] = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes(REGENERATE_ENDPOINT))
|
||||
regenerateCalls.push(req.method());
|
||||
});
|
||||
|
||||
// The refusal opens the re-proof dialog above the pending confirmation,
|
||||
// and the dialog holds the focus.
|
||||
await expectStepUpRefusal(await confirmRegeneration(page));
|
||||
await expect(reproofDialog).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
reproofDialog.evaluate((el) => el.contains(document.activeElement)),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(page.getByTestId("totp-error")).toBeHidden();
|
||||
|
||||
// Cancelled: nothing more is sent, both dialogs go, no sentence and no
|
||||
// fresh batch on the card. The live codes are still the live codes.
|
||||
await reproofDialog.getByRole("button", { name: /cancel/i }).click();
|
||||
await expect(reproofDialog).toBeHidden();
|
||||
await expect(regenerateDialog).toBeHidden();
|
||||
await expect(page.getByTestId("totp-error")).toBeHidden();
|
||||
await expect(page.getByTestId("recovery-codes")).toBeHidden();
|
||||
expect(regenerateCalls).toEqual(["POST"]);
|
||||
|
||||
// Proved: the rotation runs once more and goes through.
|
||||
await expectStepUpRefusal(await confirmRegeneration(page));
|
||||
await expect(reproofDialog).toBeVisible();
|
||||
// The code the enrolment spent is below the replay floor; wait for the
|
||||
// step after it when it is still current. Bounded by one period.
|
||||
await alignPastStep(page, enrolmentStep);
|
||||
await reproofDialog
|
||||
.locator('input[autocomplete="one-time-code"]')
|
||||
.fill(totpCodeAt(secret, Date.now()));
|
||||
const proof = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/api/auth/reproof") &&
|
||||
res.request().method() === "POST",
|
||||
);
|
||||
const retried = page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(REGENERATE_ENDPOINT) &&
|
||||
res.request().method() === "POST" &&
|
||||
res.status() === 200,
|
||||
);
|
||||
await page.getByTestId("factor-reauth-submit").click();
|
||||
expect((await proof).status()).toBe(200);
|
||||
expect((await retried).status()).toBe(200);
|
||||
await expect(reproofDialog).toBeHidden();
|
||||
await expect(regenerateDialog).toBeHidden();
|
||||
expect(regenerateCalls).toEqual(["POST", "POST", "POST"]);
|
||||
|
||||
const rotated = await visibleRecoveryCodes(page);
|
||||
expect(rotated).not.toEqual(liveRecoveryCodes);
|
||||
await expect(page.getByTestId("totp-error")).toBeHidden();
|
||||
liveRecoveryCodes = rotated;
|
||||
});
|
||||
|
||||
await test.step("asks for the second factor on the next sign-in", async () => {
|
||||
|
||||
@@ -30,8 +30,19 @@ function connect(): pg.Pool {
|
||||
return new pg.Pool({ connectionString: url });
|
||||
}
|
||||
|
||||
/** WCAG 2.1 AA on the screen as it stands. */
|
||||
/**
|
||||
* WCAG 2.1 AA on the screen as it stands.
|
||||
*
|
||||
* The decision screen is reached by a client navigation, and during one the
|
||||
* framework unmounts the old route's `<title>` and mounts the new one from a
|
||||
* suspended metadata boundary, which can reveal after the page body has
|
||||
* painted. Measured under CPU throttling: the decision is on screen for about
|
||||
* 200 ms before the title arrives. A scan in that stretch audits the
|
||||
* navigation, not the screen, so the title is waited for first, and asserted:
|
||||
* a screen that never gets one still fails here.
|
||||
*/
|
||||
async function expectNoAxeViolations(page: Page): Promise<void> {
|
||||
await expect(page).toHaveTitle("HealthLog");
|
||||
const result = await new AxeBuilder({ page })
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Unter Einstellungen, Konto ändern",
|
||||
"cta": "Bestätigen und weiter",
|
||||
"managedTitle": "Die Person, um die du dich kümmerst",
|
||||
"managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor auf deinem Konto nötig.",
|
||||
"managedBody": "Ihre Akte hat keinen eigenen Login; du betreust sie aus deinem Konto. Zum Anlegen ist ein zweiter Faktor oder ein Passkey auf deinem Konto nötig.",
|
||||
"managedCreate": "Profil anlegen und abschließen",
|
||||
"managedLater": "Du kannst es auch später unter Einstellungen, Geteilter Zugriff anlegen.",
|
||||
"finishWithoutProfile": "Ohne Profil abschließen"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Aktuelles Passwort (nötig, um die E-Mail-Adresse zu ändern)",
|
||||
"reproof": {
|
||||
"description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"descriptionChange": "Das ändert etwas, das nur du ändern können solltest. Bestätige, dass du es bist, um fortzufahren.",
|
||||
"signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.",
|
||||
"failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.",
|
||||
"errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.",
|
||||
"errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor oder Passkey und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier. Prüfe deine Verbindung und versuche es erneut."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "Profil anlegen",
|
||||
"creating": "Wird angelegt…",
|
||||
"created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.",
|
||||
"errorStepUp": "Bestätige deinen zweiten Faktor oder Passkey und versuche es erneut.",
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert. Prüfe deine Verbindung und versuche es erneut.",
|
||||
"errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.",
|
||||
"errorFailed": "Das Profil konnte nicht angelegt werden.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.",
|
||||
"guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.",
|
||||
"guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.",
|
||||
"errorMfaRequired": "Dafür ist ein zweiter Faktor oder ein Passkey nötig, und dein Konto hat noch keins von beiden. Richte unter Einstellungen → Sicherheit eins ein und versuche es dann erneut.",
|
||||
"rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.",
|
||||
"errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut.",
|
||||
"handover": {
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Change these under Settings, Account",
|
||||
"cta": "Confirm and continue",
|
||||
"managedTitle": "The person you look after",
|
||||
"managedBody": "Their record has no login of its own; you look after it from your account. A second factor on your account is required to create it.",
|
||||
"managedBody": "Their record has no login of its own; you look after it from your account. A second factor or a passkey on your account is required to create it.",
|
||||
"managedCreate": "Create the profile and finish",
|
||||
"managedLater": "You can also create it later under Settings, Shared access.",
|
||||
"finishWithoutProfile": "Finish without the profile"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Current password (needed to change the email address)",
|
||||
"reproof": {
|
||||
"description": "This shares or exports your health data. Confirm it's you to continue.",
|
||||
"descriptionChange": "This changes something only you should be able to change. Confirm it's you to continue.",
|
||||
"signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.",
|
||||
"failed": "That did not work. Check what you entered and try again."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "The invitation could not be sent. Please try again.",
|
||||
"errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.",
|
||||
"errorManageBrowserOnly": "Manage access can only be offered in a browser.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorStepUp": "Confirm your second factor or passkey, then try again.",
|
||||
"errorOffline": "That did not reach the server. Your invitation is still here. Check your connection and try again."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "Create profile",
|
||||
"creating": "Creating…",
|
||||
"created": "{name} is set up. You can switch into that record from the account menu.",
|
||||
"errorStepUp": "Confirm your second factor, then try again.",
|
||||
"errorStepUp": "Confirm your second factor or passkey, then try again.",
|
||||
"errorOffline": "That did not reach the server. Nothing has changed. Check your connection and try again.",
|
||||
"errorInvalid": "The server refused those details. Check the name, the date and the timezone.",
|
||||
"errorFailed": "The profile could not be created.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "No account on this instance carries that name or e-mail.",
|
||||
"guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.",
|
||||
"guardianErrorFailed": "The invitation could not be sent.",
|
||||
"errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.",
|
||||
"errorMfaRequired": "This needs a second factor or a passkey, and your account has neither yet. Set one up under Settings → Security, then try again.",
|
||||
"rosterLoadError": "Could not load who looks after this record.",
|
||||
"errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again.",
|
||||
"handover": {
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Cámbialo en Ajustes, Cuenta",
|
||||
"cta": "Confirmar y continuar",
|
||||
"managedTitle": "La persona a tu cargo",
|
||||
"managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor en tu cuenta.",
|
||||
"managedBody": "Su registro no tiene inicio de sesión propio; lo gestionas desde tu cuenta. Para crearlo hace falta un segundo factor o una llave de acceso en tu cuenta.",
|
||||
"managedCreate": "Crear el perfil y terminar",
|
||||
"managedLater": "También puedes crearlo después en Ajustes, Acceso compartido.",
|
||||
"finishWithoutProfile": "Terminar sin el perfil"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Contraseña actual (necesaria para cambiar la dirección de correo)",
|
||||
"reproof": {
|
||||
"description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.",
|
||||
"descriptionChange": "Esto cambia algo que solo tú deberías poder cambiar. Confirma que eres tú para continuar.",
|
||||
"signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.",
|
||||
"failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.",
|
||||
"errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.",
|
||||
"errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorStepUp": "Confirma tu segundo factor o tu llave de acceso y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "Crear perfil",
|
||||
"creating": "Creando…",
|
||||
"created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.",
|
||||
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.",
|
||||
"errorStepUp": "Confirma tu segundo factor o tu llave de acceso y vuelve a intentarlo.",
|
||||
"errorOffline": "Esto no llegó al servidor. No ha cambiado nada. Revisa tu conexión y vuelve a intentarlo.",
|
||||
"errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.",
|
||||
"errorFailed": "No se pudo crear el perfil.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.",
|
||||
"guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"guardianErrorFailed": "No se pudo enviar la invitación.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"errorMfaRequired": "Esto necesita un segundo factor o una llave de acceso, y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.",
|
||||
"rosterLoadError": "No se pudo cargar quién se ocupa de este historial.",
|
||||
"errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo.",
|
||||
"handover": {
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "À modifier dans Réglages, Compte",
|
||||
"cta": "Confirmer et continuer",
|
||||
"managedTitle": "La personne dont vous vous occupez",
|
||||
"managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur sur votre compte est nécessaire pour le créer.",
|
||||
"managedBody": "Son dossier n'a pas d'identifiant propre ; vous le gérez depuis votre compte. Un second facteur ou une clé d'accès sur votre compte est nécessaire pour le créer.",
|
||||
"managedCreate": "Créer le profil et terminer",
|
||||
"managedLater": "Vous pouvez aussi le créer plus tard dans Réglages, Accès partagé.",
|
||||
"finishWithoutProfile": "Terminer sans le profil"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Mot de passe actuel (nécessaire pour changer l'adresse e-mail)",
|
||||
"reproof": {
|
||||
"description": "Cette action partage ou exporte vos données de santé. Confirmez qu'il s'agit bien de vous pour continuer.",
|
||||
"descriptionChange": "Cela modifie quelque chose que vous seul devriez pouvoir modifier. Confirmez qu'il s'agit bien de vous pour continuer.",
|
||||
"signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.",
|
||||
"failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.",
|
||||
"errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.",
|
||||
"errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur ou votre clé d'accès, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là : vérifiez votre connexion et réessayez."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "Créer le profil",
|
||||
"creating": "Création…",
|
||||
"created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.",
|
||||
"errorStepUp": "Confirmez votre deuxième facteur ou votre clé d'accès, puis réessayez.",
|
||||
"errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé. Vérifiez votre connexion et réessayez.",
|
||||
"errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.",
|
||||
"errorFailed": "Le profil n'a pas pu être créé.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.",
|
||||
"guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"guardianErrorFailed": "L'invitation n'a pas pu être envoyée.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"errorMfaRequired": "Cela demande un deuxième facteur ou une clé d'accès, et votre compte n'a encore ni l'un ni l'autre. Configurez-en un dans Réglages → Sécurité, puis réessayez.",
|
||||
"rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.",
|
||||
"errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez.",
|
||||
"handover": {
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Modificali in Impostazioni, Account",
|
||||
"cta": "Conferma e continua",
|
||||
"managedTitle": "La persona di cui ti prendi cura",
|
||||
"managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore sul tuo account.",
|
||||
"managedBody": "La sua cartella non ha un accesso proprio; la gestisci dal tuo account. Per crearla serve un secondo fattore o una passkey sul tuo account.",
|
||||
"managedCreate": "Crea il profilo e concludi",
|
||||
"managedLater": "Puoi crearlo anche in seguito in Impostazioni, Accesso condiviso.",
|
||||
"finishWithoutProfile": "Concludi senza il profilo"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Password attuale (necessaria per cambiare l'indirizzo email)",
|
||||
"reproof": {
|
||||
"description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.",
|
||||
"descriptionChange": "Questo modifica qualcosa che solo tu dovresti poter modificare. Conferma che sei tu per continuare.",
|
||||
"signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.",
|
||||
"failed": "Non ha funzionato. Controlla quanto inserito e riprova."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "Non è stato possibile inviare l'invito. Riprova.",
|
||||
"errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.",
|
||||
"errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore o la tua passkey e riprova.",
|
||||
"errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "Crea profilo",
|
||||
"creating": "Creazione in corso…",
|
||||
"created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore e riprova.",
|
||||
"errorStepUp": "Conferma il tuo secondo fattore o la tua passkey e riprova.",
|
||||
"errorOffline": "Non è arrivato al server. Non è cambiato nulla. Controlla la connessione e riprova.",
|
||||
"errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.",
|
||||
"errorFailed": "Non è stato possibile creare il profilo.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.",
|
||||
"guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"guardianErrorFailed": "Non è stato possibile inviare l'invito.",
|
||||
"errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"errorMfaRequired": "Serve un secondo fattore o una passkey e il tuo account non ha ancora né l'uno né l'altra. Impostane uno in Impostazioni → Sicurezza e riprova.",
|
||||
"rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.",
|
||||
"errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova.",
|
||||
"handover": {
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "설정, 계정에서 변경",
|
||||
"cta": "확인하고 계속",
|
||||
"managedTitle": "내가 돌보는 사람",
|
||||
"managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증이 필요합니다.",
|
||||
"managedBody": "이 기록에는 별도의 로그인이 없으며 내 계정에서 관리합니다. 만들려면 내 계정에 2차 인증 또는 패스키가 필요합니다.",
|
||||
"managedCreate": "프로필 만들고 마치기",
|
||||
"managedLater": "나중에 설정, 공유 접근에서 만들 수도 있습니다.",
|
||||
"finishWithoutProfile": "프로필 없이 마치기"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "현재 비밀번호 (이메일 주소를 변경하려면 필요)",
|
||||
"reproof": {
|
||||
"description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.",
|
||||
"descriptionChange": "본인만 변경할 수 있어야 하는 항목을 변경합니다. 계속하려면 본인임을 확인하세요.",
|
||||
"signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.",
|
||||
"failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "초대를 보내지 못했어요. 다시 시도해 주세요.",
|
||||
"errorScope": "그 섹션들은 그렇게 공유할 수 없어요. 섹션을 하나 이상 고르거나 기록 전체를 공유하세요.",
|
||||
"errorManageBrowserOnly": "관리 권한은 브라우저에서만 줄 수 있어요.",
|
||||
"errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorStepUp": "2단계 인증 또는 패스키를 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorOffline": "서버에 닿지 못했어요. 초대 내용은 그대로 있으니 연결을 확인하고 다시 시도해 주세요."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "프로필 만들기",
|
||||
"creating": "만드는 중…",
|
||||
"created": "{name} 님 프로필을 만들었어요. 계정 메뉴에서 그 기록으로 전환할 수 있어요.",
|
||||
"errorStepUp": "2단계 인증을 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorStepUp": "2단계 인증 또는 패스키를 확인한 뒤 다시 시도해 주세요.",
|
||||
"errorOffline": "서버에 닿지 못했어요. 바뀐 것은 없으니 연결을 확인하고 다시 시도해 주세요.",
|
||||
"errorInvalid": "서버가 그 내용을 거절했어요. 이름과 날짜, 시간대를 확인해 주세요.",
|
||||
"errorFailed": "프로필을 만들지 못했어요.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "이 인스턴스에 그 이름이나 이메일을 쓰는 계정이 없어요.",
|
||||
"guardianErrorRateLimit": "최근 한 시간 동안 초대가 너무 많았어요. 잠시 기다렸다 다시 시도해 주세요.",
|
||||
"guardianErrorFailed": "초대를 보내지 못했어요.",
|
||||
"errorMfaRequired": "여기에는 2단계 인증이 필요한데 계정에 아직 없어요. 설정 → 보안에서 등록한 뒤 다시 시도해 주세요.",
|
||||
"errorMfaRequired": "여기에는 2단계 인증 또는 패스키가 필요한데 계정에 아직 둘 다 없어요. 설정 → 보안에서 하나를 등록한 뒤 다시 시도해 주세요.",
|
||||
"rosterLoadError": "이 기록을 돌보는 사람을 불러오지 못했어요.",
|
||||
"errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요.",
|
||||
"handover": {
|
||||
|
||||
+5
-4
@@ -5160,7 +5160,7 @@
|
||||
"changeInSettings": "Zmień w Ustawieniach, Konto",
|
||||
"cta": "Potwierdź i kontynuuj",
|
||||
"managedTitle": "Osoba, którą się opiekujesz",
|
||||
"managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik na Twoim koncie.",
|
||||
"managedBody": "Jej kartoteka nie ma własnego logowania; zarządzasz nią ze swojego konta. Do jej utworzenia potrzebny jest drugi składnik albo klucz dostępu na Twoim koncie.",
|
||||
"managedCreate": "Utwórz profil i zakończ",
|
||||
"managedLater": "Możesz też utworzyć go później w Ustawieniach, Dostęp współdzielony.",
|
||||
"finishWithoutProfile": "Zakończ bez profilu"
|
||||
@@ -7323,6 +7323,7 @@
|
||||
"emailChangePassword": "Obecne hasło (potrzebne do zmiany adresu e-mail)",
|
||||
"reproof": {
|
||||
"description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"descriptionChange": "To zmienia coś, co tylko Ty powinieneś móc zmienić. Potwierdź, że to Ty, aby kontynuować.",
|
||||
"signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.",
|
||||
"failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie."
|
||||
},
|
||||
@@ -12260,7 +12261,7 @@
|
||||
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.",
|
||||
"errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.",
|
||||
"errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania albo klucz dostępu i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest. Sprawdź połączenie i spróbuj ponownie."
|
||||
},
|
||||
"given": {
|
||||
@@ -12337,7 +12338,7 @@
|
||||
"create": "Utwórz profil",
|
||||
"creating": "Tworzenie…",
|
||||
"created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.",
|
||||
"errorStepUp": "Potwierdź drugi składnik logowania albo klucz dostępu i spróbuj ponownie.",
|
||||
"errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.",
|
||||
"errorFailed": "Nie udało się utworzyć profilu.",
|
||||
@@ -12376,7 +12377,7 @@
|
||||
"guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.",
|
||||
"guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"guardianErrorFailed": "Nie udało się wysłać zaproszenia.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"errorMfaRequired": "To wymaga drugiego składnika logowania albo klucza dostępu, a Twoje konto nie ma jeszcze żadnego z nich. Skonfiguruj jeden w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.",
|
||||
"rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.",
|
||||
"errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"handover": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.43.2",
|
||||
"version": "1.43.3",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.43.2";
|
||||
/* @sw-version-fallback */ "v1.43.3";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Every web action behind a fresh-proof gate goes through the re-proof dialog.
|
||||
*
|
||||
* Two server gates hold an action back until the session has proved something
|
||||
* recently: the recent-proof gate (`requireRecentProof`,
|
||||
* `assertRecentCookieProof`, 401 `auth.reproof.required`) and the fresh-factor
|
||||
* gate (`requireFreshMfa` and the helpers that escalate to it, 401
|
||||
* `auth.stepup.required`). Both name the proofs that clear them in
|
||||
* `meta.methods`, and `useRecentProof` opens the dialog on either, posts the
|
||||
* proof to `/api/auth/reproof` and runs the action once more.
|
||||
*
|
||||
* The defect this exists for: the managed-profile acts (handing a profile
|
||||
* over, adding and removing a Guardian, creating, editing and ending a
|
||||
* record) answered the fresh-factor refusal with a sentence telling the
|
||||
* person to confirm their second factor, and offered nowhere to do it. The
|
||||
* same was true of disabling the second factor, rotating recovery codes,
|
||||
* removing a security key or passkey, changing the password, the data wipe,
|
||||
* account deletion and the key rotation. The recent-proof surfaces had the
|
||||
* dialog; the fresh-factor ones had text.
|
||||
*
|
||||
* What it pins:
|
||||
*
|
||||
* 1. The set of gated route methods, discovered from the route files and
|
||||
* frozen below. A new gated route has to be entered here, with either
|
||||
* its web callers checked by the sweep or the reason it has none.
|
||||
* 2. Every request a web file makes to one of them runs inside the shared
|
||||
* flow: `recentProof.run(…)` in a component, or `guard(…)` inside a
|
||||
* query hook whose `guard` parameter every caller fills with
|
||||
* `recentProof.run`. A gated request outside it is the defect again.
|
||||
*
|
||||
* Every sweep asserts a floor, because a matcher that finds nothing agrees
|
||||
* with every list.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Source with comments removed, so a route that only DESCRIBES a gate in its
|
||||
* doc comment is not counted, and a path quoted in a comment is not a call.
|
||||
*/
|
||||
function code(rel: string): string {
|
||||
return read(rel)
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/(^|[^:"'`\\])\/\/.*$/, "$1"))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
||||
|
||||
const GATE =
|
||||
/\b(?:requireFreshMfa|requireFreshMfaIfEnrolled|requireFreshMfaOrElevationIfEnrolled|requireRecentProof|assertRecentCookieProof)\s*\(|\bfreshFactor\s*:\s*true\b/;
|
||||
|
||||
/** `src/app/api/a/[id]/route.ts` -> `/api/a/[id]`. */
|
||||
function routePath(rel: string): string {
|
||||
return "/" + rel.replace(/^app\//, "").replace(/\/route\.ts$/, "");
|
||||
}
|
||||
|
||||
/** The gated `METHOD /api/path` pairs, read off the route files. */
|
||||
function discoverGatedRoutes(): string[] {
|
||||
const routes = walkSourceFiles(join(SRC, "app", "api"), { floor: 300 })
|
||||
.filter((p) => p.endsWith("/route.ts") && !p.includes("__tests__"))
|
||||
.map((p) => `app/api/${p}`);
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
|
||||
const found = new Set<string>();
|
||||
for (const rel of routes) {
|
||||
const src = code(rel);
|
||||
if (!GATE.test(src)) continue;
|
||||
const exportRe = new RegExp(
|
||||
`export\\s+(?:const\\s+(${METHODS.join("|")})\\s*=|async\\s+function\\s+(${METHODS.join("|")})\\s*\\()`,
|
||||
"g",
|
||||
);
|
||||
const exports = [...src.matchAll(exportRe)].map((m) => ({
|
||||
method: m[1] ?? m[2],
|
||||
index: m.index ?? 0,
|
||||
}));
|
||||
const path = routePath(rel);
|
||||
// A gate before the first exported method lives in a helper or a
|
||||
// `handler` constant the exports wrap, and binds every method.
|
||||
const preamble = src.slice(0, exports[0]?.index ?? src.length);
|
||||
if (GATE.test(preamble)) {
|
||||
for (const e of exports) found.add(`${e.method} ${path}`);
|
||||
}
|
||||
exports.forEach((e, i) => {
|
||||
const block = src.slice(e.index, exports[i + 1]?.index ?? src.length);
|
||||
if (GATE.test(block)) found.add(`${e.method} ${path}`);
|
||||
});
|
||||
}
|
||||
return [...found].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every gated route method, and how the web reaches it. `"web"` means the
|
||||
* sweep below finds its callers and checks them; anything else is the reason
|
||||
* no web file calls it.
|
||||
*/
|
||||
const GATED_ROUTES: Record<string, string> = {
|
||||
"DELETE /api/admin/data": "web",
|
||||
"DELETE /api/auth/me/mfa/webauthn/[id]": "web",
|
||||
"DELETE /api/auth/passkeys/[id]": "web",
|
||||
"DELETE /api/managed-profiles/[id]": "web",
|
||||
"DELETE /api/managed-profiles/[id]/guardians/[grantId]": "web",
|
||||
"DELETE /api/settings/account": "web",
|
||||
"DELETE /api/settings/data": "web",
|
||||
"GET /api/admin/backups/[id]/download": "web",
|
||||
"GET /api/auth/reproof":
|
||||
"the flow's own question: the web asks it inside recentProof.run before an upload or a typed confirmation",
|
||||
"GET /api/export":
|
||||
"the plaintext whole-record download (`type=all`); the web's whole-record export is the full backup, and its single-type exports have their own routes",
|
||||
"GET /api/export/full-backup": "web",
|
||||
"GET /api/fhir/Patient/$everything":
|
||||
"a FHIR client endpoint, reached with a fhir:read token; no web file calls it",
|
||||
"GET /api/mcp/oauth/authorize":
|
||||
"a browser navigation from an assistant, answered with a page that links to /confirm-access, which runs the dialog",
|
||||
"PATCH /api/managed-profiles/[id]": "web",
|
||||
"POST /api/account/grants": "web",
|
||||
"POST /api/admin/backups/[id]/restore": "web",
|
||||
"POST /api/admin/backups/upload": "web",
|
||||
"POST /api/admin/encryption/rotate": "web",
|
||||
"POST /api/admin/users/[id]/reset-password": "web",
|
||||
"POST /api/auth/me/mfa/disable": "web",
|
||||
"POST /api/auth/me/mfa/recovery-codes/regenerate": "web",
|
||||
"POST /api/auth/password": "web",
|
||||
"POST /api/export/encrypted": "web",
|
||||
"POST /api/export/health-record": "web",
|
||||
"POST /api/managed-profiles": "web",
|
||||
"POST /api/managed-profiles/[id]/guardians": "web",
|
||||
"POST /api/managed-profiles/[id]/handover": "web",
|
||||
"POST /api/mcp/oauth/authorize":
|
||||
"the consent form's own submission, refused into the same /confirm-access page as the GET",
|
||||
"POST /api/mcp/tokens": "web",
|
||||
"POST /api/share-links": "web",
|
||||
"POST /api/tokens/documents": "web",
|
||||
"POST /api/tokens/measurements": "web",
|
||||
"POST /api/tokens/workouts": "web",
|
||||
};
|
||||
|
||||
/** The web side: everything under src that is not a route, a test or docs. */
|
||||
function webFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.startsWith("app/api/"))
|
||||
.filter((p) => !p.startsWith("lib/openapi/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** A string or template literal naming exactly this path. */
|
||||
function pathPattern(path: string): RegExp {
|
||||
const body = path
|
||||
.split("/")
|
||||
.map((seg) =>
|
||||
/^\[.+\]$/.test(seg)
|
||||
? "\\$\\{[^}]+\\}"
|
||||
: seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
)
|
||||
.join("/");
|
||||
return new RegExp(`["'\`]${body}(?=["'\`?])`, "g");
|
||||
}
|
||||
|
||||
/** Which method a call naming the path at `index` sends. */
|
||||
function methodAt(src: string, index: number, matchLength: number): string {
|
||||
const before = src.slice(Math.max(0, index - 160), index);
|
||||
const helper = [
|
||||
...before.matchAll(/\bapi(Get|Post|Put|Patch|Delete)\b(?:<[^()]*>)?\s*\(/g),
|
||||
].pop();
|
||||
// The helper must be the call this literal is the first argument of.
|
||||
if (
|
||||
helper &&
|
||||
/^\s*$/.test(before.slice((helper.index ?? 0) + helper[0].length))
|
||||
) {
|
||||
return helper[1].toUpperCase();
|
||||
}
|
||||
const after = src.slice(index + matchLength, index + matchLength + 240);
|
||||
const explicit = after.match(/method\s*:\s*["'](\w+)["']/);
|
||||
return explicit ? explicit[1].toUpperCase() : "GET";
|
||||
}
|
||||
|
||||
/** The name of the function the code at `index` sits in, if it has one. */
|
||||
function enclosingFunction(src: string, index: number): string | null {
|
||||
const head = src.slice(0, index);
|
||||
const decls = [
|
||||
...head.matchAll(
|
||||
/(?:\bfunction\s+(\w+)\s*\(|\b(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>)/g,
|
||||
),
|
||||
].pop();
|
||||
return decls ? (decls[1] ?? decls[2] ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request at `index` runs inside the shared flow: directly inside a
|
||||
* `recentProof.run(…)` or a hook's `guard(…)`, or in a named function that is
|
||||
* itself handed to, or called from, `recentProof.run`.
|
||||
*/
|
||||
function guardedAt(src: string, index: number): boolean {
|
||||
const before = src.slice(Math.max(0, index - 400), index);
|
||||
if (/(?:\.run|\bguard)\s*\(/.test(before)) return true;
|
||||
const name = enclosingFunction(src, index);
|
||||
if (!name) return false;
|
||||
return new RegExp(
|
||||
`\\.run\\(\\s*(?:async\\s*)?(?:\\(\\)\\s*=>\\s*)?${name}\\b`,
|
||||
).test(src);
|
||||
}
|
||||
|
||||
interface GatedCall {
|
||||
file: string;
|
||||
route: string;
|
||||
guarded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A query hook whose request is not guarded inside the hook is still guarded
|
||||
* when every component that mounts it sends the mutation through the flow:
|
||||
* `recentProof.run(() => mutation.mutateAsync(…))`. At least one such
|
||||
* component must exist, or nothing is guarded.
|
||||
*/
|
||||
function hookCallersWrap(
|
||||
files: string[],
|
||||
hookFile: string,
|
||||
src: string,
|
||||
index: number,
|
||||
): boolean {
|
||||
const hook = [
|
||||
...src.slice(0, index).matchAll(/export function (use\w+)/g),
|
||||
].pop()?.[1];
|
||||
if (!hook) return false;
|
||||
let callers = 0;
|
||||
for (const file of files) {
|
||||
if (file === hookFile) continue;
|
||||
const other = code(file);
|
||||
for (const m of other.matchAll(
|
||||
new RegExp(`\\b(?:const|let)\\s+(\\w+)\\s*=\\s*${hook}\\s*\\(`, "g"),
|
||||
)) {
|
||||
callers++;
|
||||
const wraps = new RegExp(
|
||||
`\\.run\\(\\s*(?:async\\s*)?\\(\\)\\s*=>\\s*${m[1]}\\.mutateAsync\\b`,
|
||||
);
|
||||
if (!wraps.test(other)) return false;
|
||||
}
|
||||
}
|
||||
return callers > 0;
|
||||
}
|
||||
|
||||
function gatedWebCalls(): GatedCall[] {
|
||||
const calls: GatedCall[] = [];
|
||||
const files = webFiles();
|
||||
const sources = new Map(files.map((f) => [f, code(f)]));
|
||||
for (const route of Object.keys(GATED_ROUTES)) {
|
||||
if (GATED_ROUTES[route] !== "web") continue;
|
||||
const [method, path] = route.split(" ");
|
||||
const re = pathPattern(path);
|
||||
for (const file of files) {
|
||||
const src = sources.get(file) ?? "";
|
||||
for (const m of src.matchAll(re)) {
|
||||
const index = m.index ?? 0;
|
||||
// The path handed to a component as a prop (`endpoint="/api/…"`):
|
||||
// the request is wherever that component sends the prop.
|
||||
const prop = src.slice(Math.max(0, index - 40), index).match(/(\w+)=$/);
|
||||
if (prop) {
|
||||
const sends = [
|
||||
...src.matchAll(
|
||||
new RegExp(`\\bapiFetchRaw\\(\\s*${prop[1]}\\s*,`, "g"),
|
||||
),
|
||||
];
|
||||
for (const send of sends) {
|
||||
const at = send.index ?? 0;
|
||||
if (methodAt(src, at, send[0].length) !== method) continue;
|
||||
calls.push({ file, route, guarded: guardedAt(src, at) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (methodAt(src, index, m[0].length) !== method) continue;
|
||||
const guarded =
|
||||
guardedAt(src, index) ||
|
||||
(file.startsWith("lib/queries/") &&
|
||||
hookCallersWrap(files, file, src, index));
|
||||
calls.push({ file, route, guarded });
|
||||
}
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the sweep cannot read as guarded, each checked by hand and pinned.
|
||||
* The request runs after a `recentProof.run(() => apiGet("/api/auth/reproof"))`
|
||||
* a few lines earlier, so the proof is in place before a file is sent or a
|
||||
* typed confirmation is spent.
|
||||
*/
|
||||
const PRECHECKED = new Set<string>([
|
||||
// `upload.mutationFn` asks first, then calls `uploadOnce`, so a large file
|
||||
// is not sent once only to be refused.
|
||||
"components/admin/backups-section.tsx -> POST /api/admin/backups/upload",
|
||||
]);
|
||||
|
||||
describe("fresh-proof gates on the web", () => {
|
||||
it("freezes the set of gated route methods", () => {
|
||||
const discovered = discoverGatedRoutes();
|
||||
expect(discovered.length).toBeGreaterThanOrEqual(30);
|
||||
expect(discovered).toEqual(Object.keys(GATED_ROUTES).sort());
|
||||
});
|
||||
|
||||
it("finds a web caller for every route marked as called from the web", () => {
|
||||
const calls = gatedWebCalls();
|
||||
expect(calls.length).toBeGreaterThanOrEqual(25);
|
||||
const reached = new Set(calls.map((c) => c.route));
|
||||
const unreached = Object.keys(GATED_ROUTES).filter(
|
||||
(r) => GATED_ROUTES[r] === "web" && !reached.has(r),
|
||||
);
|
||||
expect(unreached).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends every gated request through the re-proof flow", () => {
|
||||
const unguarded = gatedWebCalls()
|
||||
.filter((c) => !c.guarded)
|
||||
.map((c) => `${c.file} -> ${c.route}`)
|
||||
.filter((key) => !PRECHECKED.has(key));
|
||||
expect(unguarded).toEqual([]);
|
||||
});
|
||||
|
||||
it("renders the dialog wherever a component runs the flow", () => {
|
||||
const files = [...new Set(gatedWebCalls().map((c) => c.file))].filter((f) =>
|
||||
f.endsWith(".tsx"),
|
||||
);
|
||||
expect(files.length).toBeGreaterThanOrEqual(10);
|
||||
const missing = files.filter((f) => {
|
||||
const src = code(f);
|
||||
return !/useRecentProof\s*\(/.test(src) || !/\.dialog\b/.test(src);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes every caller of a guarded query hook pass recentProof.run", () => {
|
||||
// Hooks whose gated request runs inside `guard(…)`.
|
||||
const hookFiles = [
|
||||
...new Set(
|
||||
gatedWebCalls()
|
||||
.filter((c) => c.file.startsWith("lib/queries/"))
|
||||
.map((c) => c.file),
|
||||
),
|
||||
];
|
||||
expect(hookFiles.length).toBeGreaterThanOrEqual(1);
|
||||
const hooks: string[] = [];
|
||||
for (const file of hookFiles) {
|
||||
for (const m of code(file).matchAll(
|
||||
/export function (use\w+)\(\s*guard\s*:\s*ProofGuard\s*\)/g,
|
||||
)) {
|
||||
hooks.push(m[1]);
|
||||
}
|
||||
}
|
||||
expect(hooks.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
const offenders: string[] = [];
|
||||
let callSites = 0;
|
||||
for (const file of webFiles()) {
|
||||
if (hookFiles.includes(file)) continue;
|
||||
const src = code(file);
|
||||
for (const hook of hooks) {
|
||||
for (const m of src.matchAll(
|
||||
new RegExp(`\\b${hook}\\s*\\(([^)]*)\\)`, "g"),
|
||||
)) {
|
||||
callSites++;
|
||||
if (!/^\s*\w+\.run\s*$/.test(m[1])) {
|
||||
offenders.push(`${file}: ${hook}(${m[1].trim()})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(callSites).toBeGreaterThanOrEqual(6);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@
|
||||
* token, or run an admin backup, restore, wipe or reset. Those routes answer
|
||||
* 401 `auth.reproof.required` with `meta.methods` when the session carries no
|
||||
* recent proof; the web opens the re-proof dialog, posts the proof here, and
|
||||
* retries.
|
||||
* retries. The fresh-factor routes (`requireFreshMfa`: the managed-profile
|
||||
* acts, second-factor and passkey changes, the password change, account
|
||||
* deletion, the data wipe, key rotation) answer 401 `auth.stepup.required`
|
||||
* with `meta.methods` on a browser session, and the same dialog clears them
|
||||
* here with a possession proof.
|
||||
*
|
||||
* What a proof stamps on the session row:
|
||||
* - `totp`, `webauthn` or `passkey`: `mfaVerifiedAt` and `reproofAt`. The
|
||||
|
||||
@@ -45,6 +45,11 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { KeyBackupCard } from "./key-backup-card";
|
||||
import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
@@ -105,12 +110,22 @@ export function EncryptionSection() {
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
// Rotation needs the admin's second factor proved within five minutes. When
|
||||
// it is older the server names the proofs that clear it, the shared dialog
|
||||
// collects one, and the rotation is queued once more.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const rotate = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiFetchRaw("/api/admin/encryption/rotate", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/admin/encryption/rotate", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
throw new Error(t("admin.section.encryption.stepUpNeeded"));
|
||||
@@ -126,10 +141,12 @@ export function EncryptionSection() {
|
||||
});
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showErrorToast(
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
err.message || t("admin.section.encryption.rotateFailed"),
|
||||
{ cause: err },
|
||||
);
|
||||
if (message === null) return;
|
||||
showErrorToast(message, { cause: err });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -182,6 +199,7 @@ export function EncryptionSection() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{recentProof.dialog}
|
||||
<KeyBackupCard />
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
|
||||
@@ -600,7 +600,7 @@ describe("ending a managed profile", () => {
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("Confirm your second factor");
|
||||
expect(html).toContain("Confirm your second factor or passkey");
|
||||
expect(html).not.toContain("The profile could not be deleted");
|
||||
});
|
||||
|
||||
@@ -616,7 +616,7 @@ describe("ending a managed profile", () => {
|
||||
"p1",
|
||||
);
|
||||
const html = renderGatedSection();
|
||||
expect(html).toContain("does not have one yet");
|
||||
expect(html).toContain("has neither yet");
|
||||
expect(html).toContain("Security");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* #959 — "I get the error message that I need to confirm my second factor,
|
||||
* but there is nowhere to do so."
|
||||
*
|
||||
* Handing a managed profile over, adding a Guardian and removing one are gated
|
||||
* on a second factor proved within five minutes (`requireFreshMfa`). Its
|
||||
* refusal, 401 `auth.stepup.required`, now names the proofs that clear it in
|
||||
* `meta.methods`, and each of these mutations runs inside the re-proof flow:
|
||||
* the dialog opens with those proofs, the proof goes to `/api/auth/reproof`,
|
||||
* and the request is sent once more.
|
||||
*
|
||||
* Driven for real below: the hooks' own `mutationFn`s, the flow
|
||||
* `useRecentProof` binds to the dialog, and a fake network that answers the
|
||||
* way the routes do. `@tanstack/react-query` is reduced to the two calls the
|
||||
* hook bodies make, as in `managed-profiles-invalidation.test.ts`; the unit
|
||||
* environment has no DOM, so the dialog itself is represented by the view the
|
||||
* flow drives (which proofs it offers, its error line) and by the test acting
|
||||
* as the person: proving, or closing it.
|
||||
*
|
||||
* Mutation checks, run: drop `guard(…)` from `useCreateManagedProfileHandover`
|
||||
* and every handover case goes red (the refusal escapes, no dialog); make
|
||||
* `isProofRefusal` match `auth.reproof.required` only and every case that
|
||||
* expects a dialog goes red.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
return {
|
||||
...actual,
|
||||
useMutation: (options: unknown) => options,
|
||||
useQueryClient: () => new QueryClient(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("qrcode", () => ({
|
||||
default: { toDataURL: vi.fn(async () => "data:image/png;base64,AAAA") },
|
||||
}));
|
||||
|
||||
import {
|
||||
useCreateManagedProfileHandover,
|
||||
useInviteManagedProfileGuardian,
|
||||
useRemoveManagedProfileGuardian,
|
||||
} from "@/lib/queries/use-managed-profiles";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
createRecentProofFlow,
|
||||
type RecentProofFlow,
|
||||
} from "@/components/settings/security-section/recent-proof-flow";
|
||||
import type { ReauthMethod } from "@/components/settings/security-section/existing-factor-reauth-dialog";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
|
||||
type MutationOptions<I, O> = { mutationFn: (input: I) => Promise<O> };
|
||||
|
||||
// ── The network, answering the way the routes do ────────────────────────────
|
||||
|
||||
const STALE = {
|
||||
data: null,
|
||||
error: "Recent second-factor verification required",
|
||||
meta: { errorCode: "auth.stepup.required", methods: ["totp", "passkey"] },
|
||||
};
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Every request, as `METHOD path`, in order. */
|
||||
let sent: string[] = [];
|
||||
/** The session's second factor is fresh once a proof was accepted. */
|
||||
let fresh = false;
|
||||
/** The codes the authenticator would accept. */
|
||||
const GOOD_CODE = "246810";
|
||||
let actionAnswer: () => Response = () => json(200, { data: {}, error: null });
|
||||
|
||||
beforeEach(() => {
|
||||
sent = [];
|
||||
fresh = false;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
sent.push(`${method} ${path}`);
|
||||
if (path === "/api/auth/reproof") {
|
||||
const proof = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (proof.method === "totp" && proof.code === GOOD_CODE) {
|
||||
fresh = true;
|
||||
return json(200, { data: { method: "totp" }, error: null });
|
||||
}
|
||||
return json(401, {
|
||||
data: null,
|
||||
error: "Verification failed",
|
||||
meta: { errorCode: "auth.reproof.failed" },
|
||||
});
|
||||
}
|
||||
return fresh ? actionAnswer() : json(401, STALE);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── The dialog, as the flow drives it ───────────────────────────────────────
|
||||
|
||||
interface Dialog {
|
||||
flow: RecentProofFlow;
|
||||
/** What the dialog offered each time it opened. */
|
||||
offered: ReauthMethod[][];
|
||||
open: boolean;
|
||||
error: string | null;
|
||||
/** Resolves when the dialog next opens. */
|
||||
opened: () => Promise<void>;
|
||||
}
|
||||
|
||||
function dialog(): Dialog {
|
||||
let notify: (() => void) | null = null;
|
||||
const d: Dialog = {
|
||||
offered: [],
|
||||
open: false,
|
||||
error: null,
|
||||
opened: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (d.open) resolve();
|
||||
else notify = resolve;
|
||||
}),
|
||||
flow: undefined as unknown as RecentProofFlow,
|
||||
};
|
||||
d.flow = createRecentProofFlow({
|
||||
view: {
|
||||
setMethods: (methods) => {
|
||||
d.open = methods !== null;
|
||||
if (methods) {
|
||||
d.offered.push(methods);
|
||||
notify?.();
|
||||
notify = null;
|
||||
}
|
||||
},
|
||||
setPending: () => {},
|
||||
setError: (error) => {
|
||||
d.error = error;
|
||||
},
|
||||
},
|
||||
// The same call `useRecentProof` makes.
|
||||
submit: (proof) => apiPost("/api/auth/reproof", proof),
|
||||
messages: () => ({ signInAgain: "sign in again", failed: "failed" }),
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
const HANDOVER = "/api/managed-profiles/p1/handover";
|
||||
|
||||
function useHandover(d: Dialog) {
|
||||
return useCreateManagedProfileHandover(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; expiresInDays: 7; proposals: [] },
|
||||
{ url: string; qrDataUrl: string }
|
||||
>;
|
||||
}
|
||||
|
||||
describe("handing a profile over with a stale second factor", () => {
|
||||
beforeEach(() => {
|
||||
actionAnswer = () =>
|
||||
json(201, {
|
||||
data: {
|
||||
url: "https://example.test/claim#t",
|
||||
token: "t",
|
||||
expiresAt: "2026-10-24T00:00:00.000Z",
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the dialog with the proofs the server named, then hands over", async () => {
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
expect(d.offered).toEqual([["totp", "passkey"]]);
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
|
||||
const link = await pending;
|
||||
expect(link.url).toBe("https://example.test/claim#t");
|
||||
expect(d.open).toBe(false);
|
||||
expect(sent).toEqual([
|
||||
`POST ${HANDOVER}`,
|
||||
"POST /api/auth/reproof",
|
||||
`POST ${HANDOVER}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the dialog open on a wrong code and retries only after a right one", async () => {
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
await d.flow.prove({ method: "totp", code: "000000" });
|
||||
expect(d.open).toBe(true);
|
||||
expect(d.error).toBe("failed");
|
||||
expect(sent).toEqual([`POST ${HANDOVER}`, "POST /api/auth/reproof"]);
|
||||
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
await pending;
|
||||
expect(sent.filter((s) => s === `POST ${HANDOVER}`)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sends nothing more when the dialog is closed", async () => {
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
d.flow.cancel();
|
||||
|
||||
await expect(pending).rejects.toBeInstanceOf(ReproofCancelledError);
|
||||
expect(sent).toEqual([`POST ${HANDOVER}`]);
|
||||
});
|
||||
|
||||
it("asks once: a second refusal after the proof is the answer", async () => {
|
||||
actionAnswer = () => json(401, STALE);
|
||||
const d = dialog();
|
||||
const pending = useHandover(d).mutationFn({
|
||||
profileId: "p1",
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ status: 401 });
|
||||
expect(d.offered).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("opens no dialog for an account with no second factor, and says where to get one", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
json(401, {
|
||||
data: null,
|
||||
error: "Recent second-factor verification required",
|
||||
meta: { errorCode: "auth.stepup.mfa_not_enrolled" },
|
||||
}),
|
||||
);
|
||||
const d = dialog();
|
||||
const err = await useHandover(d)
|
||||
.mutationFn({ profileId: "p1", expiresInDays: 7, proposals: [] })
|
||||
.catch((e: unknown) => e);
|
||||
|
||||
expect(d.offered).toEqual([]);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
expect(stepUpErrorKey(err as ApiError)).toBe(
|
||||
"recordSharing.managed.errorMfaRequired",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding and removing a Guardian with a stale second factor", () => {
|
||||
beforeEach(() => {
|
||||
actionAnswer = () => json(201, { data: { id: "g2" }, error: null });
|
||||
});
|
||||
|
||||
it("adds the Guardian after the proof", async () => {
|
||||
const d = dialog();
|
||||
const invite = useInviteManagedProfileGuardian(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; identifier: string; expiresAt: null },
|
||||
unknown
|
||||
>;
|
||||
const pending = invite.mutationFn({
|
||||
profileId: "p1",
|
||||
identifier: "someone",
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
await d.opened();
|
||||
expect(d.offered).toEqual([["totp", "passkey"]]);
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
await pending;
|
||||
|
||||
expect(sent).toEqual([
|
||||
"POST /api/managed-profiles/p1/guardians",
|
||||
"POST /api/auth/reproof",
|
||||
"POST /api/managed-profiles/p1/guardians",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes the Guardian after the proof", async () => {
|
||||
actionAnswer = () => json(200, { data: { revoked: true }, error: null });
|
||||
const d = dialog();
|
||||
const remove = useRemoveManagedProfileGuardian(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; grantId: string },
|
||||
unknown
|
||||
>;
|
||||
const pending = remove.mutationFn({ profileId: "p1", grantId: "g2" });
|
||||
|
||||
await d.opened();
|
||||
await d.flow.prove({ method: "totp", code: GOOD_CODE });
|
||||
await pending;
|
||||
|
||||
expect(sent).toEqual([
|
||||
"DELETE /api/managed-profiles/p1/guardians/g2",
|
||||
"POST /api/auth/reproof",
|
||||
"DELETE /api/managed-profiles/p1/guardians/g2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes nothing when the dialog is closed", async () => {
|
||||
const d = dialog();
|
||||
const remove = useRemoveManagedProfileGuardian(
|
||||
d.flow.run,
|
||||
) as unknown as MutationOptions<
|
||||
{ profileId: string; grantId: string },
|
||||
unknown
|
||||
>;
|
||||
const pending = remove.mutationFn({ profileId: "p1", grantId: "g2" });
|
||||
|
||||
await d.opened();
|
||||
d.flow.cancel();
|
||||
await expect(pending).rejects.toBeInstanceOf(ReproofCancelledError);
|
||||
expect(sent).toEqual(["DELETE /api/managed-profiles/p1/guardians/g2"]);
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const STEP_UP_ACTIONS = new Set<GrantAction>([
|
||||
*
|
||||
* `requireFreshMfa` refuses for two different reasons and says which: a stale
|
||||
* proof (`auth.stepup.required`), which the person clears by proving their
|
||||
* factor, and NO factor at all (`auth.stepup.mfa_not_enrolled`), which they
|
||||
* factor in the re-proof dialog (`useRecentProof`), and NO factor at all (`auth.stepup.mfa_not_enrolled`), which they
|
||||
* cannot clear that way. Collapsing the two tells somebody with no second
|
||||
* factor to confirm one — a dead end offered by the very surface that owns
|
||||
* the route out of it.
|
||||
@@ -74,9 +74,19 @@ const STEP_UP_ACTIONS = new Set<GrantAction>([
|
||||
* invitation, and the two acts below) so the two sentences cannot come apart.
|
||||
*/
|
||||
export function stepUpErrorKey(err: ApiError): string {
|
||||
return err.meta?.errorCode === "auth.stepup.mfa_not_enrolled"
|
||||
? "recordSharing.managed.errorMfaRequired"
|
||||
: "recordSharing.managed.errorStepUp";
|
||||
switch (err.meta?.errorCode) {
|
||||
case "auth.stepup.mfa_not_enrolled":
|
||||
return "recordSharing.managed.errorMfaRequired";
|
||||
// The re-proof dialog had nothing to offer (an account that signs in
|
||||
// through an identity provider only); signing in again is the way through.
|
||||
case "auth.reproof.sign_in_again":
|
||||
return "settings.reproof.signInAgain";
|
||||
// A stale proof normally never gets here: every action in this family runs
|
||||
// through the re-proof dialog, which clears it and sends the request again.
|
||||
// This is the sentence for a refusal the dialog could not take up.
|
||||
default:
|
||||
return "recordSharing.managed.errorStepUp";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
GrantActionAlert,
|
||||
grantActionErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ManagedProfileCreateForm } from "@/components/settings/access/managed-profile-create-form";
|
||||
import { ManagedProfileEditForm } from "@/components/settings/access/managed-profile-edit-form";
|
||||
import { ManagedProfileGuardians } from "@/components/settings/access/managed-profile-guardians";
|
||||
@@ -69,11 +73,17 @@ import {
|
||||
export function ManagedProfileCard() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const remove = useDeleteManagedProfile();
|
||||
// Ending a record is gated on a fresh second factor; the dialog collects it
|
||||
// and the deletion goes out once more.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const remove = useDeleteManagedProfile(recentProof.run);
|
||||
const profiles = managedProfilesOf(user?.accountAccess?.accounts);
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="managed-profile-card">
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="managed-profiles"
|
||||
icon={UserRoundCog}
|
||||
@@ -131,7 +141,10 @@ function ManagedProfileRow({
|
||||
const { t, tCount } = useTranslations();
|
||||
const guardians = useManagedProfileGuardians(profile.accountId);
|
||||
const name = accountLabel(profile);
|
||||
const failed = remove.isError && remove.variables === profile.accountId;
|
||||
const failed =
|
||||
remove.isError &&
|
||||
!(remove.error instanceof ReproofCancelledError) &&
|
||||
remove.variables === profile.accountId;
|
||||
// Per row, not per card. The edit form reads the record's identity when it
|
||||
// opens, so one shared flag would put every row's form on the wire the
|
||||
// moment any of them was opened.
|
||||
|
||||
@@ -9,6 +9,10 @@ import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -88,7 +92,12 @@ export function ManagedProfileCreateForm({
|
||||
onCreated?: (profile: ManagedProfileView) => void;
|
||||
} = {}) {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const create = useCreateManagedProfile();
|
||||
// Gated on a fresh second factor: the dialog collects it and the request
|
||||
// goes out once more. A closed dialog changed nothing and says nothing.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const create = useCreateManagedProfile(recentProof.run);
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [dateOfBirth, setDateOfBirth] = useState("");
|
||||
@@ -135,165 +144,173 @@ export function ManagedProfileCreateForm({
|
||||
// person is being asked to retry, and a form that emptied itself
|
||||
// would make them type it all again to find out whether the second
|
||||
// attempt fails the same way.
|
||||
onError: (err) => setError(t(createManagedProfileErrorKey(err))),
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(t(createManagedProfileErrorKey(err)));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-create-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-name">
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="managed-profile-name"
|
||||
data-slot="managed-profile-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("recordSharing.managed.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={
|
||||
displayName.length > 0 && nameIssue !== null ? "true" : undefined
|
||||
}
|
||||
aria-describedby="managed-profile-name-hint"
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-name"
|
||||
hintId="managed-profile-name-hint"
|
||||
hint={t("recordSharing.managed.nameHint")}
|
||||
// Only once somebody has typed. "Give the record a name" on an empty
|
||||
// field they have not reached yet is a telling-off, not guidance.
|
||||
error={
|
||||
displayName.length > 0 && nameIssue !== null ? t(nameIssue) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Outside the form: the dialog's own form must not submit this one
|
||||
through the portal. */}
|
||||
{recentProof.dialog}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-create-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-name">
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="managed-profile-name"
|
||||
data-slot="managed-profile-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={t("recordSharing.managed.namePlaceholder")}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={
|
||||
displayName.length > 0 && nameIssue !== null ? "true" : undefined
|
||||
}
|
||||
aria-describedby="managed-profile-name-hint"
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-name"
|
||||
hintId="managed-profile-name-hint"
|
||||
hint={t("recordSharing.managed.nameHint")}
|
||||
// Only once somebody has typed. "Give the record a name" on an empty
|
||||
// field they have not reached yet is a telling-off, not guidance.
|
||||
error={
|
||||
displayName.length > 0 && nameIssue !== null ? t(nameIssue) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-dob">
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="managed-profile-dob"
|
||||
data-slot="managed-profile-dob"
|
||||
data-testid="managed-profile-dob-input"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
aria-describedby="managed-profile-dob-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-dob-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.dobHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-dob">
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="managed-profile-dob"
|
||||
data-slot="managed-profile-dob"
|
||||
data-testid="managed-profile-dob-input"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
aria-describedby="managed-profile-dob-hint"
|
||||
/>
|
||||
<p
|
||||
id="managed-profile-dob-hint"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("recordSharing.managed.dobHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-locale">
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-locale"
|
||||
data-slot="managed-profile-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.localeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-locale">
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-locale"
|
||||
data-slot="managed-profile-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.localeHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-gender">
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-gender"
|
||||
data-slot="managed-profile-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="managed-profile-gender">
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="managed-profile-gender"
|
||||
data-slot="managed-profile-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
{/* The picker's own list comes from the engine, so the select branch
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id="managed-profile-timezone"
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
{/* The picker's own list comes from the engine, so the select branch
|
||||
cannot produce an unknown zone. Its free-text fallback (older
|
||||
engines without `Intl.supportedValuesOf`) can, and this sentence is
|
||||
then the ONLY explanation for a submit button that will not press.
|
||||
It rides beside the picker rather than through its `hint` prop,
|
||||
because that prop renders muted meta and a refusal rendered as meta
|
||||
is a refusal nobody reads. */}
|
||||
<FieldGuidance
|
||||
slot="managed-profile-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-create-submit"
|
||||
disabled={create.isPending || blocked}
|
||||
>
|
||||
{create.isPending
|
||||
? t("recordSharing.managed.creating")
|
||||
: (submitLabel ?? t("recordSharing.managed.create"))}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
data-slot="managed-profile-create-submit"
|
||||
disabled={create.isPending || blocked}
|
||||
>
|
||||
{create.isPending
|
||||
? t("recordSharing.managed.creating")
|
||||
: (submitLabel ?? t("recordSharing.managed.create"))}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-create-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{created && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-created"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.created", { name: created })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-create-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{created && (
|
||||
<p
|
||||
role="status"
|
||||
data-slot="managed-profile-created"
|
||||
className="text-sm"
|
||||
>
|
||||
{t("recordSharing.managed.created", { name: created })}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
genderFromSelect,
|
||||
} from "@/components/settings/access/managed-profile-create-form";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { TimezonePicker } from "@/components/settings/timezone-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -124,7 +128,12 @@ function ManagedProfileEditFields({
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t, locale: actorLocale } = useTranslations();
|
||||
const update = useUpdateManagedProfile();
|
||||
// Gated on a fresh second factor: the dialog collects it and the request
|
||||
// goes out once more. A closed dialog changed nothing and says nothing.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const update = useUpdateManagedProfile(recentProof.run);
|
||||
|
||||
const [displayName, setDisplayName] = useState(profile.displayName ?? "");
|
||||
const [dateOfBirth, setDateOfBirth] = useState(profile.dateOfBirth ?? "");
|
||||
@@ -172,143 +181,151 @@ function ManagedProfileEditFields({
|
||||
// Nothing is reset: a refused save is one somebody is being asked to
|
||||
// retry, and a form that restored the server's values would throw the
|
||||
// edit away to explain that it did not land.
|
||||
onError: (err) => setError(t(editManagedProfileErrorKey(err))),
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(t(editManagedProfileErrorKey(err)));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-edit-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-name-${profile.id}`}>
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`managed-profile-edit-name-${profile.id}`}
|
||||
data-slot="managed-profile-edit-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={nameIssue !== null ? "true" : undefined}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-name"
|
||||
error={nameIssue === null ? null : t(nameIssue)}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Outside the form: the dialog's own form must not submit this one
|
||||
through the portal. */}
|
||||
{recentProof.dialog}
|
||||
<form
|
||||
onSubmit={submit}
|
||||
data-slot="managed-profile-edit-form"
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-name-${profile.id}`}>
|
||||
{t("recordSharing.managed.nameLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`managed-profile-edit-name-${profile.id}`}
|
||||
data-slot="managed-profile-edit-name"
|
||||
value={displayName}
|
||||
onChange={(e) => {
|
||||
setDisplayName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={80}
|
||||
aria-invalid={nameIssue !== null ? "true" : undefined}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-name"
|
||||
error={nameIssue === null ? null : t(nameIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-dob-${profile.id}`}>
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`managed-profile-edit-dob-${profile.id}`}
|
||||
data-slot="managed-profile-edit-dob"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-dob-${profile.id}`}>
|
||||
{t("recordSharing.managed.dobLabel")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`managed-profile-edit-dob-${profile.id}`}
|
||||
data-slot="managed-profile-edit-dob"
|
||||
value={dateOfBirth}
|
||||
onChange={setDateOfBirth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-locale-${profile.id}`}>
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-locale-${profile.id}`}
|
||||
data-slot="managed-profile-edit-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-locale-${profile.id}`}>
|
||||
{t("recordSharing.managed.localeLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-locale-${profile.id}`}
|
||||
data-slot="managed-profile-edit-locale"
|
||||
className="w-full"
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value as Locale)}
|
||||
>
|
||||
{locales.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{localeLabels[option]}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-gender-${profile.id}`}>
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-gender-${profile.id}`}
|
||||
data-slot="managed-profile-edit-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`managed-profile-edit-gender-${profile.id}`}>
|
||||
{t("recordSharing.managed.genderLabel")}
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id={`managed-profile-edit-gender-${profile.id}`}
|
||||
data-slot="managed-profile-edit-gender"
|
||||
className="w-full"
|
||||
value={gender ?? ""}
|
||||
onChange={(e) => setGender(genderFromSelect(e.target.value))}
|
||||
>
|
||||
{MANAGED_PROFILE_GENDER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recordSharing.managed.genderHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id={`managed-profile-edit-timezone-${profile.id}`}
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<TimezonePicker
|
||||
id={`managed-profile-edit-timezone-${profile.id}`}
|
||||
value={timezone}
|
||||
onChange={(next) => {
|
||||
setTimezone(next);
|
||||
setError(null);
|
||||
}}
|
||||
hint={t("recordSharing.managed.timezoneHint")}
|
||||
/>
|
||||
<FieldGuidance
|
||||
slot="managed-profile-edit-timezone"
|
||||
error={timezoneIssue === null ? null : t(timezoneIssue)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-edit-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Above the action row, per UI-STANDARDS §12: nothing follows it. */}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-edit-error"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-cancel"
|
||||
onClick={onDone}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-submit"
|
||||
disabled={blocked || update.isPending}
|
||||
>
|
||||
{update.isPending
|
||||
? t("recordSharing.managed.editSaving")
|
||||
: t("recordSharing.managed.editSave")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-cancel"
|
||||
onClick={onDone}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="managed-profile-edit-submit"
|
||||
disabled={blocked || update.isPending}
|
||||
>
|
||||
{update.isPending
|
||||
? t("recordSharing.managed.editSaving")
|
||||
: t("recordSharing.managed.editSave")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
stepUpErrorKey,
|
||||
} from "@/components/settings/access/grant-action-error";
|
||||
import { endOfDayIso } from "@/components/settings/access/grant-invite-card";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
@@ -80,8 +84,14 @@ export function ManagedProfileGuardians({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user } = useAuth();
|
||||
const invite = useInviteManagedProfileGuardian();
|
||||
const remove = useRemoveManagedProfileGuardian();
|
||||
// Both acts are gated on a fresh second factor; the dialog collects it and
|
||||
// the request goes out once more. A closed dialog changed nothing and says
|
||||
// nothing.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const invite = useInviteManagedProfileGuardian(recentProof.run);
|
||||
const remove = useRemoveManagedProfileGuardian(recentProof.run);
|
||||
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [expiresOn, setExpiresOn] = useState("");
|
||||
@@ -116,6 +126,7 @@ export function ManagedProfileGuardians({
|
||||
|
||||
return (
|
||||
<div data-slot="managed-profile-guardians" className="space-y-3">
|
||||
{recentProof.dialog}
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("recordSharing.managed.guardiansTitle", { name: profileName })}
|
||||
</h3>
|
||||
@@ -129,7 +140,9 @@ export function ManagedProfileGuardians({
|
||||
// one is the caller's own — which the route refuses anyway.
|
||||
const removable = !isSelf;
|
||||
const failed =
|
||||
remove.isError && remove.variables?.grantId === guardian.grantId;
|
||||
remove.isError &&
|
||||
!(remove.error instanceof ReproofCancelledError) &&
|
||||
remove.variables?.grantId === guardian.grantId;
|
||||
return (
|
||||
<li
|
||||
key={guardian.grantId}
|
||||
@@ -269,7 +282,7 @@ export function ManagedProfileGuardians({
|
||||
? t("recordSharing.managed.guardianInviting")
|
||||
: t("recordSharing.managed.guardianInvite")}
|
||||
</Button>
|
||||
{invite.isError && (
|
||||
{invite.isError && !(invite.error instanceof ReproofCancelledError) && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-guardian-error"
|
||||
|
||||
@@ -7,6 +7,10 @@ import { showErrorToast } from "@/lib/errors/error-toast";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { stepUpErrorKey } from "@/components/settings/access/grant-action-error";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -65,7 +69,14 @@ export function ManagedProfileHandover({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const status = useManagedProfileHandover(profileId);
|
||||
const create = useCreateManagedProfileHandover();
|
||||
// Minting the link is gated on a fresh second factor. The dialog asks for it
|
||||
// when the server says so and the mint is sent again, so a Guardian who
|
||||
// signed in more than five minutes ago is not left with a sentence telling
|
||||
// them to confirm a factor somewhere this panel does not offer.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const create = useCreateManagedProfileHandover(recentProof.run);
|
||||
const revoke = useRevokeManagedProfileHandover();
|
||||
const [replacing, setReplacing] = useState(false);
|
||||
|
||||
@@ -105,6 +116,7 @@ export function ManagedProfileHandover({
|
||||
})}
|
||||
className="space-y-4 rounded-lg border p-3"
|
||||
>
|
||||
{recentProof.dialog}
|
||||
<p className="text-sm">
|
||||
{t("recordSharing.managed.handover.description", {
|
||||
name: profileName,
|
||||
@@ -359,7 +371,7 @@ function HandoverProposalForm({
|
||||
{replacing && <p>{t("recordSharing.managed.handover.replaceNote")}</p>}
|
||||
</div>
|
||||
|
||||
{create.isError && (
|
||||
{create.isError && !(create.error instanceof ReproofCancelledError) && (
|
||||
<p
|
||||
role="alert"
|
||||
data-slot="managed-profile-handover-error"
|
||||
|
||||
@@ -21,6 +21,11 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
/**
|
||||
@@ -57,6 +62,9 @@ export function AdvancedSection() {
|
||||
function DataResetCard() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
@@ -68,11 +76,18 @@ function DataResetCard() {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/settings/data", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
});
|
||||
// An account with a second factor wipes its record only with that
|
||||
// factor proved within five minutes; the shared dialog collects it when
|
||||
// the server asks and the wipe is sent again.
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/settings/data", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsg(json.error || t("settings.dangerZoneDeleteFailed"));
|
||||
@@ -84,8 +99,13 @@ function DataResetCard() {
|
||||
setMsg(t("settings.dangerZoneSuccess"));
|
||||
setMsgType("success");
|
||||
setConfirmOpen(false);
|
||||
} catch {
|
||||
setMsg(t("settings.dangerZoneDeleteFailed"));
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
t("settings.dangerZoneDeleteFailed"),
|
||||
);
|
||||
if (message === null) return;
|
||||
setMsg(message);
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
@@ -94,6 +114,7 @@ function DataResetCard() {
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-data-reset-card">
|
||||
{recentProof.dialog}
|
||||
{/* v1.4.43 QoL (L5) — dropped the `AlertTriangle` icon and
|
||||
neutralised the title colour so the danger-zone shaping is
|
||||
GitHub-style (red CTA only) rather than red-on-red-on-red.
|
||||
@@ -193,6 +214,9 @@ function DataResetCard() {
|
||||
*/
|
||||
function AccountDeleteCard() {
|
||||
const { t } = useTranslations();
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
@@ -204,11 +228,17 @@ function AccountDeleteCard() {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
});
|
||||
// Same gate as the wipe: a second factor proved within five minutes on
|
||||
// an account that has one, collected by the shared dialog.
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/settings/account", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
@@ -229,15 +259,21 @@ function AccountDeleteCard() {
|
||||
setTimeout(() => {
|
||||
loadDocument("/auth/login");
|
||||
}, 1_500);
|
||||
} catch {
|
||||
setMsg(t("settings.deleteAccountFailed"));
|
||||
setMsgType("error");
|
||||
} catch (err) {
|
||||
setDeleting(false);
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
t("settings.deleteAccountFailed"),
|
||||
);
|
||||
if (message === null) return;
|
||||
setMsg(message);
|
||||
setMsgType("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard data-slot="settings-account-delete-card">
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="delete-account"
|
||||
icon={Trash2}
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
} from "@/components/settings/account-section/account-section-utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
recentProofErrorMessage,
|
||||
throwIfReproofRequired,
|
||||
useRecentProof,
|
||||
} from "@/components/settings/security-section/use-recent-proof";
|
||||
|
||||
/**
|
||||
* Change-password card.
|
||||
@@ -40,6 +45,9 @@ export function PasswordCard() {
|
||||
const [msg, setMsg] = useState<StatusMessage | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
|
||||
async function handleChangePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -55,15 +63,23 @@ export function PasswordCard() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
}),
|
||||
// An account with a second factor changes its password only with that
|
||||
// factor proved within five minutes; the shared dialog collects it when
|
||||
// the server asks and the change is sent again.
|
||||
const body = JSON.stringify({
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
});
|
||||
const res = await recentProof.run(async () =>
|
||||
throwIfReproofRequired(
|
||||
await apiFetchRaw("/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const json = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -79,8 +95,10 @@ export function PasswordCard() {
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch {
|
||||
setMsg({ key: "common.networkError" });
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(err, "");
|
||||
if (message === null) return;
|
||||
setMsg(message ? { text: message } : { key: "common.networkError" });
|
||||
setMsgType("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -89,6 +107,7 @@ export function PasswordCard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{recentProof.dialog}
|
||||
{/* The action is this card's only one, so it sits in the card's action
|
||||
row like every other primary. It stays full-width under `sm` — the
|
||||
German "Passwort ändern" is long enough that a right-hugging button
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
* 3. an ordinary failure still shows its own message rather than being
|
||||
* swallowed by the step-up sentence,
|
||||
* 4. the sentence resolves in more than one locale,
|
||||
* 5. the sentence names the recovery the web actually has. The mint endpoint
|
||||
* is Bearer-only by design, so there is no in-page re-proof dialog for a
|
||||
* cookie session; signing in again is the recovery, and a passkey login
|
||||
* stamps `mfaVerifiedAt`. If that ever changes, this assertion is the
|
||||
* reminder that the copy has to change with it.
|
||||
* 5. the sentence names a recovery that always exists. A refusal that names
|
||||
* its proofs (`meta.methods`) opens the re-proof dialog instead and never
|
||||
* reaches this sentence (`useRecentProof`, pinned by
|
||||
* `step-up-web-caller-guard.test.ts`); what is left is a refusal the
|
||||
* dialog cannot take up, and signing in again is the recovery there — a
|
||||
* passkey login stamps `mfaVerifiedAt`.
|
||||
*
|
||||
* Mutation check: return `err.message` from the removal's `onError` and cases 1,
|
||||
* 2 and 4 go red; narrow `describeStepUp` to the exact `auth.stepup.required`
|
||||
@@ -85,9 +86,9 @@ describe("passkey removal — the step-up refusal a person sees", () => {
|
||||
});
|
||||
|
||||
it("names the recovery the web actually has", () => {
|
||||
// Not "verify to continue": there is no in-page re-proof for a cookie
|
||||
// session, because the elevation mint is Bearer-only by design. Signing in
|
||||
// again is the recovery, and a passkey login stamps the session.
|
||||
// The dialog handles every refusal that names its proofs; this sentence
|
||||
// is for the rest, where signing in again is the recovery and a passkey
|
||||
// login stamps the session.
|
||||
expect(STEP_UP_SENTENCE.toLowerCase()).toContain("sign in again");
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ const REAUTH_METHODS: readonly ReauthMethod[] = [
|
||||
* 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).
|
||||
*/
|
||||
export function nothingToReprove(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
Array.isArray(err.meta?.methods) &&
|
||||
err.meta.methods.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
export function offeredReauthMethods(err: unknown): ReauthMethod[] | null {
|
||||
if (!(err instanceof ApiError)) return null;
|
||||
const raw = err.meta?.methods;
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { describeStepUp } from "./security-keys-card";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
interface PasskeyInfo {
|
||||
id: string;
|
||||
@@ -164,22 +165,30 @@ export function PasskeyListSection({
|
||||
onError: (err: Error) => setMsg(err.message),
|
||||
});
|
||||
|
||||
// Removal is step-up gated: a second factor, or on a passkey-only account a
|
||||
// passkey, proved within five minutes. The server names what clears it and
|
||||
// the shared dialog collects that, then the removal runs again.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/passkeys/${id}`);
|
||||
await recentProof.run(() => apiDelete(`/api/auth/passkeys/${id}`));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMsg(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
|
||||
},
|
||||
// Removal is step-up gated. The server's own prose says "second-factor",
|
||||
// which is wrong for the passkey-only account this card mostly serves — the
|
||||
// credential to re-prove is the passkey — so the refusal gets its own
|
||||
// sentence naming the recovery that exists on the web: sign in again.
|
||||
onError: (err: Error) =>
|
||||
// A refusal the dialog could not take up keeps its own sentence. The
|
||||
// server's prose says "second-factor", which is wrong for the passkey-only
|
||||
// account this card mostly serves, so it names the recovery that always
|
||||
// exists on the web: sign in again.
|
||||
onError: (err: Error) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setMsg(
|
||||
describeStepUp(err, err.message, t("settings.passkeyStepUpRequired")),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const DEVICE_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -189,6 +198,7 @@ export function PasskeyListSection({
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="passkeys"
|
||||
icon={Fingerprint}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
|
||||
/**
|
||||
* The re-proof flow without React: catch the refusal, ask for a proof, post
|
||||
* it, run the action once more. `useRecentProof` binds it to component state
|
||||
* and the dialog; the unit suite drives it directly, because this repository's
|
||||
* component tests run without a browser DOM and the flow is what matters.
|
||||
*/
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
];
|
||||
|
||||
/** The proofs a refusal says this account can give. */
|
||||
function methodsFrom(err: ApiError): ReauthMethod[] {
|
||||
const raw = err.meta?.methods;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((m): m is ReauthMethod =>
|
||||
KNOWN_METHODS.includes(m as ReauthMethod),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal the re-proof dialog can clear.
|
||||
*
|
||||
* Two gates answer this way. The recent-proof gate (`auth.reproof.required`)
|
||||
* in front of exports, share links, tokens, invitations and the admin data
|
||||
* actions; and the fresh-factor gate (`auth.stepup.required`, from
|
||||
* `requireFreshMfa`) in front of the managed-profile acts, the second-factor
|
||||
* changes, passkey removal, the password change, account deletion, the data
|
||||
* wipe and the key rotation. The second one names the proofs it would take in
|
||||
* `meta.methods` on a browser session, and a `POST /api/auth/reproof` with one
|
||||
* of them stamps the session the way signing in with the factor does, so the
|
||||
* same dialog clears both. A fresh-factor refusal without `methods` (an
|
||||
* account with no factor at all says `auth.stepup.mfa_not_enrolled` instead)
|
||||
* is not something a dialog can answer, and stays with the caller's own
|
||||
* message.
|
||||
*/
|
||||
export function isProofRefusal(err: unknown): boolean {
|
||||
if (isReproofRequired(err)) return true;
|
||||
return (
|
||||
err instanceof ApiError &&
|
||||
err.status === 401 &&
|
||||
err.meta?.errorCode === "auth.stepup.required" &&
|
||||
methodsFrom(err).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
|
||||
export class ReproofCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Re-proof cancelled");
|
||||
this.name = "ReproofCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecentProofFlowView {
|
||||
/** Open the dialog offering these proofs, or close it with `null`. */
|
||||
setMethods: (methods: ReauthMethod[] | null) => void;
|
||||
setPending: (pending: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export interface RecentProofFlow {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
/** The dialog collected a proof: post it and release the waiting action. */
|
||||
prove: (proof: ExistingFactorProof) => Promise<void>;
|
||||
/** The dialog was closed without a proof. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export function createRecentProofFlow(deps: {
|
||||
view: RecentProofFlowView;
|
||||
/** Posts the proof to `/api/auth/reproof`. */
|
||||
submit: (proof: ExistingFactorProof) => Promise<unknown>;
|
||||
/** Read at the moment they are needed, so a locale switch is honoured. */
|
||||
messages: () => { signInAgain: string; failed: string };
|
||||
}): RecentProofFlow {
|
||||
let waiter: { resolve: () => void; reject: () => void } | null = null;
|
||||
|
||||
const run = async <T>(action: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await action();
|
||||
} catch (err) {
|
||||
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.
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(deps.messages().signInAgain, 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
waiter = {
|
||||
resolve,
|
||||
reject: () => reject(new ReproofCancelledError()),
|
||||
};
|
||||
deps.view.setError(null);
|
||||
deps.view.setMethods(offered);
|
||||
});
|
||||
// Once. A second refusal is the answer, not a reason to ask again.
|
||||
return action();
|
||||
}
|
||||
};
|
||||
|
||||
const prove = async (proof: ExistingFactorProof): Promise<void> => {
|
||||
deps.view.setPending(true);
|
||||
deps.view.setError(null);
|
||||
try {
|
||||
await deps.submit(proof);
|
||||
deps.view.setMethods(null);
|
||||
const w = waiter;
|
||||
waiter = null;
|
||||
w?.resolve();
|
||||
} catch (err) {
|
||||
deps.view.setError(
|
||||
err instanceof ApiError && err.status === 429
|
||||
? err.message
|
||||
: deps.messages().failed,
|
||||
);
|
||||
} finally {
|
||||
deps.view.setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
deps.view.setMethods(null);
|
||||
deps.view.setError(null);
|
||||
const w = waiter;
|
||||
waiter = null;
|
||||
w?.reject();
|
||||
};
|
||||
|
||||
return { run, prove, cancel };
|
||||
}
|
||||
@@ -37,10 +37,12 @@ import { formatDate } from "@/lib/format";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
nothingToReprove,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import { ReproofCancelledError, useRecentProof } from "./use-recent-proof";
|
||||
|
||||
export interface WebauthnKeyInfo {
|
||||
id: string;
|
||||
@@ -133,6 +135,11 @@ export function SecurityKeysCard({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err) && nothingToReprove(err)) {
|
||||
setReauthOpen(false);
|
||||
setError(t("settings.reproof.signInAgain"));
|
||||
return;
|
||||
}
|
||||
if (isReproofRequired(err)) {
|
||||
setOfferedMethods(offeredReauthMethods(err));
|
||||
setError(null);
|
||||
@@ -172,26 +179,35 @@ export function SecurityKeysCard({
|
||||
),
|
||||
});
|
||||
|
||||
// Removing a key needs the second factor proved within five minutes; the
|
||||
// shared dialog collects it when the server asks, then the removal runs
|
||||
// again.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/auth/me/mfa/webauthn/${id}`);
|
||||
await recentProof.run(() => apiDelete(`/api/auth/me/mfa/webauthn/${id}`));
|
||||
},
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeStepUp(
|
||||
err,
|
||||
t("settings.security.keys.removeFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="security-keys"
|
||||
icon={Usb}
|
||||
|
||||
@@ -31,11 +31,17 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { RecoveryCodesPanel } from "./recovery-codes-panel";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
recentProofErrorMessage,
|
||||
useRecentProof,
|
||||
} from "./use-recent-proof";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
nothingToReprove,
|
||||
offeredReauthMethods,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
@@ -117,6 +123,33 @@ export function TotpCard({
|
||||
const [regenerateOpen, setRegenerateOpen] = useState(false);
|
||||
const [disableOpen, setDisableOpen] = useState(false);
|
||||
|
||||
// Disabling the factor and rotating the recovery codes both need the second
|
||||
// factor proved within five minutes. When it is older, the server names the
|
||||
// proofs that clear it and the shared dialog collects one, then the action
|
||||
// runs again.
|
||||
const recentProof = useRecentProof({
|
||||
description: t("settings.reproof.descriptionChange"),
|
||||
});
|
||||
|
||||
// Disabling is asked BEFORE the confirmation opens, not after its code was
|
||||
// typed: the route checks the session's proof before it reads the code, and
|
||||
// a code typed first and then spent on the re-proof would be refused as a
|
||||
// replay when the disable is sent again.
|
||||
async function openDisable() {
|
||||
setError(null);
|
||||
try {
|
||||
await recentProof.run(() => apiGet("/api/auth/reproof"));
|
||||
} catch (err) {
|
||||
const message = recentProofErrorMessage(
|
||||
err,
|
||||
t("settings.security.totp.disableFailed"),
|
||||
);
|
||||
if (message) setError(message);
|
||||
return;
|
||||
}
|
||||
setDisableOpen(true);
|
||||
}
|
||||
|
||||
function resetWizard() {
|
||||
setSetup(null);
|
||||
setQrDataUrl(null);
|
||||
@@ -154,6 +187,11 @@ export function TotpCard({
|
||||
}
|
||||
},
|
||||
onError: (err, proof) => {
|
||||
if (isReproofRequired(err) && nothingToReprove(err)) {
|
||||
setReauthOpen(false);
|
||||
setError(t("settings.reproof.signInAgain"));
|
||||
return;
|
||||
}
|
||||
if (isReproofRequired(err)) {
|
||||
const offered = offeredReauthMethods(err);
|
||||
if (offered) setReauthMethods(offered);
|
||||
@@ -194,31 +232,37 @@ export function TotpCard({
|
||||
|
||||
const disable = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/auth/me/mfa/disable", {
|
||||
code: disableCode,
|
||||
method: disableMethod,
|
||||
});
|
||||
await recentProof.run(() =>
|
||||
apiPost("/api/auth/me/mfa/disable", {
|
||||
code: disableCode,
|
||||
method: disableMethod,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setDisableCode("");
|
||||
setFreshCodes(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.totp.disableFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSettled: () => setDisableOpen(false),
|
||||
});
|
||||
|
||||
const regenerate = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiPost<{ recoveryCodes: string[] }>(
|
||||
"/api/auth/me/mfa/recovery-codes/regenerate",
|
||||
return recentProof.run(() =>
|
||||
apiPost<{ recoveryCodes: string[] }>(
|
||||
"/api/auth/me/mfa/recovery-codes/regenerate",
|
||||
),
|
||||
);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
@@ -226,14 +270,16 @@ export function TotpCard({
|
||||
setFreshCodes(data.recoveryCodes);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.mfaStatus() });
|
||||
},
|
||||
onError: (err) =>
|
||||
onError: (err) => {
|
||||
if (err instanceof ReproofCancelledError) return;
|
||||
setError(
|
||||
describeError(
|
||||
err,
|
||||
t("settings.security.recovery.regenFailed"),
|
||||
t("settings.security.stepUpRequired"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onSettled: () => setRegenerateOpen(false),
|
||||
});
|
||||
|
||||
@@ -250,6 +296,7 @@ export function TotpCard({
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="totp-card">
|
||||
{recentProof.dialog}
|
||||
<SettingsCardHeader
|
||||
anchor="two-factor"
|
||||
icon={Smartphone}
|
||||
@@ -471,7 +518,13 @@ export function TotpCard({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={disableOpen} onOpenChange={setDisableOpen}>
|
||||
<AlertDialog
|
||||
open={disableOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) void openDisable();
|
||||
else setDisableOpen(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
ExistingFactorReauthDialog,
|
||||
isReproofRequired,
|
||||
type ExistingFactorProof,
|
||||
type ReauthMethod,
|
||||
} from "./existing-factor-reauth-dialog";
|
||||
import {
|
||||
ReproofCancelledError,
|
||||
createRecentProofFlow,
|
||||
isProofRefusal,
|
||||
} from "./recent-proof-flow";
|
||||
|
||||
const KNOWN_METHODS: readonly ReauthMethod[] = [
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
];
|
||||
|
||||
/** The proofs a `auth.reproof.required` refusal says this account can give. */
|
||||
function methodsFrom(err: ApiError): ReauthMethod[] {
|
||||
const raw = err.meta?.methods;
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((m): m is ReauthMethod =>
|
||||
KNOWN_METHODS.includes(m as ReauthMethod),
|
||||
);
|
||||
}
|
||||
|
||||
/** The person closed the re-proof dialog. Nothing ran; show nothing. */
|
||||
export class ReproofCancelledError extends Error {
|
||||
constructor() {
|
||||
super("Re-proof cancelled");
|
||||
this.name = "ReproofCancelledError";
|
||||
}
|
||||
}
|
||||
export { ReproofCancelledError };
|
||||
|
||||
/**
|
||||
* The sentence to show for an error `run` rejected with, or null when there is
|
||||
@@ -76,7 +57,7 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
401,
|
||||
meta,
|
||||
);
|
||||
if (isReproofRequired(err)) throw err;
|
||||
if (isProofRefusal(err)) throw err;
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -85,8 +66,10 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
*
|
||||
* Exports of the whole record, share links, token minting and the admin data
|
||||
* actions answer 401 `auth.reproof.required` with `meta.methods` when the
|
||||
* session has not signed in or re-proved within five minutes. `run` catches
|
||||
* exactly that refusal, opens the re-proof dialog with the listed methods,
|
||||
* session has not signed in or re-proved within five minutes; the
|
||||
* fresh-factor routes answer 401 `auth.stepup.required` with `meta.methods`
|
||||
* when the second factor is older than that (`isProofRefusal`). `run` catches
|
||||
* exactly those refusals, opens the re-proof dialog with the listed methods,
|
||||
* posts the proof to `/api/auth/reproof` and runs the action once more. Every
|
||||
* other error rejects as usual; a cancelled dialog rejects with
|
||||
* `ReproofCancelledError`. `recentProofErrorMessage` maps both for display.
|
||||
@@ -95,7 +78,16 @@ export async function throwIfReproofRequired(res: Response): Promise<Response> {
|
||||
* sign-on only) gets no dialog: the refusal is rethrown with a message asking
|
||||
* to sign in again.
|
||||
*/
|
||||
export function useRecentProof(): {
|
||||
export function useRecentProof(
|
||||
options: {
|
||||
/**
|
||||
* The dialog's one line on why it asks. Defaults to the sentence for
|
||||
* sharing and exporting; the fresh-factor acts (changing who looks after a
|
||||
* record, a second factor, the account itself) pass their own.
|
||||
*/
|
||||
description?: string;
|
||||
} = {},
|
||||
): {
|
||||
run: <T>(action: () => Promise<T>) => Promise<T>;
|
||||
dialog: ReactNode;
|
||||
} {
|
||||
@@ -103,57 +95,18 @@ export function useRecentProof(): {
|
||||
const [methods, setMethods] = useState<ReauthMethod[] | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const waiter = useRef<{
|
||||
resolve: () => void;
|
||||
reject: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const run = useCallback(
|
||||
async <T,>(action: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await action();
|
||||
} catch (err) {
|
||||
if (!isReproofRequired(err)) throw err;
|
||||
const offered = methodsFrom(err as ApiError);
|
||||
if (offered.length === 0) {
|
||||
throw new ApiError(t("settings.reproof.signInAgain"), 401, {
|
||||
errorCode: "auth.reproof.sign_in_again",
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
waiter.current = {
|
||||
resolve,
|
||||
reject: () => reject(new ReproofCancelledError()),
|
||||
};
|
||||
setError(null);
|
||||
setMethods(offered);
|
||||
});
|
||||
return action();
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const onProof = useCallback(
|
||||
async (proof: ExistingFactorProof) => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiPost("/api/auth/reproof", proof);
|
||||
setMethods(null);
|
||||
const w = waiter.current;
|
||||
waiter.current = null;
|
||||
w?.resolve();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError && err.status === 429
|
||||
? err.message
|
||||
: t("settings.reproof.failed"),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
},
|
||||
// One flow per locale, as the callbacks it replaces were (`[t]`): it holds
|
||||
// the waiting action, so it is not rebuilt on every render.
|
||||
const { run, prove, cancel } = useMemo(
|
||||
() =>
|
||||
createRecentProofFlow({
|
||||
view: { setMethods, setPending, setError },
|
||||
submit: (proof) => apiPost("/api/auth/reproof", proof),
|
||||
messages: () => ({
|
||||
signInAgain: t("settings.reproof.signInAgain"),
|
||||
failed: t("settings.reproof.failed"),
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -162,18 +115,13 @@ export function useRecentProof(): {
|
||||
key={methods?.join(",") ?? "closed"}
|
||||
open={methods !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setMethods(null);
|
||||
setError(null);
|
||||
const w = waiter.current;
|
||||
waiter.current = null;
|
||||
w?.reject();
|
||||
if (!open) cancel();
|
||||
}}
|
||||
methods={methods ?? ["password"]}
|
||||
pending={pending}
|
||||
error={error}
|
||||
onProof={(proof) => void onProof(proof)}
|
||||
description={t("settings.reproof.description")}
|
||||
onProof={(proof) => void prove(proof)}
|
||||
description={options.description ?? t("settings.reproof.description")}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// The stale refusal names the proofs the account holds, passkeys among them.
|
||||
passkey: { count: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ vi.mock("@/lib/db", () => ({
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
webauthnMfaCredential: { count: vi.fn() },
|
||||
// The stale refusal names the proofs the account holds, passkeys among them.
|
||||
passkey: { count: vi.fn() },
|
||||
stepUpElevation: { findUnique: vi.fn() },
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
|
||||
@@ -82,6 +82,46 @@ describe("requireFreshMfa", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The stale refusal names what clears it, so the browser can open the
|
||||
* re-proof dialog instead of printing a sentence (#959): the possession
|
||||
* proofs the account holds, never a password, which stamps `reproofAt` only
|
||||
* and would leave this gate shut.
|
||||
*/
|
||||
it("names the possession proofs on a stale refusal", async () => {
|
||||
mockSession({ ...MFA_USER, passwordHash: "argon2-hash" });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 10 * 60_000),
|
||||
} as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["totp", "webauthn", "passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("offers a passkey-only account its passkey, not its password", async () => {
|
||||
mockSession({
|
||||
...MFA_USER,
|
||||
totpConfirmedAt: null,
|
||||
passwordHash: "argon2-hash",
|
||||
});
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
requireFreshMfa(5 * 60, "any-possession"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an absent verification stamp", async () => {
|
||||
mockSession(MFA_USER);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
@@ -93,9 +133,10 @@ describe("requireFreshMfa", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a user with neither TOTP nor a security key", async () => {
|
||||
it("rejects a user with neither TOTP nor a security key nor a passkey", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(0 as never);
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
@@ -123,21 +164,50 @@ describe("requireFreshMfa", () => {
|
||||
* parameter, and it changes nothing else: the freshness read below is the same
|
||||
* read either way.
|
||||
*
|
||||
* A primary passkey is deliberately outside the default set and inside
|
||||
* `"any-possession"`. It DOES stamp `mfaVerifiedAt` on login, so it can clear
|
||||
* the gate — but counting it by default would pull every passkey-holding
|
||||
* Since v1.43.3 a primary passkey counts by default: `requireFreshMfa`
|
||||
* defaults to `"any-possession"`, so a passkey-only account clears the
|
||||
* managed-profile acts with a passkey re-proof. It DOES stamp `mfaVerifiedAt`
|
||||
* on login and on re-proof, so it can clear the gate. What stays narrow is
|
||||
* `hasSecondFactorEnrolled`: widening THAT would pull every passkey-holding
|
||||
* account into `requireFreshMfaIfEnrolled`'s gate on account deletion, the
|
||||
* data reset, the password change, the encrypted export and key rotation,
|
||||
* none of which have ever applied to them. The sibling suite
|
||||
* `require-fresh-mfa-if-enrolled.test.ts` is what catches that if the two
|
||||
* predicates are ever collapsed into one.
|
||||
* data reset, the password change, the encrypted export and key rotation.
|
||||
* The sibling suite `require-fresh-mfa-if-enrolled.test.ts` is what catches
|
||||
* that if the two predicates are ever collapsed into one.
|
||||
*/
|
||||
it("admits a passkey-only account by default, with a fresh stamp", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: new Date(Date.now() - 60_000),
|
||||
} as never);
|
||||
|
||||
const ctx = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
expect(ctx.user.id).toBe("user-1");
|
||||
});
|
||||
|
||||
it("asks a passkey-only account for its passkey by default when the stamp is stale", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
vi.mocked(prisma.session.findUnique).mockResolvedValue({
|
||||
mfaVerifiedAt: null,
|
||||
} as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
extraMeta: { methods: ["passkey"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a passkey-only account when only a second factor counts", async () => {
|
||||
mockSession({ ...MFA_USER, totpConfirmedAt: null });
|
||||
vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.passkey.count).mockResolvedValue(1 as never);
|
||||
|
||||
await expect(requireFreshMfa(5 * 60)).rejects.toMatchObject({
|
||||
await expect(
|
||||
requireFreshMfa(5 * 60, "second-factor"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "auth.stepup.mfa_not_enrolled",
|
||||
});
|
||||
});
|
||||
|
||||
+55
-8
@@ -1253,11 +1253,13 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
|
||||
* the route is protecting.
|
||||
*
|
||||
* `second-factor` — a confirmed TOTP secret or a registered second-factor
|
||||
* security key. The default, and the right question for the routes that MANAGE
|
||||
* a second factor: there is nothing there to protect if nothing is enrolled.
|
||||
* security key. The default of `requireMfaManagementAuth`, and the right
|
||||
* question for the routes that MANAGE a second factor: there is nothing there
|
||||
* to protect if nothing is enrolled.
|
||||
*
|
||||
* `any-possession` — the above, OR a registered PRIMARY passkey. The right
|
||||
* question for a route protecting the passkey itself, because a passkey is a
|
||||
* `any-possession` — the above, OR a registered PRIMARY passkey. The default
|
||||
* of `requireFreshMfa` since v1.43.3, and the right question for a route
|
||||
* protecting the passkey itself, because a passkey is a
|
||||
* possession factor in its own right and this codebase already treats it as
|
||||
* one in three places: `/api/auth/passkey/login-verify` stamps `mfaVerifiedAt`
|
||||
* on a passkey login (v1.23, M-review M1), `passkey` is in
|
||||
@@ -1270,6 +1272,37 @@ export type FreshMfaContext = AuthContext & { mfaVerifiedAt: Date };
|
||||
*/
|
||||
export type FreshFactorProofSource = "second-factor" | "any-possession";
|
||||
|
||||
/*
|
||||
* A PASSKEY COUNTS AS A SECOND FACTOR FOR THE UNCONDITIONAL STEP-UP GATE.
|
||||
*
|
||||
* Maintainer decision, v1.43.3. `requireFreshMfa` defaults to
|
||||
* `any-possession`, so an account whose only strong credential is a passkey
|
||||
* is no longer refused `mfa_not_enrolled` on the routes that call it directly
|
||||
* (creating, editing, deleting and handing over a managed profile, inviting
|
||||
* and removing a guardian, the admin key rotation). It clears the gate the
|
||||
* way every other account does: a passkey sign-in or a passkey re-proof at
|
||||
* `POST /api/auth/reproof` stamps `Session.mfaVerifiedAt`, and the gate reads
|
||||
* that stamp inside the same five minutes.
|
||||
*
|
||||
* The limits, each deliberate:
|
||||
*
|
||||
* - `hasSecondFactorEnrolled` keeps its narrow meaning. It decides whether
|
||||
* the CONDITIONAL gates (`requireFreshMfaIfEnrolled`,
|
||||
* `requireFreshMfaOrElevationIfEnrolled`, `requireRecentProof`'s branches,
|
||||
* the enrollment proofs) apply at all. Widening it would newly gate
|
||||
* account deletion, the data reset, the password change and the export
|
||||
* for every passkey holder, take the password re-proof away from them, and
|
||||
* change what the native app's Bearer requests need. None of that is part
|
||||
* of the decision.
|
||||
* - Password login is untouched. It asks for a second factor from
|
||||
* `totpConfirmedAt` and the security-key count only; a passkey-only
|
||||
* account signing in with its password is not challenged, as before.
|
||||
* - The MFA-management routes (`requireMfaManagementAuth`) still default to
|
||||
* `second-factor`: disabling TOTP or rotating recovery codes on an account
|
||||
* with no second factor has nothing to protect.
|
||||
* - Cookie only, as ever. The Bearer path is not reached from here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v1.23 — step-up gate. Passes only for a COOKIE session whose
|
||||
* `Session.mfaVerifiedAt` is within `maxAgeSeconds` AND whose user holds a
|
||||
@@ -1285,12 +1318,13 @@ export type FreshFactorProofSource = "second-factor" | "any-possession";
|
||||
* waves gate account deletion, key rotation, and passphrase export on it.
|
||||
*
|
||||
* @param proofSource which credentials count as "can prove a factor". Defaults
|
||||
* to the second-factor set every existing caller means; see
|
||||
* {@link FreshFactorProofSource}.
|
||||
* to `any-possession`, so a primary passkey counts (see the decision note
|
||||
* below {@link FreshFactorProofSource}); `requireMfaManagementAuth` passes
|
||||
* `second-factor` explicitly.
|
||||
*/
|
||||
export async function requireFreshMfa(
|
||||
maxAgeSeconds: number,
|
||||
proofSource: FreshFactorProofSource = "second-factor",
|
||||
proofSource: FreshFactorProofSource = "any-possession",
|
||||
): Promise<FreshMfaContext> {
|
||||
const sessionData = await getSession();
|
||||
if (!sessionData)
|
||||
@@ -1321,7 +1355,20 @@ export async function requireFreshMfa(
|
||||
});
|
||||
const verifiedAt = row?.mfaVerifiedAt ?? null;
|
||||
if (!verifiedAt || verifiedAt.getTime() < Date.now() - maxAgeSeconds * 1000) {
|
||||
throw new StepUpRequiredError();
|
||||
// Name the proofs that clear this gate, the way the recent-proof gate
|
||||
// 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.
|
||||
const methods = (await recentProofMethods(sessionData.user)).filter(
|
||||
(method) => method !== "password",
|
||||
);
|
||||
annotate({ meta: { step_up_methods: methods.join(",") } });
|
||||
throw new StepUpRequiredError(
|
||||
"auth.stepup.required",
|
||||
"Recent second-factor verification required",
|
||||
{ methods },
|
||||
);
|
||||
}
|
||||
|
||||
return { ...sessionData, authMethod: "cookie", mfaVerifiedAt: verifiedAt };
|
||||
|
||||
@@ -21,6 +21,13 @@ import { prisma } from "@/lib/db";
|
||||
* a behaviour change to five destructive actions, made in passing, for the
|
||||
* benefit of a sixth. `canProveFreshFactor` in `api-handler.ts` is where the wider question
|
||||
* lives.
|
||||
*
|
||||
* Since v1.43.3 a passkey DOES count as a second factor where the gate is
|
||||
* unconditional: `requireFreshMfa` defaults to `any-possession`, so a
|
||||
* passkey-only account clears the managed-profile acts with a passkey
|
||||
* re-proof instead of being told to enrol something. That decision was made
|
||||
* there and not here, on purpose; the note beside `FreshFactorProofSource`
|
||||
* lists what it does and does not change.
|
||||
*/
|
||||
export async function hasSecondFactorEnrolled(user: {
|
||||
id: string;
|
||||
|
||||
@@ -75,7 +75,7 @@ const accountGrantInvite = inviteGrantSchema.meta({
|
||||
const createManagedProfileRequest = createManagedProfileSchema.meta({
|
||||
id: "CreateManagedProfileRequest",
|
||||
description:
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
|
||||
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `gender` is optional and takes the same three values `PATCH /api/auth/me` takes for an ordinary account (`MALE`, `FEMALE`, `OTHER`) or null; absent and null both mean not recorded, and the value is what the cycle module derives its default from, so a record created for a child who does not need it starts with it off. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. A primary passkey counts as that factor here: a passkey sign-in or a passkey re-proof at POST /api/auth/reproof stamps the session. Rate-limited to ten an hour per caller.",
|
||||
});
|
||||
|
||||
const updateManagedProfileRequest = updateManagedProfileSchema.meta({
|
||||
@@ -1089,7 +1089,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
"401": {
|
||||
description:
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
"No fresh second-factor proof (`meta.errorCode: auth.stepup.required`, with `meta.methods` naming the proofs that clear it at POST /api/auth/reproof), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account with neither a second factor nor a passkey cannot create a managed profile until it enrols one. A passkey counts: an account whose only strong credential is a passkey is offered `passkey` in `meta.methods`. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
|
||||
@@ -1099,7 +1099,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
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.\n\n" +
|
||||
"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' +
|
||||
"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: {
|
||||
|
||||
@@ -116,7 +116,9 @@ describe("what a managed-profile change refreshes", () => {
|
||||
|
||||
describe("the creation hook, driven for real", () => {
|
||||
it("posts the four fields the strict schema accepts, and no others", async () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
const options = useCreateManagedProfile((action) =>
|
||||
action(),
|
||||
) as unknown as {
|
||||
mutationFn: (input: unknown) => Promise<unknown>;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
@@ -147,7 +149,9 @@ describe("the creation hook, driven for real", () => {
|
||||
});
|
||||
|
||||
it("puts the new profile in the switcher by refreshing the payload", () => {
|
||||
const options = useCreateManagedProfile() as unknown as {
|
||||
const options = useCreateManagedProfile((action) =>
|
||||
action(),
|
||||
) as unknown as {
|
||||
onSuccess: () => void;
|
||||
};
|
||||
options.onSuccess();
|
||||
|
||||
@@ -108,21 +108,34 @@ function useInvalidateManagedProfiles() {
|
||||
return () => invalidateManagedProfileReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a request that the server may hold back for a fresh proof — in a
|
||||
* component, `useRecentProof().run`.
|
||||
*
|
||||
* Every write in this family resolves `requireFreshMfa`, whose 401
|
||||
* `auth.stepup.required` names the proofs that clear it in `meta.methods`.
|
||||
* The guard opens the re-proof dialog on that answer, posts the proof and
|
||||
* sends the request once more, so the mutation settles with the real outcome
|
||||
* instead of a sentence telling somebody to confirm a factor with nowhere to
|
||||
* do it. The argument is required rather than defaulted: a caller that forgot
|
||||
* it would compile, render the refusal as text, and be the defect again.
|
||||
*/
|
||||
export type ProofGuard = <T>(action: () => Promise<T>) => Promise<T>;
|
||||
|
||||
/**
|
||||
* Create a record somebody looks after.
|
||||
*
|
||||
* `requireFreshMfa` guards the route, so a 401 carrying
|
||||
* `meta.errorCode: auth.stepup.required` is the EXPECTED first answer for
|
||||
* anybody who has a second factor and has not proved it recently. The form
|
||||
* routes that into the app's re-verification path rather than into a generic
|
||||
* failure — see `managed-profile-create-form.tsx`.
|
||||
* anybody who has a second factor and has not proved it recently. `guard`
|
||||
* turns it into the re-proof dialog and a second attempt.
|
||||
*/
|
||||
export function useCreateManagedProfile() {
|
||||
export function useCreateManagedProfile(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileCreate(),
|
||||
mutationFn: (input: CreateManagedProfileInput) =>
|
||||
apiPost<ManagedProfileView>("/api/managed-profiles", input),
|
||||
guard(() => apiPost<ManagedProfileView>("/api/managed-profiles", input)),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -151,21 +164,23 @@ export function useManagedProfile(profileId: string, enabled = true) {
|
||||
*
|
||||
* Guarded by `requireFreshMfa` like every other act in this family, so a 401
|
||||
* carrying `meta.errorCode: auth.stepup.required` is the expected first answer
|
||||
* rather than a failure — the form routes it into the re-verification path.
|
||||
* rather than a failure — `guard` routes it into the re-proof dialog.
|
||||
*
|
||||
* The whole family is invalidated on success because the display name is what
|
||||
* the switcher, the record banner and this card all identify the record by, and
|
||||
* the record's own identity read is dropped alongside so a second edit opens
|
||||
* from the server's version rather than from the one that was just replaced.
|
||||
*/
|
||||
export function useUpdateManagedProfile() {
|
||||
export function useUpdateManagedProfile(guard: ProofGuard) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileUpdate(),
|
||||
mutationFn: (input: UpdateManagedProfileInput) =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
guard(() =>
|
||||
apiPatch<ManagedProfileView>(
|
||||
`/api/managed-profiles/${input.profileId}`,
|
||||
managedProfileEditBody(input),
|
||||
),
|
||||
),
|
||||
onSuccess: (_result, input) => {
|
||||
invalidateManagedProfileReads(queryClient);
|
||||
@@ -250,12 +265,14 @@ export function useManagedProfileGuardians(profileId: string, enabled = true) {
|
||||
* floor. A Guardian may not strand a record with nobody looking after it, but
|
||||
* they may end the record.
|
||||
*/
|
||||
export function useDeleteManagedProfile() {
|
||||
export function useDeleteManagedProfile(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileDelete(),
|
||||
mutationFn: (profileId: string) =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
guard(() =>
|
||||
apiDelete<{ deleted: true }>(`/api/managed-profiles/${profileId}`),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -285,14 +302,16 @@ export interface InviteManagedProfileGuardianInput {
|
||||
* here accepts on their behalf, and until they accept they do not count toward
|
||||
* the last-Guardian floor.
|
||||
*/
|
||||
export function useInviteManagedProfileGuardian() {
|
||||
export function useInviteManagedProfileGuardian(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianInvite(),
|
||||
mutationFn: (input: InviteManagedProfileGuardianInput) =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
guard(() =>
|
||||
apiPost(
|
||||
`/api/managed-profiles/${input.profileId}/guardians`,
|
||||
guardianInviteBody(input),
|
||||
),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
@@ -328,7 +347,7 @@ export function guardianInviteBody(input: InviteManagedProfileGuardianInput): {
|
||||
* `variables` carries both ids so a refusal can be rendered on the guardian row
|
||||
* it belongs to rather than on the panel.
|
||||
*/
|
||||
export function useRemoveManagedProfileGuardian() {
|
||||
export function useRemoveManagedProfileGuardian(guard: ProofGuard) {
|
||||
const invalidate = useInvalidateManagedProfiles();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileGuardianRemove(),
|
||||
@@ -338,7 +357,10 @@ export function useRemoveManagedProfileGuardian() {
|
||||
}: {
|
||||
profileId: string;
|
||||
grantId: string;
|
||||
}) => apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
}) =>
|
||||
guard(() =>
|
||||
apiDelete(`/api/managed-profiles/${profileId}/guardians/${grantId}`),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -409,19 +431,22 @@ export function useManagedProfileHandover(profileId: string, enabled = true) {
|
||||
|
||||
/**
|
||||
* Mint the handover link. Step-up gated like every act in this family, so a
|
||||
* 401 carrying `auth.stepup.required` is the expected first answer.
|
||||
* 401 carrying `auth.stepup.required` is the expected first answer, and
|
||||
* `guard` turns it into the re-proof dialog and a second attempt.
|
||||
*
|
||||
* The link lives in the mutation's `data` and nowhere else: it is shown once
|
||||
* and never cached under a query key.
|
||||
*/
|
||||
export function useCreateManagedProfileHandover() {
|
||||
export function useCreateManagedProfileHandover(guard: ProofGuard) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationKey: queryKeys.managedProfileHandoverCreate(),
|
||||
mutationFn: async (input: CreateManagedProfileHandoverInput) => {
|
||||
const link = await apiPost<ManagedProfileHandoverLink>(
|
||||
`/api/managed-profiles/${input.profileId}/handover`,
|
||||
handoverCreateBody(input),
|
||||
const link = await guard(() =>
|
||||
apiPost<ManagedProfileHandoverLink>(
|
||||
`/api/managed-profiles/${input.profileId}/handover`,
|
||||
handoverCreateBody(input),
|
||||
),
|
||||
);
|
||||
// Lazy chunk, as the admin invite and the share link do: the QR encoder
|
||||
// is needed here, at mint time, and nowhere else.
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* #959 — a Guardian who signs in through an identity provider, with a second
|
||||
* factor enrolled in the app, hands a managed profile over after the
|
||||
* five-minute window has passed. The report: "I get the error message that I
|
||||
* need to confirm my second factor, but there is nowhere to do so."
|
||||
*
|
||||
* Driven end to end against real Postgres and a mock identity provider that
|
||||
* speaks the wire (discovery, JWKS, a PKCE-checking token endpoint, ES256 ID
|
||||
* tokens), the same harness as `managed-profile-handover-sso.test.ts`:
|
||||
*
|
||||
* 1. the real OIDC login and callback, which puts an account with a second
|
||||
* factor through the app's own MFA challenge;
|
||||
* 2. the real `/api/auth/mfa/verify`, which stamps the session;
|
||||
* 3. ten minutes later the handover refuses with `auth.stepup.required` AND
|
||||
* names the proofs that clear it, which is what lets the browser open the
|
||||
* re-proof dialog instead of printing a sentence;
|
||||
* 4. `/api/auth/reproof` with the second factor, exactly what the dialog
|
||||
* posts, and the handover goes through.
|
||||
*
|
||||
* And the other half of the question: a Guardian whose only factor is the one
|
||||
* the identity provider asked for. The app cannot see that factor, so the gate
|
||||
* asks for one of its own (`mfa_not_enrolled`), and the way through is
|
||||
* reachable: enrolling right after a sign-in needs no further proof, and the
|
||||
* confirmation stamps the session, so the handover follows.
|
||||
*/
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { exportJWK, generateKeyPair, SignJWT, type CryptoKey } from "jose";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { _resetOidcCacheForTests } from "@/lib/auth/oidc";
|
||||
import { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-handover-stepup-sso-32-bytes-minimum-123456";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const APP = "http://localhost:3000";
|
||||
const CLIENT_ID = "healthlog-test";
|
||||
const CLIENT_SECRET = "client-secret-for-the-mock-idp";
|
||||
const MINUTE = 60 * 1000;
|
||||
|
||||
// ── The mock identity provider ──────────────────────────────────────────────
|
||||
|
||||
interface Grant {
|
||||
sub: string;
|
||||
email: string;
|
||||
nonce: string;
|
||||
codeChallenge: string;
|
||||
}
|
||||
|
||||
let idp: Server;
|
||||
let ISSUER = "";
|
||||
let privateKey: CryptoKey;
|
||||
let publicJwk: Record<string, unknown>;
|
||||
const grants = new Map<string, Grant>();
|
||||
|
||||
function s256(verifier: string): string {
|
||||
return createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const pair = await generateKeyPair("ES256");
|
||||
privateKey = pair.privateKey;
|
||||
publicJwk = { ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "ES256" };
|
||||
|
||||
idp = createServer(async (req, res) => {
|
||||
const json = (status: number, body: unknown) => {
|
||||
res.writeHead(status, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
const path = new URL(req.url ?? "/", ISSUER).pathname;
|
||||
if (path === "/.well-known/openid-configuration") {
|
||||
return json(200, {
|
||||
issuer: ISSUER,
|
||||
authorization_endpoint: `${ISSUER}/authorize`,
|
||||
token_endpoint: `${ISSUER}/token`,
|
||||
jwks_uri: `${ISSUER}/jwks`,
|
||||
});
|
||||
}
|
||||
if (path === "/jwks") return json(200, { keys: [publicJwk] });
|
||||
if (path === "/token" && req.method === "POST") {
|
||||
let raw = "";
|
||||
for await (const chunk of req) raw += chunk;
|
||||
const form = new URLSearchParams(raw);
|
||||
const grant = grants.get(form.get("code") ?? "");
|
||||
if (
|
||||
!grant ||
|
||||
form.get("client_id") !== CLIENT_ID ||
|
||||
form.get("client_secret") !== CLIENT_SECRET ||
|
||||
s256(form.get("code_verifier") ?? "") !== grant.codeChallenge
|
||||
) {
|
||||
return json(400, { error: "invalid_grant" });
|
||||
}
|
||||
grants.delete(form.get("code") ?? "");
|
||||
const idToken = await new SignJWT({
|
||||
nonce: grant.nonce,
|
||||
email: grant.email,
|
||||
email_verified: true,
|
||||
})
|
||||
.setProtectedHeader({ alg: "ES256", kid: "k1" })
|
||||
.setIssuer(ISSUER)
|
||||
.setAudience(CLIENT_ID)
|
||||
.setSubject(grant.sub)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
return json(200, { id_token: idToken, token_type: "Bearer" });
|
||||
}
|
||||
json(404, { error: "not_found" });
|
||||
});
|
||||
await new Promise<void>((resolve) => idp.listen(0, "127.0.0.1", resolve));
|
||||
ISSUER = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => idp.close(() => resolve()));
|
||||
});
|
||||
|
||||
// ── Signing in through the provider ─────────────────────────────────────────
|
||||
|
||||
type NextResponseLike = Response & {
|
||||
cookies?: { get(name: string): { value: string } | undefined };
|
||||
};
|
||||
|
||||
function setCookie(response: Response, name: string): string | undefined {
|
||||
return (response as NextResponseLike).cookies?.get(name)?.value || undefined;
|
||||
}
|
||||
|
||||
let sequence = 0;
|
||||
|
||||
/**
|
||||
* The browser's trip: the login route, the provider, the callback. Returns the
|
||||
* callback's answer; the session (when one is minted) lands in the cookie jar.
|
||||
*/
|
||||
async function signInThroughProvider(identity: {
|
||||
sub: string;
|
||||
email: string;
|
||||
}): Promise<Response> {
|
||||
const { GET: login } = await import("@/app/api/auth/oidc/login/route");
|
||||
const started = await login(new NextRequest(`${APP}/api/auth/oidc/login`));
|
||||
const location = new URL(started.headers.get("location") ?? "");
|
||||
expect(location.origin + location.pathname).toBe(`${ISSUER}/authorize`);
|
||||
const stateCookie = setCookie(started, "oidc_auth_state");
|
||||
expect(stateCookie).toBeDefined();
|
||||
|
||||
const code = `code-${sequence++}`;
|
||||
grants.set(code, {
|
||||
...identity,
|
||||
nonce: location.searchParams.get("nonce") ?? "",
|
||||
codeChallenge: location.searchParams.get("code_challenge") ?? "",
|
||||
});
|
||||
const state = location.searchParams.get("state") ?? "";
|
||||
|
||||
const { GET: callback } = await import("@/app/api/auth/oidc/callback/route");
|
||||
return callback(
|
||||
new NextRequest(
|
||||
`${APP}/api/auth/oidc/callback?code=${code}&state=${encodeURIComponent(state)}`,
|
||||
{ headers: { cookie: `oidc_auth_state=${stateCookie}` } },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function post(path: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(`${APP}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
async function mfaVerify(mfaTicket: string, code: string) {
|
||||
const { POST } = await import("@/app/api/auth/mfa/verify/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/mfa/verify", { mfaTicket, method: "totp", code }),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproof(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/reproof/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/reproof", body));
|
||||
}
|
||||
|
||||
async function handover(profileId: string) {
|
||||
const { POST } =
|
||||
await import("@/app/api/managed-profiles/[id]/handover/route");
|
||||
return POST(
|
||||
post(
|
||||
`/api/managed-profiles/${profileId}/handover`,
|
||||
handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }),
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function totpSetup() {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/setup/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/me/mfa/totp/setup"));
|
||||
}
|
||||
|
||||
async function totpConfirm(code: string) {
|
||||
const { POST } = await import("@/app/api/auth/me/mfa/totp/confirm/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/me/mfa/totp/confirm", { code }),
|
||||
);
|
||||
}
|
||||
|
||||
async function meta(res: Response) {
|
||||
return (
|
||||
(await res.json()) as {
|
||||
meta?: { errorCode?: string; methods?: string[] };
|
||||
}
|
||||
).meta;
|
||||
}
|
||||
|
||||
function codeFor(secret: string): string {
|
||||
return new OTPAuth.TOTP({
|
||||
issuer: "HealthLog",
|
||||
label: "HealthLog",
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: OTPAuth.Secret.fromBase32(secret),
|
||||
}).generate({ timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// ── The household ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A Guardian bound to the provider's subject, and the profile they look after. */
|
||||
async function guardianWithProfile(withTotp: boolean) {
|
||||
const prisma = getPrismaClient();
|
||||
const suffix = sequence++;
|
||||
let secret: string | null = null;
|
||||
let totp = {};
|
||||
if (withTotp) {
|
||||
const { generateTotpSecret } = await import("@/lib/auth/mfa/totp");
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
secret = generateTotpSecret();
|
||||
totp = {
|
||||
totpSecretEncrypted: encrypt(secret),
|
||||
totpConfirmedAt: new Date(),
|
||||
totpLastStep: null,
|
||||
};
|
||||
}
|
||||
const guardian = await prisma.user.create({
|
||||
data: {
|
||||
username: `guardian-${suffix}`,
|
||||
email: `guardian-${suffix}@example.test`,
|
||||
oidcIssuer: ISSUER,
|
||||
oidcSub: `subject-guardian-${suffix}`,
|
||||
onboardingCompletedAt: new Date(),
|
||||
...totp,
|
||||
},
|
||||
});
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Robin",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
return {
|
||||
guardian,
|
||||
profileId: profile.id,
|
||||
secret,
|
||||
identity: { sub: guardian.oidcSub!, email: guardian.email! },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
let clock = 0;
|
||||
|
||||
/** Move the wall clock the app reads, as time passing would. */
|
||||
function advance(ms: number) {
|
||||
clock += ms;
|
||||
vi.setSystemTime(clock);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
grants.clear();
|
||||
_resetOidcCacheForTests();
|
||||
process.env.APP_URL = APP;
|
||||
process.env.NEXT_PUBLIC_APP_URL = APP;
|
||||
process.env.OIDC_ISSUER_URL = ISSUER;
|
||||
process.env.OIDC_CLIENT_ID = CLIENT_ID;
|
||||
process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET;
|
||||
process.env.OIDC_BUTTON_LABEL = "Example ID";
|
||||
// Single sign-on only: the report came from such a server, and it is the
|
||||
// strictest case (no password to fall back on anywhere).
|
||||
process.env.OIDC_ONLY = "true";
|
||||
// Only `Date` is faked: the provider round trip and Postgres keep real
|
||||
// timers, and the codes generated below follow the moved clock.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
clock = Date.now();
|
||||
vi.setSystemTime(clock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
for (const key of [
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_BUTTON_LABEL",
|
||||
"OIDC_ONLY",
|
||||
]) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
// ── The tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("a Guardian signed in through SSO with a second factor in the app", () => {
|
||||
it("confirms the factor when asked, then hands the profile over", async () => {
|
||||
const { guardian, profileId, secret, identity } =
|
||||
await guardianWithProfile(true);
|
||||
|
||||
// The callback puts the account through the app's own challenge.
|
||||
const cb = await signInThroughProvider(identity);
|
||||
expect([302, 303, 307]).toContain(cb.status);
|
||||
const handoff = setCookie(cb, "oidc_mfa_handoff");
|
||||
expect(handoff).toBeDefined();
|
||||
const { ticket, methods } = JSON.parse(decodeURIComponent(handoff!)) as {
|
||||
ticket: string;
|
||||
methods: string[];
|
||||
};
|
||||
expect(methods).toContain("totp");
|
||||
expect(cookieJar.get("healthlog_session")).toBeUndefined();
|
||||
|
||||
const verified = await mfaVerify(ticket, codeFor(secret!));
|
||||
expect(verified.status).toBe(200);
|
||||
const sessionId = cookieJar.get("healthlog_session");
|
||||
expect(sessionId).toBeDefined();
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: guardian.id },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
expect(session.mfaVerifiedAt).not.toBeNull();
|
||||
|
||||
// Ten minutes on, the factor is stale. The refusal is the gate, and it
|
||||
// names what clears it: the TOTP factor this account holds.
|
||||
advance(10 * MINUTE);
|
||||
const refused = await handover(profileId);
|
||||
expect(refused.status).toBe(401);
|
||||
expect(await meta(refused)).toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
methods: ["totp"],
|
||||
});
|
||||
expect(
|
||||
await getPrismaClient().managedProfileHandover.count({
|
||||
where: { profileId },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// What the dialog posts.
|
||||
const proved = await reproof({ method: "totp", code: codeFor(secret!) });
|
||||
expect(proved.status).toBe(200);
|
||||
|
||||
// And the request is sent once more.
|
||||
const minted = await handover(profileId);
|
||||
expect(minted.status).toBe(201);
|
||||
const link = (await minted.json()).data as { url: string };
|
||||
expect(link.url).toContain("/claim");
|
||||
expect(
|
||||
await getPrismaClient().managedProfileHandover.count({
|
||||
where: { profileId },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not let the provider's sign-in stand in for the factor", async () => {
|
||||
// The re-proof dialog never offers "sign in again" to this account: it
|
||||
// holds a factor, and the gate names it. A fresh SSO sign-in still goes
|
||||
// through the factor, so it is no shortcut either.
|
||||
const { profileId, secret, identity } = await guardianWithProfile(true);
|
||||
const cb = await signInThroughProvider(identity);
|
||||
const { ticket } = JSON.parse(
|
||||
decodeURIComponent(setCookie(cb, "oidc_mfa_handoff")!),
|
||||
) as { ticket: string };
|
||||
expect((await mfaVerify(ticket, codeFor(secret!))).status).toBe(200);
|
||||
advance(10 * MINUTE);
|
||||
|
||||
const wrong = await reproof({ method: "totp", code: "000000" });
|
||||
expect(wrong.status).toBe(401);
|
||||
expect((await handover(profileId)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a Guardian whose only second factor is the identity provider's", () => {
|
||||
it("is asked for an app factor, can enrol one at once, and then hands over", async () => {
|
||||
const { guardian, profileId, identity } = await guardianWithProfile(false);
|
||||
|
||||
const cb = await signInThroughProvider(identity);
|
||||
expect([302, 303, 307]).toContain(cb.status);
|
||||
expect(cookieJar.get("healthlog_session")).toBeDefined();
|
||||
|
||||
// The app cannot see the provider's factor, so the gate asks for one of
|
||||
// its own — and says so with its own code, which the web turns into
|
||||
// "set one up under Settings → Security" rather than a dialog.
|
||||
const refused = await handover(profileId);
|
||||
expect(refused.status).toBe(401);
|
||||
expect((await meta(refused))?.errorCode).toBe(
|
||||
"auth.stepup.mfa_not_enrolled",
|
||||
);
|
||||
|
||||
// Right after signing in, enrolment needs no further proof.
|
||||
const setup = await totpSetup();
|
||||
expect(setup.status).toBe(200);
|
||||
const { totpSecret } = (await setup.json()).data as { totpSecret: string };
|
||||
const confirmed = await totpConfirm(codeFor(totpSecret));
|
||||
expect(confirmed.status).toBe(200);
|
||||
|
||||
// The confirmation stamped the session, so the handover goes through.
|
||||
const minted = await handover(profileId);
|
||||
expect(minted.status).toBe(201);
|
||||
const row = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: guardian.id },
|
||||
select: { totpConfirmedAt: true },
|
||||
});
|
||||
expect(row.totpConfirmedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is told to sign in again when the sign-in is too old to enrol from", async () => {
|
||||
const { identity } = await guardianWithProfile(false);
|
||||
await signInThroughProvider(identity);
|
||||
advance(10 * MINUTE);
|
||||
|
||||
// Nothing to re-prove in place: no password, no passkey, no factor. The
|
||||
// empty list is what makes the web say "sign in again" instead of
|
||||
// offering a password this account does not have.
|
||||
const refused = await totpSetup();
|
||||
expect(refused.status).toBe(401);
|
||||
expect(await meta(refused)).toMatchObject({
|
||||
errorCode: "auth.reproof.required",
|
||||
methods: [],
|
||||
});
|
||||
|
||||
// And signing in again is a way through.
|
||||
cookieJar.clear();
|
||||
await signInThroughProvider(identity);
|
||||
expect((await totpSetup()).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* #959 — a passkey counts as the second factor on the managed-profile acts.
|
||||
*
|
||||
* A Guardian whose only strong credential is a primary passkey (a password
|
||||
* too, but no TOTP and no security key) used to be refused
|
||||
* `auth.stepup.mfa_not_enrolled` by `requireFreshMfa`, and so could not hand a
|
||||
* profile over, create one or manage its guardians. Driven against real
|
||||
* Postgres through the real routes, with only the WebAuthn signature check
|
||||
* stubbed:
|
||||
*
|
||||
* 1. the password login is unchanged: no MFA challenge, a session straight
|
||||
* away, and that session is not stamped as a fresh factor;
|
||||
* 2. the handover refuses with `auth.stepup.required` and offers the passkey
|
||||
* (not the password) as the way through;
|
||||
* 3. a password re-proof does not open the gate;
|
||||
* 4. a passkey re-proof, which is what the dialog posts, does, and the
|
||||
* handover goes through.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
||||
import { handoverCreateBody } from "@/lib/queries/use-managed-profiles";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-managed-profile-passkey-step-up-32-bytes-min";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/dispatch-localised", () => ({
|
||||
dispatchLocalisedNotification: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// The signature check is the one piece a test cannot produce honestly; the
|
||||
// challenge lookup, the ownership check and the session stamp all run.
|
||||
vi.mock("@/lib/auth/passkey", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/auth/passkey")>(
|
||||
"@/lib/auth/passkey",
|
||||
);
|
||||
return { ...actual, verifyAuthentication: vi.fn() };
|
||||
});
|
||||
|
||||
const { hashPassword } = await import("@/lib/auth/password");
|
||||
|
||||
const APP = "http://localhost:3000";
|
||||
const PASSWORD = "Correct horse battery staple!42";
|
||||
const EMAIL = "guardian@example.test";
|
||||
const CREDENTIAL_ID = "guardian-primary-passkey";
|
||||
|
||||
type RouteFn = (request: NextRequest) => Promise<Response>;
|
||||
|
||||
function post(path: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(`${APP}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const { POST } = await import("@/app/api/auth/login/route");
|
||||
return (POST as unknown as RouteFn)(
|
||||
post("/api/auth/login", { email: EMAIL, password: PASSWORD }),
|
||||
);
|
||||
}
|
||||
|
||||
async function reproof(body: unknown) {
|
||||
const { POST } = await import("@/app/api/auth/reproof/route");
|
||||
return (POST as unknown as RouteFn)(post("/api/auth/reproof", body));
|
||||
}
|
||||
|
||||
async function handover(profileId: string) {
|
||||
const { POST } =
|
||||
await import("@/app/api/managed-profiles/[id]/handover/route");
|
||||
return POST(
|
||||
post(
|
||||
`/api/managed-profiles/${profileId}/handover`,
|
||||
handoverCreateBody({ profileId, expiresInDays: 7, proposals: [] }),
|
||||
),
|
||||
{ params: Promise.resolve({ id: profileId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function body(res: Response) {
|
||||
return (await res.json()) as {
|
||||
data?: unknown;
|
||||
meta?: { errorCode?: string; methods?: string[]; mfaRequired?: boolean };
|
||||
};
|
||||
}
|
||||
|
||||
async function passkeyProof(userId: string) {
|
||||
const challenge = await getPrismaClient().authChallenge.create({
|
||||
data: {
|
||||
userId,
|
||||
challenge: `proof-${Date.now()}`,
|
||||
type: "authentication",
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000),
|
||||
},
|
||||
});
|
||||
return {
|
||||
method: "passkey",
|
||||
challengeId: challenge.id,
|
||||
credential: {
|
||||
id: CREDENTIAL_ID,
|
||||
rawId: CREDENTIAL_ID,
|
||||
type: "public-key",
|
||||
response: {
|
||||
clientDataJSON: "client-data",
|
||||
authenticatorData: "authenticator-data",
|
||||
signature: "signature",
|
||||
},
|
||||
clientExtensionResults: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let guardianId = "";
|
||||
let profileId = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.authChallenge.deleteMany();
|
||||
await prisma.rateLimit.deleteMany();
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
process.env.APP_URL = APP;
|
||||
process.env.NEXT_PUBLIC_APP_URL = APP;
|
||||
|
||||
const guardian = await prisma.user.create({
|
||||
data: {
|
||||
username: "guardian",
|
||||
email: EMAIL,
|
||||
passwordHash: await hashPassword(PASSWORD),
|
||||
onboardingCompletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
guardianId = guardian.id;
|
||||
await prisma.passkey.create({
|
||||
data: {
|
||||
userId: guardian.id,
|
||||
credentialId: CREDENTIAL_ID,
|
||||
credentialPublicKey: Buffer.from([4, 3, 2, 1]),
|
||||
counter: 0,
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
transports: ["internal"],
|
||||
},
|
||||
});
|
||||
const { profile } = await createManagedProfile({
|
||||
creatorId: guardian.id,
|
||||
displayName: "Robin",
|
||||
dateOfBirth: null,
|
||||
locale: "en",
|
||||
timezone: "UTC",
|
||||
});
|
||||
profileId = profile.id;
|
||||
|
||||
const { verifyAuthentication } = await import("@/lib/auth/passkey");
|
||||
vi.mocked(verifyAuthentication).mockResolvedValue({
|
||||
verification: { verified: true },
|
||||
passkey: { userId: guardian.id },
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("a Guardian whose only strong credential is a passkey", () => {
|
||||
it("signs in with the password exactly as before, without an MFA challenge", async () => {
|
||||
const res = await login();
|
||||
expect(res.status).toBe(200);
|
||||
expect((await body(res)).meta?.mfaRequired).toBeUndefined();
|
||||
expect(cookieJar.get("healthlog_session")).toBeDefined();
|
||||
const session = await getPrismaClient().session.findFirstOrThrow({
|
||||
where: { userId: guardianId },
|
||||
select: { mfaVerifiedAt: true },
|
||||
});
|
||||
// The password login proves no factor, so it cannot open the gate itself.
|
||||
expect(session.mfaVerifiedAt).toBeNull();
|
||||
expect(await getPrismaClient().mfaChallenge.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("is offered the passkey, refused on a password, and hands over after the passkey", async () => {
|
||||
expect((await login()).status).toBe(200);
|
||||
|
||||
const refused = await handover(profileId);
|
||||
expect(refused.status).toBe(401);
|
||||
expect((await body(refused)).meta).toMatchObject({
|
||||
errorCode: "auth.stepup.required",
|
||||
methods: ["passkey"],
|
||||
});
|
||||
|
||||
// A password re-proof is accepted by the endpoint (the recent-proof gate
|
||||
// still takes it on this account) but stamps no factor, so the handover
|
||||
// stays shut.
|
||||
const byPassword = await reproof({
|
||||
method: "password",
|
||||
password: PASSWORD,
|
||||
});
|
||||
expect(byPassword.status).toBe(200);
|
||||
expect((await handover(profileId)).status).toBe(401);
|
||||
|
||||
// What the dialog posts after the passkey ceremony.
|
||||
const byPasskey = await reproof(await passkeyProof(guardianId));
|
||||
expect(byPasskey.status).toBe(200);
|
||||
|
||||
const minted = await handover(profileId);
|
||||
expect(minted.status).toBe(201);
|
||||
expect(
|
||||
await getPrismaClient().managedProfileHandover.count({
|
||||
where: { profileId },
|
||||
}),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user