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:
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(