Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms

This commit is contained in:
Marc-André Bombeck committed 2026-10-17 11:35:55 +02:00
commit e8e3cf4cd3
47 files changed
+2646 -553

No files matched your search

+24 -6
View File
@@ -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"
+51 -15
View File
@@ -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}
+29 -10
View File
@@ -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")}
/>
);