taler-typescript-core

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

commit 52aab1ac24fe4df2e8a532d32c3480b4c7bf29ad
parent ad96fd0d35b06fc33541b61bc1e86f0143876647
Author: Florian Dold <dold@taler.net>
Date:   Sat, 29 Aug 2026 15:54:53 +0200

challenger web UI: clarify destination changes

Diffstat:
Mpackages/challenger-webui/src/Routing.tsx | 24+++++++++++++++++++++++-
Mpackages/challenger-webui/src/pages/AskChallenge.tsx | 429++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 374 insertions(+), 79 deletions(-)

diff --git a/packages/challenger-webui/src/Routing.tsx b/packages/challenger-webui/src/Routing.tsx @@ -46,6 +46,7 @@ const publicPages = { noinfo: urlPattern(/\/noinfo/, () => `#/noinfo`), authorize: urlPattern(/\/authorize/, () => `#/authorize`), ask: urlPattern(/\/ask/, () => `#/ask`), + change: urlPattern(/\/change/, () => `#/change`), answer: urlPattern(/\/answer/, () => `#/answer`), completed: urlPattern(/\/completed/, () => `#/completed`), setup: urlPattern<{ client: string }>( @@ -193,6 +194,27 @@ function PublicRouting(): VNode { /> ); } + case "change": { + const sessionId = getSession(location.params); + + if (!sessionId) { + return <MissingSessionParameters />; + } + return ( + <AskChallenge + session={sessionId} + changeOnly + focus + routeSolveChallenge={publicPages.answer} + onSendSuccesful={() => { + navigateTo(publicPages.answer.url({})); + }} + onComplete={() => { + navigateTo(publicPages.completed.url({})); + }} + /> + ); + } case "answer": { const sessionId = getSession(location.params); @@ -204,7 +226,7 @@ function PublicRouting(): VNode { <AnswerChallenge focus session={sessionId} - routeAsk={publicPages.ask} + routeAsk={publicPages.change} onComplete={() => { navigateTo(publicPages.completed.url({})); }} diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx @@ -34,7 +34,7 @@ import { useAsyncAction, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ActionButton, @@ -42,7 +42,7 @@ import { formatHttpErrorDetails, InlineNotice, LoadingState, - PrimaryActionLink, + SecondaryAction, TechnicalDetails, VerificationCard, } from "../components/VerificationUi.js"; @@ -55,9 +55,92 @@ type Props = { onComplete: () => void; session: SessionId; routeSolveChallenge: RouteDefinition<EmptyObject>; + changeOnly?: boolean; focus?: boolean; }; +export type AddressEntryMode = "initial" | "change" | "locked"; + +export function getAddressEntryMode( + status: Pick< + ChallengerApi.ChallengeStatus, + "last_address" | "fix_address" | "changes_left" + >, + changeOnly: boolean, +): AddressEntryMode { + if (!changeOnly) return "initial"; + if (!status.last_address) return "locked"; + if (status.fix_address || status.changes_left < 1) return "locked"; + return "change"; +} + +function addressFieldNames( + type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"], +): string[] { + switch (type) { + case "email": + return [TalerFormAttributes.CONTACT_EMAIL]; + case "phone": + return [TalerFormAttributes.CONTACT_PHONE]; + case "postal": + case "postal-ch": + return [ + TalerFormAttributes.CONTACT_NAME, + TalerFormAttributes.ADDRESS_LINES, + TalerFormAttributes.ADDRESS_COUNTRY, + ]; + default: + assertUnreachable(type); + } +} + +export function isSameAddressForType( + type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"], + previous: Record<string, string>, + candidate: Record<string, string>, +): boolean { + return addressFieldNames(type).every( + (field) => previous[field] === candidate[field], + ); +} + +export function canSubmitDestination( + mode: AddressEntryMode, + type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"], + previous: Record<string, string> | undefined, + candidate: Record<string, string> | undefined, +): boolean { + if (!candidate || mode === "locked") return false; + if (mode === "initial") return true; + return !!previous && !isSameAddressForType(type, previous, candidate); +} + +export function getAddressDisplayLines( + type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"], + address: Record<string, string>, + countryLabel?: string, +): string[] { + switch (type) { + case "email": + return [address[TalerFormAttributes.CONTACT_EMAIL]].filter( + (line): line is string => !!line, + ); + case "phone": + return [address[TalerFormAttributes.CONTACT_PHONE]].filter( + (line): line is string => !!line, + ); + case "postal": + case "postal-ch": + return [ + address[TalerFormAttributes.CONTACT_NAME], + ...(address[TalerFormAttributes.ADDRESS_LINES]?.split(/\r?\n/) ?? []), + countryLabel ?? address[TalerFormAttributes.ADDRESS_COUNTRY], + ].filter((line): line is string => !!line); + default: + assertUnreachable(type); + } +} + export function AskChallenge(props: Props): VNode { const { i18n } = useTranslationContext(); const { config } = useChallengerApiContext(); @@ -158,6 +241,7 @@ function AskChallengeInternal({ onComplete, routeSolveChallenge, session, + changeOnly = false, focus, lastStatus, }: Props & { lastStatus: ChallengerApi.ChallengeStatus }): VNode { @@ -175,12 +259,15 @@ function AskChallengeInternal({ initial[TalerFormAttributes.ADDRESS_COUNTRY] = "CH"; } + const mode = getAddressEntryMode(lastStatus, changeOnly); + const design = getFormDesignBasedOnAddressType( i18n, lang, config.address_type, config.restrictions ?? {}, lastStatus.fix_address, + mode === "change", ); const form = useForm(design, initial); @@ -195,12 +282,20 @@ function AskChallengeInternal({ form.status.status === "fail" ? undefined : (form.status.result as Record<string, string>); - - const info = lastStatus.fix_address ? lastStatus.last_address! : contact; - - // i18n.str`create challenge`, - const sendArgs = - form.status.errors || !info ? undefined : ([session.nonce, info] as const); + const previousAddress = lastStatus.last_address; + const unchanged = + mode === "change" && + !!previousAddress && + !!contact && + isSameAddressForType(config.address_type, previousAddress, contact); + const sendArgs = canSubmitDestination( + mode, + config.address_type, + previousAddress, + contact, + ) + ? ([session.nonce, contact!] as const) + : undefined; const send = useAsyncAction< Awaited<ReturnType<typeof lib.challenger.challenge>>, [string, Record<string, string>] @@ -245,30 +340,130 @@ function AskChallengeInternal({ // Translators: Description below the postal-verification heading explaining // that a physical letter will contain the one-time code. const postalDescription = i18n.str`We will send you a letter containing a one-time verification code.`; - const title = + // Translators: Heading shown after the user chooses to replace the email + // address that received the current verification code. + const changeEmailTitle = i18n.str`Change your email address`; + // Translators: Heading shown after the user chooses to replace the phone + // number that received the current verification code. + const changePhoneTitle = i18n.str`Change your phone number`; + // Translators: Heading shown after the user chooses to replace the postal + // address that received the current verification letter. + const changePostalTitle = i18n.str`Change your postal address`; + // Translators: Instruction above a replacement email-address form. A new + // verification code will be sent after the form is submitted. + const changeEmailDescription = i18n.str`Enter the email address where you want to receive a new verification code.`; + // Translators: Instruction above a replacement phone-number form. A new + // verification code will be sent after the form is submitted. + const changePhoneDescription = i18n.str`Enter the phone number where you want to receive a new verification code.`; + // Translators: Instruction above a replacement postal-address form. A new + // verification letter will be sent after the form is submitted. + const changePostalDescription = i18n.str`Enter the postal address where you want to receive a new verification code.`; + const initialTitle = config.address_type === "email" ? emailTitle : config.address_type === "phone" ? phoneTitle : postalTitle; - const description = + const initialDescription = config.address_type === "email" ? emailDescription : config.address_type === "phone" ? phoneDescription : postalDescription; - // Translators: "destination" means the email address, phone number, or - // postal address receiving the verification code. - const destinationLabel = i18n.str`Verification destination`; - // Translators: "destination" means the email address, phone number, or - // postal address receiving the verification code. - const oneChangeLeft = i18n.str`You can change the destination one more time.`; - // Translators: Instruction in a notice that an earlier code is still usable - // and can be entered instead of requesting another one. - const useExistingCodeDescription = i18n.str`Use the code you received to continue.`; - // Translators: Link that moves from address entry to the screen for entering - // a verification code that was already sent. - const enterCodeLabel = i18n.str`Enter verification code`; + const changeTitle = + config.address_type === "email" + ? changeEmailTitle + : config.address_type === "phone" + ? changePhoneTitle + : changePostalTitle; + const changeDescription = + config.address_type === "email" + ? changeEmailDescription + : config.address_type === "phone" + ? changePhoneDescription + : changePostalDescription; + // Translators: Label above the email address that received the current + // verification code, before the user enters a replacement. + const currentEmailLabel = i18n.str`Current email address`; + // Translators: Label above the phone number that received the current + // verification code, before the user enters a replacement. + const currentPhoneLabel = i18n.str`Current phone number`; + // Translators: Label above the postal address that received the current + // verification letter, before the user enters a replacement. + const currentPostalLabel = i18n.str`Current postal address`; + const currentAddressLabel = + config.address_type === "email" + ? currentEmailLabel + : config.address_type === "phone" + ? currentPhoneLabel + : currentPostalLabel; + // Translators: Section heading above the fields for a replacement postal + // address. The current postal address is displayed immediately before it. + const newPostalAddressLabel = i18n.str`New postal address`; + // Translators: Warning heading when the user can replace their email + // address, phone number, or postal address only once more. + const lastChangeTitle = i18n.str`Last available change`; + // Translators: Warning that submitting a replacement email address will use + // the final permitted address change for this verification. + const oneEmailChangeLeft = i18n.str`You can change the email address one more time.`; + // Translators: Warning that submitting a replacement phone number will use + // the final permitted address change for this verification. + const onePhoneChangeLeft = i18n.str`You can change the phone number one more time.`; + // Translators: Warning that submitting a replacement postal address will use + // the final permitted address change for this verification. + const onePostalChangeLeft = i18n.str`You can change the postal address one more time.`; + const oneChangeLeft = + config.address_type === "email" + ? oneEmailChangeLeft + : config.address_type === "phone" + ? onePhoneChangeLeft + : onePostalChangeLeft; + // Translators: Help text below a prefilled replacement-address form. The + // primary action stays disabled until at least one field is edited. + const unchangedAddressHelp = i18n.str`Change at least one field to continue.`; + // Translators: Supporting text beside the secondary action that returns to + // an earlier, still-usable verification code. %1$s is the email address, + // phone number, or postal addressee that received it. + const previousCodeStillValid = i18n.str`The previous code sent to ${prevAddr ?? ""} can still be used.`; + // Translators: Secondary link that cancels changing the email address, phone + // number, or postal address and returns to verification-code entry. + const backToCodeLabel = i18n.str`Back to code entry`; + // Translators: Warning heading shown when the verification no longer permits + // replacing its email address, phone number, or postal address. + const noChangesTitle = i18n.str`No changes remaining`; + // Translators: Page title when an email address is fixed or all permitted + // email-address changes have been used. + const emailLockedTitle = i18n.str`Email address cannot be changed`; + // Translators: Page title when a phone number is fixed or all permitted + // phone-number changes have been used. + const phoneLockedTitle = i18n.str`Phone number cannot be changed`; + // Translators: Page title when a postal address is fixed or all permitted + // postal-address changes have been used. + const postalLockedTitle = i18n.str`Postal address cannot be changed`; + const lockedTitle = + config.address_type === "email" + ? emailLockedTitle + : config.address_type === "phone" + ? phoneLockedTitle + : postalLockedTitle; + // Translators: Explanation that the email address for this verification is + // fixed or that all permitted email-address changes have been used. + const emailLockedDescription = i18n.str`This email address cannot be changed.`; + // Translators: Explanation that the phone number for this verification is + // fixed or that all permitted phone-number changes have been used. + const phoneLockedDescription = i18n.str`This phone number cannot be changed.`; + // Translators: Explanation that the postal address for this verification is + // fixed or that all permitted postal-address changes have been used. + const postalLockedDescription = i18n.str`This postal address cannot be changed.`; + const lockedDescription = + config.address_type === "email" + ? emailLockedDescription + : config.address_type === "phone" + ? phoneLockedDescription + : postalLockedDescription; + // Translators: Recovery instruction when neither the current verification + // code nor a replacement address can be used. + const restartDescription = i18n.str`Return to the application and start a new verification.`; // Translators: Error-notice heading after Challenger fails to send a // verification code. const sendFailureTitle = i18n.str`Code could not be sent`; @@ -278,68 +473,137 @@ function AskChallengeInternal({ // Translators: Primary button that sends a one-time verification code to the // entered email address, phone number, or postal address. const sendCodeLabel = i18n.str`Send verification code`; + // Translators: Primary button that sends a verification code to a replacement + // email address entered by the user. + const sendToNewEmailLabel = i18n.str`Send code to new email address`; + // Translators: Primary button that sends a verification code to a replacement + // phone number entered by the user. + const sendToNewPhoneLabel = i18n.str`Send code to new phone number`; + // Translators: Primary button that sends a verification letter to a + // replacement postal address entered by the user. + const sendToNewPostalLabel = i18n.str`Send code to new postal address`; + const changeActionLabel = + config.address_type === "email" + ? sendToNewEmailLabel + : config.address_type === "phone" + ? sendToNewPhoneLabel + : sendToNewPostalLabel; + const title = + mode === "initial" + ? initialTitle + : mode === "change" + ? changeTitle + : lockedTitle; + const description = + mode === "initial" + ? initialDescription + : mode === "change" + ? changeDescription + : undefined; + const countryCode = previousAddress?.[TalerFormAttributes.ADDRESS_COUNTRY]; + const countryLabel = countryCode + ? countryNameList(i18n).find((choice) => choice.value === countryCode) + ?.label + : undefined; + const currentAddressLines = previousAddress + ? getAddressDisplayLines(config.address_type, previousAddress, countryLabel) + : []; + const canUsePreviousCode = !!prevAddr && lastStatus.auth_attempts_left > 0; return ( <VerificationCard step="details" title={title} description={description}> - {prevAddr && lastStatus.auth_attempts_left > 0 ? ( - <div class="mb-6 space-y-3"> - <InlineNotice - // Translators: %1$s is the email address, phone number, or postal - // addressee to which the verification code was delivered. - title={i18n.str`A verification code was sent to ${prevAddr}`} - > - {useExistingCodeDescription} - </InlineNotice> - <PrimaryActionLink href={routeSolveChallenge.url({})}> - {enterCodeLabel} - </PrimaryActionLink> - </div> - ) : undefined} - - {lastStatus.fix_address && prevAddr ? ( + {previousAddress && mode !== "initial" ? ( <div class="rounded-lg border border-outlineVariant bg-secondaryContainer p-4 text-onSecondaryContainer dark:border-darkSecondaryContainer dark:bg-darkSecondaryContainer dark:text-darkOnSecondaryContainer"> - <p class="text-sm font-semibold">{destinationLabel}</p> - <p class="mt-1 break-words">{prevAddr}</p> + <p class="text-sm font-semibold">{currentAddressLabel}</p> + <div class="mt-1 break-words"> + {currentAddressLines.map((line, index) => ( + <p key={`${index}:${line}`}>{line}</p> + ))} + </div> </div> - ) : ( - <FormUI - design={design} - model={form.model} - onSubmit={sendArgs ? () => send.run(...sendArgs) : undefined} - /> - )} - - {lastStatus.changes_left === 1 ? ( - <p class="mt-3 text-sm font-semibold text-onWarningContainer dark:text-darkWarning"> - {oneChangeLeft} - </p> ) : undefined} - {actionError ? ( + {mode === "locked" ? ( <div class="mt-5"> - <InlineNotice tone="error" title={sendFailureTitle}> - {actionError} + <InlineNotice tone="warning" title={noChangesTitle}> + {lockedDescription} + {!canUsePreviousCode ? ( + <span> {restartDescription}</span> + ) : undefined} </InlineNotice> - {actionErrorDetails ? ( - <TechnicalDetails copyText={actionErrorDetails}> - {actionErrorDetails} - </TechnicalDetails> + {canUsePreviousCode ? ( + <div class="mt-4"> + <SecondaryAction href={routeSolveChallenge.url({})}> + {backToCodeLabel} + </SecondaryAction> + </div> ) : undefined} </div> - ) : undefined} + ) : ( + <> + {mode === "change" && lastStatus.changes_left === 1 ? ( + <div class="mt-5"> + <InlineNotice tone="warning" title={lastChangeTitle}> + {oneChangeLeft} + </InlineNotice> + </div> + ) : undefined} - {!lastStatus.fix_address || !prevAddr ? ( - <div class="mt-6"> - <ActionButton - busyLabel={sendingCodeLabel} - running={send.running} - disabled={!sendArgs} - onClick={sendArgs ? () => send.run(...sendArgs) : undefined} - > - {sendCodeLabel} - </ActionButton> - </div> - ) : undefined} + <div class={mode === "change" ? "mt-5" : undefined}> + {mode === "change" && + (config.address_type === "postal" || + config.address_type === "postal-ch") ? ( + <p class="mb-2 text-sm font-semibold">{newPostalAddressLabel}</p> + ) : undefined} + <FormUI + design={design} + model={form.model} + onSubmit={sendArgs ? () => send.run(...sendArgs) : undefined} + /> + </div> + + {unchanged ? ( + <p class="mt-3 text-sm text-secondary dark:text-darkSecondary"> + {unchangedAddressHelp} + </p> + ) : undefined} + + {actionError ? ( + <div class="mt-5"> + <InlineNotice tone="error" title={sendFailureTitle}> + {actionError} + </InlineNotice> + {actionErrorDetails ? ( + <TechnicalDetails copyText={actionErrorDetails}> + {actionErrorDetails} + </TechnicalDetails> + ) : undefined} + </div> + ) : undefined} + + <div class="mt-6"> + <ActionButton + busyLabel={sendingCodeLabel} + running={send.running} + disabled={!sendArgs} + onClick={sendArgs ? () => send.run(...sendArgs) : undefined} + > + {mode === "change" ? changeActionLabel : sendCodeLabel} + </ActionButton> + </div> + + {mode === "change" && canUsePreviousCode ? ( + <div class="mt-6 border-t border-outlineVariant pt-5 dark:border-darkSecondaryContainer"> + <p class="mb-3 text-sm text-secondary dark:text-darkSecondary"> + {previousCodeStillValid} + </p> + <SecondaryAction href={routeSolveChallenge.url({})}> + {backToCodeLabel} + </SecondaryAction> + </div> + ) : undefined} + </> + )} </VerificationCard> ); } @@ -502,7 +766,20 @@ function getFormDesignBasedOnAddressType( type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"], restrictions: Record<string, ChallengerApi.Restriction | undefined>, read_only: boolean, + changing: boolean, ): FormDesign { + // Translators: Label for the initial email-address input where the user + // wants to receive a verification code. + const emailLabel = i18n.str`Email`; + // Translators: Label for a prefilled email-address input that must be edited + // to choose a replacement destination. + const newEmailLabel = i18n.str`New email address`; + // Translators: Label for the initial phone-number input where the user wants + // to receive a verification code by SMS. + const phoneLabel = i18n.str`Phone`; + // Translators: Label for a prefilled phone-number input that must be edited + // to choose a replacement destination. + const newPhoneLabel = i18n.str`New phone number`; switch (type) { case "email": return { @@ -512,9 +789,7 @@ function getFormDesignBasedOnAddressType( type: "text", id: TalerFormAttributes.CONTACT_EMAIL, required: true, - // Translators: Label for the email-address input where the user - // wants to receive a verification code. - label: i18n.str`Email`, + label: changing ? newEmailLabel : emailLabel, disabled: read_only, validator(text) { const restriction = getRestriction( @@ -543,9 +818,7 @@ function getFormDesignBasedOnAddressType( type: "phone", id: TalerFormAttributes.CONTACT_PHONE, required: true, - // Translators: Label for the telephone-number input where the user - // wants to receive a verification code by SMS. - label: i18n.str`Phone`, + label: changing ? newPhoneLabel : phoneLabel, disabled: read_only, validator(text) { const restriction = getRestriction(