taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/challenger-webui/src/challenger-flow.test.ts | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/challenger-webui/src/components/VerificationUi.tsx | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/challenger-webui/src/pages/AnswerChallenge.tsx | 381+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mpackages/challenger-webui/src/pages/Setup.tsx | 1-
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>