commit 9ce26171f415fa34547d65e6b53b08ad0ce453c1
parent 52aab1ac24fe4df2e8a532d32c3480b4c7bf29ad
Author: Florian Dold <dold@taler.net>
Date: Sat, 29 Aug 2026 15:55:04 +0200
challenger web UI: separate code recovery actions
Diffstat:
4 files changed, 458 insertions(+), 107 deletions(-)
diff --git a/packages/challenger-webui/src/challenger-flow.test.ts b/packages/challenger-webui/src/challenger-flow.test.ts
@@ -1,19 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
+import { TalerFormAttributes } from "@gnu-taler/taler-util";
import { getSession, safeToURL } from "./Routing.js";
import { getChallengeNextPage } from "./components/CheckChallengeIsUpToDate.js";
import { getSessionStateKey } from "./hooks/session.js";
import {
+ canSubmitDestination,
findInvalidRestriction,
+ getAddressDisplayLines,
+ getAddressEntryMode,
getLocalizedRestrictionHint,
INT_PHONE_REGEX,
+ isSameAddressForType,
} from "./pages/AskChallenge.js";
import {
ReturnTimerDriver,
safeRedirectURL,
scheduleAutomaticReturn,
} from "./pages/CallengeCompleted.js";
-import { formatCountdown } from "./pages/AnswerChallenge.js";
+import {
+ formatCountdown,
+ getCodeResendState,
+ getExhaustedRecoveryAction,
+} from "./pages/AnswerChallenge.js";
function status(
overrides: Record<string, unknown> = {},
@@ -109,12 +118,138 @@ test("server restrictions are localized and invalid expressions are rejected", (
assert.equal(INT_PHONE_REGEX.test("+1234567890123456"), false);
});
+test("destination changes require an actual address-field change", () => {
+ const email = { [TalerFormAttributes.CONTACT_EMAIL]: "old@example.com" };
+ assert.equal(
+ getAddressEntryMode(
+ {
+ last_address: undefined,
+ fix_address: false,
+ changes_left: 1,
+ },
+ false,
+ ),
+ "initial",
+ );
+ assert.equal(
+ getAddressEntryMode(
+ {
+ last_address: email,
+ fix_address: false,
+ changes_left: 1,
+ },
+ true,
+ ),
+ "change",
+ );
+ assert.equal(
+ getAddressEntryMode(
+ {
+ last_address: email,
+ fix_address: false,
+ changes_left: 1,
+ },
+ false,
+ ),
+ "initial",
+ );
+ assert.equal(
+ getAddressEntryMode(
+ {
+ last_address: email,
+ fix_address: true,
+ changes_left: 1,
+ },
+ true,
+ ),
+ "locked",
+ );
+ assert.equal(
+ getAddressEntryMode(
+ {
+ last_address: email,
+ fix_address: false,
+ changes_left: 0,
+ },
+ true,
+ ),
+ "locked",
+ );
+
+ assert.equal(
+ isSameAddressForType(
+ "email",
+ { ...email, read_only: "true" },
+ { ...email },
+ ),
+ true,
+ );
+ assert.equal(canSubmitDestination("change", "email", email, email), false);
+ assert.equal(
+ canSubmitDestination("change", "email", email, {
+ [TalerFormAttributes.CONTACT_EMAIL]: "new@example.com",
+ }),
+ true,
+ );
+ assert.equal(canSubmitDestination("locked", "email", email, email), false);
+ assert.equal(
+ canSubmitDestination("initial", "email", undefined, email),
+ true,
+ );
+});
+
+test("postal destination comparison and summary use every address field", () => {
+ const postal = {
+ [TalerFormAttributes.CONTACT_NAME]: "Alice Example",
+ [TalerFormAttributes.ADDRESS_LINES]: "Main Street 1\n12345 Exampletown",
+ [TalerFormAttributes.ADDRESS_COUNTRY]: "CH",
+ };
+ assert.equal(
+ isSameAddressForType("postal", postal, {
+ [TalerFormAttributes.ADDRESS_COUNTRY]: "CH",
+ [TalerFormAttributes.ADDRESS_LINES]: "Main Street 1\n12345 Exampletown",
+ [TalerFormAttributes.CONTACT_NAME]: "Alice Example",
+ }),
+ true,
+ );
+ assert.equal(
+ isSameAddressForType("postal", postal, {
+ ...postal,
+ [TalerFormAttributes.ADDRESS_COUNTRY]: "DE",
+ }),
+ false,
+ );
+ assert.deepEqual(getAddressDisplayLines("postal", postal, "Switzerland"), [
+ "Alice Example",
+ "Main Street 1",
+ "12345 Exampletown",
+ "Switzerland",
+ ]);
+});
+
test("resend countdown uses a stable minute and second format", () => {
assert.equal(formatCountdown(0), "0:00");
assert.equal(formatCountdown(9), "0:09");
assert.equal(formatCountdown(61), "1:01");
});
+test("code recovery distinguishes ready, cooldown, and terminal states", () => {
+ assert.equal(getCodeResendState(true, 1, 0), "ready");
+ assert.equal(getCodeResendState(true, 1, 42), "cooldown");
+ assert.equal(getCodeResendState(true, 0, 0), "exhausted");
+ assert.equal(getCodeResendState(true, 0, 42), "exhausted");
+ assert.equal(getCodeResendState(false, 1, 0), "unavailable");
+});
+
+test("exhausted code entry promotes the best remaining recovery action", () => {
+ assert.equal(getExhaustedRecoveryAction("ready", true), "send");
+ assert.equal(getExhaustedRecoveryAction("cooldown", true), "send");
+ assert.equal(getExhaustedRecoveryAction("exhausted", true), "change");
+ assert.equal(getExhaustedRecoveryAction("unavailable", true), "change");
+ assert.equal(getExhaustedRecoveryAction("exhausted", false), "return");
+ assert.equal(getExhaustedRecoveryAction("unavailable", false), "return");
+});
+
test("successful verification counts down to one deadline before returning", () => {
let now = 0;
let callback: (() => void) | undefined;
diff --git a/packages/challenger-webui/src/components/VerificationUi.tsx b/packages/challenger-webui/src/components/VerificationUi.tsx
@@ -143,7 +143,7 @@ export function ActionButton({
<button
type={submit ? "submit" : "button"}
class="flex min-h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-semibold text-onPrimary shadow-sm hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:bg-outlineVariant disabled:text-secondary disabled:opacity-60 dark:bg-darkPrimary dark:text-darkOnPrimary dark:focus-visible:outline-darkPrimary dark:disabled:bg-darkSecondaryContainer dark:disabled:text-darkSecondary"
- disabled={disabled || running || !onClick}
+ disabled={disabled || running || (!submit && !onClick)}
aria-busy={running}
onClick={onClick}
>
@@ -170,6 +170,50 @@ export function SecondaryAction({
);
}
+export function SecondaryButton({
+ children,
+ busyLabel,
+ running = false,
+ disabled = false,
+ onClick,
+}: {
+ children: ComponentChildren;
+ busyLabel: TranslatedString;
+ running?: boolean;
+ disabled?: boolean;
+ onClick?: () => void | Promise<void>;
+}): VNode {
+ return (
+ <button
+ type="button"
+ class="flex min-h-11 w-full items-center justify-center gap-2 rounded-lg border border-outline bg-background px-4 py-2.5 text-center text-sm font-semibold text-onBackground hover:bg-secondaryContainer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:border-outlineVariant disabled:text-secondary disabled:opacity-60 dark:border-darkSecondary dark:bg-darkBackground dark:text-darkOnBackground dark:hover:bg-darkSecondaryContainer dark:focus-visible:outline-darkPrimary dark:disabled:border-darkSecondaryContainer dark:disabled:text-darkSecondary"
+ disabled={disabled || running || !onClick}
+ aria-busy={running}
+ onClick={onClick}
+ >
+ {running ? <Spinner /> : undefined}
+ <span>{running ? busyLabel : children}</span>
+ </button>
+ );
+}
+
+export function TextActionLink({
+ href,
+ children,
+}: {
+ href: string;
+ children: ComponentChildren;
+}): VNode {
+ return (
+ <a
+ href={href}
+ class="inline-flex min-h-11 items-center rounded-md px-1 text-sm font-semibold text-primary underline decoration-primary/40 underline-offset-4 hover:decoration-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:text-darkPrimary dark:decoration-darkPrimary/40 dark:hover:decoration-darkPrimary dark:focus-visible:outline-darkPrimary"
+ >
+ {children}
+ </a>
+ );
+}
+
export function PrimaryActionLink({
href,
children,
diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx
@@ -34,8 +34,11 @@ import {
formatHttpErrorDetails,
InlineNotice,
LoadingState,
+ PrimaryActionLink,
SecondaryAction,
+ SecondaryButton,
TechnicalDetails,
+ TextActionLink,
VerificationCard,
} from "../components/VerificationUi.js";
import { challengeFailureMessage } from "./AskChallenge.js";
@@ -72,11 +75,11 @@ export function AnswerChallenge({
const { i18n } = useTranslationContext();
const { sent, completed } = useSessionState(session);
const [pin, setPin] = useState("");
- const [actionError, setActionError] = useState<TranslatedString>();
- const [actionErrorDetails, setActionErrorDetails] = useState<string>();
- const [actionErrorSource, setActionErrorSource] = useState<
- "delivery" | "code"
- >("code");
+ const [codeError, setCodeError] = useState<TranslatedString>();
+ const [codeErrorDetails, setCodeErrorDetails] = useState<string>();
+ const [deliveryError, setDeliveryError] = useState<TranslatedString>();
+ const [deliveryErrorDetails, setDeliveryErrorDetails] = useState<string>();
+ const [newCodeSent, setNewCodeSent] = useState(false);
const pinInput = useRef<HTMLInputElement | null>(null);
const { result, retry, isRefreshing } = useChallengeSession(session);
@@ -93,9 +96,6 @@ export function AnswerChallenge({
[retransmissionTime],
);
const remainingSeconds = useRemainingSeconds(deadline);
- // Translators: %1$s is a countdown formatted as minutes:seconds, for
- // example 1:05.
- const resendCountdown = i18n.str`You can request another code in ${formatCountdown(remainingSeconds)}.`;
const lastAddress = lastStatus?.last_address
? getAddressDescriptionFromAddrType(
config.address_type,
@@ -123,9 +123,12 @@ export function AnswerChallenge({
// Translators: Instruction used when Challenger cannot display the destination
// to which the one-time verification code was sent.
const enterReceivedCodeDescription = i18n.str`Enter the one-time verification code you received.`;
- // Translators: Informational notice heading shown while the resend cooldown is
- // active, confirming that a code was sent.
- const codeSentTitle = i18n.str`Code sent`;
+ // Translators: Success-notice heading after Challenger has delivered a
+ // replacement one-time verification code.
+ const newCodeSentTitle = i18n.str`New code sent`;
+ // Translators: A successfully delivered replacement code invalidates the old
+ // code, so this tells the user to enter the most recently received code.
+ const enterLatestCodeDescription = i18n.str`A new code was sent. Enter the latest code you received.`;
// Translators: Form-field label for the one-time numeric code sent by email,
// SMS, or postal mail.
const verificationCodeLabel = i18n.str`Verification code`;
@@ -150,27 +153,31 @@ export function AnswerChallenge({
>((ct, nonce, address) => lib.challenger.challenge(nonce, address), {
onResult(operationResult) {
if (operationResult.type === "ok") {
- setActionError(undefined);
- setActionErrorDetails(undefined);
+ setDeliveryError(undefined);
+ setDeliveryErrorDetails(undefined);
if (operationResult.body.type === "completed") {
completed(operationResult.body);
onComplete();
} else {
+ setPin("");
+ setCodeError(undefined);
+ setCodeErrorDetails(undefined);
+ setNewCodeSent(true);
sent(operationResult.body);
void revalidateChallengeSession();
}
return;
}
- setActionError(challengeFailureMessage(i18n, operationResult));
- setActionErrorDetails(formatHttpErrorDetails(operationResult));
- setActionErrorSource("delivery");
+ setNewCodeSent(false);
+ setDeliveryError(challengeFailureMessage(i18n, operationResult));
+ setDeliveryErrorDetails(formatHttpErrorDetails(operationResult));
},
onError(error) {
- setActionError(
+ setNewCodeSent(false);
+ setDeliveryError(
i18n.str`We could not contact the verification service. Check your connection and try again.`,
);
- setActionErrorDetails(formatErrorDetails(error));
- setActionErrorSource("delivery");
+ setDeliveryErrorDetails(formatErrorDetails(error));
},
});
@@ -181,16 +188,15 @@ export function AnswerChallenge({
onResult(operationResult) {
if (operationResult.type === "ok") {
if (operationResult.body.type === "completed") {
- setActionError(undefined);
- setActionErrorDetails(undefined);
+ setCodeError(undefined);
+ setCodeErrorDetails(undefined);
completed(operationResult.body);
onComplete();
return;
}
setPin("");
- setActionError(incorrectCodeMessage);
- setActionErrorDetails(undefined);
- setActionErrorSource("code");
+ setCodeError(incorrectCodeMessage);
+ setCodeErrorDetails(undefined);
pinInput.current?.focus();
void revalidateChallengeSession();
return;
@@ -198,26 +204,29 @@ export function AnswerChallenge({
const pending = getPendingResponse(operationResult);
if (pending) {
setPin("");
- setActionError(incorrectCodeMessage);
- setActionErrorDetails(undefined);
- setActionErrorSource("code");
+ setCodeError(incorrectCodeMessage);
+ setCodeErrorDetails(undefined);
pinInput.current?.focus();
void revalidateChallengeSession();
return;
}
- setActionError(solveFailureMessage(i18n, operationResult.case));
- setActionErrorDetails(formatHttpErrorDetails(operationResult));
- setActionErrorSource("code");
+ setCodeError(solveFailureMessage(i18n, operationResult.case));
+ setCodeErrorDetails(formatHttpErrorDetails(operationResult));
},
onError(error) {
- setActionError(
+ setCodeError(
i18n.str`We could not contact the verification service. Check your connection and try again.`,
);
- setActionErrorDetails(formatErrorDetails(error));
- setActionErrorSource("code");
+ setCodeErrorDetails(formatErrorDetails(error));
},
});
+ useEffect(() => {
+ if (newCodeSent && pinInput.current) {
+ doAutoFocus(pinInput.current);
+ }
+ }, [newCodeSent, lastStatus?.auth_attempts_left]);
+
if (!result) {
return <LoadingState title={i18n.str`Loading verification…`} />;
}
@@ -270,11 +279,18 @@ export function AnswerChallenge({
const cantTry = lastStatus.auth_attempts_left === 0;
const canChange = lastStatus.changes_left > 0;
- const canResend =
- !!lastStatus.last_address &&
- lastStatus.pin_transmissions_left > 0 &&
- remainingSeconds === 0;
+ const resendState = getCodeResendState(
+ !!lastStatus.last_address,
+ lastStatus.pin_transmissions_left,
+ remainingSeconds,
+ );
const canVerify = pin.length > 0 && !cantTry;
+ const onResend = lastStatus.last_address
+ ? async () => {
+ setNewCodeSent(false);
+ await resend.run(session.nonce, lastStatus.last_address!);
+ }
+ : undefined;
if (cantTry) {
return (
@@ -284,15 +300,18 @@ export function AnswerChallenge({
description={exhaustedCodeDescription}
>
<CodeActions
+ mode="exhausted"
+ addressType={config.address_type}
routeAsk={routeAsk.url({})}
+ returnUrl={session.redirectURL}
canChange={canChange}
- canResend={canResend}
+ resendState={resendState}
+ remainingSeconds={remainingSeconds}
resendRunning={resend.running}
- onResend={
- lastStatus.last_address
- ? () => resend.run(session.nonce, lastStatus.last_address!)
- : undefined
- }
+ onResend={onResend}
+ deliveryError={deliveryError}
+ deliveryErrorDetails={deliveryErrorDetails}
+ codeSendFailureTitle={codeSendFailureTitle}
/>
</VerificationCard>
);
@@ -323,8 +342,10 @@ export function AnswerChallenge({
</p>
) : undefined}
- {remainingSeconds > 0 ? (
- <InlineNotice title={codeSentTitle}>{resendCountdown}</InlineNotice>
+ {newCodeSent ? (
+ <InlineNotice tone="success" title={newCodeSentTitle}>
+ {enterLatestCodeDescription}
+ </InlineNotice>
) : undefined}
<form
@@ -352,30 +373,23 @@ export function AnswerChallenge({
value={pin}
placeholder="123456"
class="mt-2 block w-full rounded-lg border px-3.5 py-2.5 text-base shadow-sm focus:ring-2"
- aria-invalid={!!actionError}
- aria-describedby={actionError ? "verification-code-error" : undefined}
+ aria-invalid={!!codeError}
+ aria-describedby={codeError ? "verification-code-error" : undefined}
onInput={(event) => {
setPin(event.currentTarget.value);
- setActionError(undefined);
- setActionErrorDetails(undefined);
+ setCodeError(undefined);
+ setCodeErrorDetails(undefined);
}}
/>
- {actionError ? (
+ {codeError ? (
<div id="verification-code-error" class="mt-4">
- <InlineNotice
- tone="error"
- title={
- actionErrorSource === "delivery"
- ? codeSendFailureTitle
- : codeNotAcceptedTitle
- }
- >
- {actionError}
+ <InlineNotice tone="error" title={codeNotAcceptedTitle}>
+ {codeError}
</InlineNotice>
- {actionErrorDetails ? (
- <TechnicalDetails copyText={actionErrorDetails}>
- {actionErrorDetails}
+ {codeErrorDetails ? (
+ <TechnicalDetails copyText={codeErrorDetails}>
+ {codeErrorDetails}
</TechnicalDetails>
) : undefined}
</div>
@@ -393,9 +407,6 @@ export function AnswerChallenge({
busyLabel={verifyingLabel}
running={solve.running}
disabled={!canVerify}
- onClick={
- canVerify ? () => solve.run(session.nonce, { pin }) : undefined
- }
>
{verifyCodeLabel}
</ActionButton>
@@ -403,70 +414,232 @@ export function AnswerChallenge({
</form>
<CodeActions
+ mode="entry"
+ addressType={config.address_type}
routeAsk={routeAsk.url({})}
canChange={canChange}
- canResend={canResend}
+ resendState={resendState}
+ remainingSeconds={remainingSeconds}
resendRunning={resend.running}
- onResend={
- lastStatus.last_address
- ? () => resend.run(session.nonce, lastStatus.last_address!)
- : undefined
- }
+ onResend={onResend}
+ deliveryError={deliveryError}
+ deliveryErrorDetails={deliveryErrorDetails}
+ codeSendFailureTitle={codeSendFailureTitle}
/>
</VerificationCard>
);
}
+export type CodeResendState =
+ | "ready"
+ | "cooldown"
+ | "exhausted"
+ | "unavailable";
+
+export function getCodeResendState(
+ hasAddress: boolean,
+ transmissionsLeft: number,
+ remainingSeconds: number,
+): CodeResendState {
+ if (!hasAddress) return "unavailable";
+ if (transmissionsLeft <= 0) return "exhausted";
+ if (remainingSeconds > 0) return "cooldown";
+ return "ready";
+}
+
+export type ExhaustedRecoveryAction = "send" | "change" | "return";
+
+export function getExhaustedRecoveryAction(
+ resendState: CodeResendState,
+ canChange: boolean,
+): ExhaustedRecoveryAction {
+ if (resendState === "ready" || resendState === "cooldown") return "send";
+ if (canChange) return "change";
+ return "return";
+}
+
function CodeActions({
+ mode,
+ addressType,
routeAsk,
+ returnUrl,
canChange,
- canResend,
+ resendState,
+ remainingSeconds,
resendRunning,
onResend,
+ deliveryError,
+ deliveryErrorDetails,
+ codeSendFailureTitle,
}: {
+ mode: "entry" | "exhausted";
+ addressType: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"];
routeAsk: string;
+ returnUrl?: string;
canChange: boolean;
- canResend: boolean;
+ resendState: CodeResendState;
+ remainingSeconds: number;
resendRunning: boolean;
onResend?: () => Promise<void>;
+ deliveryError?: TranslatedString;
+ deliveryErrorDetails?: string;
+ codeSendFailureTitle: TranslatedString;
}): VNode {
const { i18n } = useTranslationContext();
- // Translators: "destination" means the email address, phone number, or
- // postal address receiving the verification code.
- const useDifferentDestination = i18n.str`Use a different destination`;
- // Translators: "destination" means the email address, phone number, or
- // postal address receiving the verification code.
- const destinationCannotChange = i18n.str`Destination cannot be changed`;
- // Translators: Busy-state label while Challenger resends the one-time code to
- // the same destination.
- const sendingCodeLabel = i18n.str`Sending code…`;
- // Translators: Button that sends another one-time code to the same email
- // address, phone number, or postal address.
- const resendCodeLabel = i18n.str`Resend code`;
- return (
- <div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
- {canChange ? (
- <SecondaryAction href={routeAsk}>
- {useDifferentDestination}
- </SecondaryAction>
- ) : (
- <button
- type="button"
- disabled
- class="min-h-11 rounded-lg border border-outlineVariant px-4 py-2.5 text-sm font-semibold text-secondary dark:border-darkSecondaryContainer dark:text-darkSecondary"
- >
- {destinationCannotChange}
- </button>
- )}
+ // Translators: Secondary action that opens a form for replacing the email
+ // address that received the current verification code.
+ const useDifferentEmail = i18n.str`Use a different email address`;
+ // Translators: Secondary action that opens a form for replacing the phone
+ // number that received the current verification code.
+ const useDifferentPhone = i18n.str`Use a different phone number`;
+ // Translators: Secondary action that opens a form for replacing the postal
+ // address that received the current verification letter.
+ const useDifferentPostal = i18n.str`Use a different postal address`;
+ const useDifferentDestination =
+ addressType === "email"
+ ? useDifferentEmail
+ : addressType === "phone"
+ ? useDifferentPhone
+ : useDifferentPostal;
+ // Translators: Explanation shown after the user has used all permitted email
+ // address changes for this verification.
+ const emailCannotChange = i18n.str`The email address can no longer be changed.`;
+ // Translators: Explanation shown after the user has used all permitted phone
+ // number changes for this verification.
+ const phoneCannotChange = i18n.str`The phone number can no longer be changed.`;
+ // Translators: Explanation shown after the user has used all permitted postal
+ // address changes for this verification.
+ const postalCannotChange = i18n.str`The postal address can no longer be changed.`;
+ const destinationCannotChange =
+ addressType === "email"
+ ? emailCannotChange
+ : addressType === "phone"
+ ? phoneCannotChange
+ : postalCannotChange;
+ // Translators: Busy-state label while Challenger sends a replacement one-time
+ // code to the same destination.
+ const sendingCodeLabel = i18n.str`Sending a new code…`;
+ // Translators: Button that sends a replacement one-time code. When delivery
+ // succeeds, this new code replaces the previously valid code.
+ const sendNewCodeLabel = i18n.str`Send a new code`;
+ // Translators: Heading for recovery actions below the normal verification-code
+ // form, such as sending another code or changing its destination.
+ const didNotReceiveCodeTitle = i18n.str`Didn't receive a code?`;
+ // Translators: Heading for recovery actions after the current verification
+ // code has no entry attempts remaining.
+ const continueVerificationTitle = i18n.str`Continue verification`;
+ // Translators: %1$s is a countdown formatted as minutes:seconds, for example
+ // 1:05. It tells the user when sending a replacement code becomes available.
+ const sendCountdown = i18n.str`You can send a new code in ${formatCountdown(remainingSeconds)}.`;
+ // Translators: Explanation shown when the verification has used all permitted
+ // one-time-code deliveries.
+ const transmissionsExhausted = i18n.str`No more codes can be sent for this verification.`;
+ // Translators: Rare recovery-state explanation when Challenger has no email
+ // address, phone number, or postal address to which it could send another code.
+ const destinationUnavailable = i18n.str`No destination is available for another code.`;
+ const canSendEventually =
+ resendState === "ready" || resendState === "cooldown";
+ const canSendNow = resendState === "ready";
+ const exhaustedPrimary = getExhaustedRecoveryAction(resendState, canChange);
+ const returnHost = returnUrl ? new URL(returnUrl).host : undefined;
+ // Translators: %1$s is the host name of the application that requested the
+ // verification. This is the final recovery action when verification cannot
+ // continue in Challenger.
+ const returnLabel = returnHost
+ ? i18n.str`Return to ${returnHost}`
+ : undefined;
+ const sendButton =
+ mode === "exhausted" ? (
<ActionButton
busyLabel={sendingCodeLabel}
running={resendRunning}
- disabled={!canResend}
- onClick={canResend ? onResend : undefined}
+ disabled={!canSendNow}
+ onClick={canSendNow ? onResend : undefined}
>
- {resendCodeLabel}
+ {sendNewCodeLabel}
</ActionButton>
- </div>
+ ) : (
+ <SecondaryButton
+ busyLabel={sendingCodeLabel}
+ running={resendRunning}
+ disabled={!canSendNow}
+ onClick={canSendNow ? onResend : undefined}
+ >
+ {sendNewCodeLabel}
+ </SecondaryButton>
+ );
+
+ return (
+ <section
+ class={`${mode === "entry" ? "mt-7 border-t border-outlineVariant pt-6 dark:border-darkSecondaryContainer" : ""}`}
+ aria-labelledby="code-recovery-title"
+ >
+ <h2 id="code-recovery-title" class="text-base font-bold">
+ {mode === "entry" ? didNotReceiveCodeTitle : continueVerificationTitle}
+ </h2>
+
+ {deliveryError ? (
+ <div class="mt-4">
+ <InlineNotice tone="error" title={codeSendFailureTitle}>
+ {deliveryError}
+ </InlineNotice>
+ {deliveryErrorDetails ? (
+ <TechnicalDetails copyText={deliveryErrorDetails}>
+ {deliveryErrorDetails}
+ </TechnicalDetails>
+ ) : undefined}
+ </div>
+ ) : undefined}
+
+ <div class="mt-4 space-y-3">
+ {canSendEventually ? sendButton : undefined}
+ {resendState === "cooldown" ? (
+ <p
+ class="text-sm text-secondary dark:text-darkSecondary"
+ role="status"
+ >
+ {sendCountdown}
+ </p>
+ ) : undefined}
+ {resendState === "exhausted" ? (
+ <p class="text-sm text-secondary dark:text-darkSecondary">
+ {transmissionsExhausted}
+ </p>
+ ) : undefined}
+ {resendState === "unavailable" ? (
+ <p class="text-sm text-secondary dark:text-darkSecondary">
+ {destinationUnavailable}
+ </p>
+ ) : undefined}
+
+ {mode === "exhausted" && exhaustedPrimary === "change" ? (
+ <PrimaryActionLink href={routeAsk}>
+ {useDifferentDestination}
+ </PrimaryActionLink>
+ ) : canChange ? (
+ mode === "exhausted" ? (
+ <SecondaryAction href={routeAsk}>
+ {useDifferentDestination}
+ </SecondaryAction>
+ ) : (
+ <TextActionLink href={routeAsk}>
+ {useDifferentDestination}
+ </TextActionLink>
+ )
+ ) : (
+ <p class="text-sm text-secondary dark:text-darkSecondary">
+ {destinationCannotChange}
+ </p>
+ )}
+
+ {mode === "exhausted" &&
+ exhaustedPrimary === "return" &&
+ returnUrl &&
+ returnLabel ? (
+ <PrimaryActionLink href={returnUrl}>{returnLabel}</PrimaryActionLink>
+ ) : undefined}
+ </div>
+ </section>
);
}
diff --git a/packages/challenger-webui/src/pages/Setup.tsx b/packages/challenger-webui/src/pages/Setup.tsx
@@ -199,7 +199,6 @@ export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode {
busyLabel={creatingSessionLabel}
running={start.running}
disabled={!startArgs}
- onClick={startArgs ? () => start.run(...startArgs) : undefined}
>
{createSessionLabel}
</ActionButton>