mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix(auth): let every fresh-factor refusal open the re-proof dialog
A Guardian signed in through single sign-on with a second factor enrolled could not hand a managed profile over once five minutes had passed since sign-in: the handover answered 401 auth.stepup.required, and the panel printed "Confirm your second factor, then try again" with no way to do so. The same held for every action behind requireFreshMfa: creating, editing and ending a managed profile, adding and removing a Guardian, disabling the second factor, rotating recovery codes, removing a security key or a passkey, changing the password, the data wipe, account deletion and the key rotation. The recent-proof routes had the dialog; these had text. - requireFreshMfa names the proofs that clear a stale stamp in meta.methods (the possession proofs the account holds, never a password, which stamps reproofAt only). - The re-proof flow treats that refusal like auth.reproof.required: open the dialog, post the proof to /api/auth/reproof, send the request once more. The flow moves into a React-free module so it can be driven in tests; the hook binds it to the dialog. - The managed-profile mutation hooks take a required proof guard, and every caller passes recentProof.run. The other surfaces wrap their requests in recentProof.run; disabling the second factor asks before its confirmation opens, so the code typed there is not spent twice. - An account with nothing to re-prove in place (single sign-on only, no factor) is told to sign in again when adding a factor, instead of being offered a password it does not have. - step-up-web-caller-guard freezes the gated route methods and fails when a web request to one of them runs outside the flow. An account whose only second factor is the identity provider's is still asked for an app factor on the managed-profile acts: the app cannot see the provider's factor. Enrolling right after a sign-in needs no further proof, and the confirmation stamps the session, so the act follows. Refs #959
This commit is contained in:
1 parent
59f910df5e
commit
99f0607a69
36 files changed
+2122
-466
No files matched your search
@@ -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