mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge remote-tracking branch 'origin/main' into fix/v1434-illness-symptoms
This commit is contained in:
commit
e8e3cf4cd3
47 files changed
+2646
-553
No files matched your search
@@ -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")}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user