taler-typescript-core

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

commit d5bfe042cb166c258c9915ed6b5cd117c4b24356
parent 07b543e5a58ad2091b6abeabc54ef6cc9903f782
Author: Florian Dold <dold@taler.net>
Date:   Sat, 29 Aug 2026 14:58:20 +0200

challenger: polish tester and web UI feedback

Diffstat:
Mpackages/challenger-webui/src/app.tsx | 10+++++++---
Mpackages/challenger-webui/src/challenger-flow.test.ts | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx | 15+++++++++++----
Mpackages/challenger-webui/src/components/VerificationUi.tsx | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/challenger-webui/src/pages/AnswerChallenge.tsx | 52++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/challenger-webui/src/pages/AskChallenge.tsx | 33+++++++++++++++++++++++++--------
Mpackages/challenger-webui/src/pages/CallengeCompleted.tsx | 97++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mpackages/challenger-webui/src/pages/Frame.tsx | 4+++-
Mpackages/challenger-webui/src/pages/Setup.tsx | 13++++++++++++-
Mpackages/taler-harness/README-challenger-tester.md | 7++++---
Mpackages/taler-harness/src/challenger-tester.test.ts | 16++++++++++++++++
Mpackages/taler-harness/src/challenger-tester.ts | 15++++++++++++---
12 files changed, 432 insertions(+), 39 deletions(-)

diff --git a/packages/challenger-webui/src/app.tsx b/packages/challenger-webui/src/app.tsx @@ -40,6 +40,7 @@ import { revalidateChallengeSession } from "./hooks/challenge.js"; import { strings } from "./i18n/strings.js"; import { ActionButton, + formatErrorDetails, InlineNotice, LoadingState, TechnicalDetails, @@ -190,7 +191,9 @@ function ChallengerConnectionStatus({ <InlineNotice tone="error" title={incompatibleTitle}> {incompatibleDescription} </InlineNotice> - <TechnicalDetails>{versionDetails}</TechnicalDetails> + <TechnicalDetails copyText={versionDetails}> + {versionDetails} + </TechnicalDetails> </VerificationCard> ); } @@ -206,6 +209,7 @@ function ChallengerConnectionStatus({ const connectingLabel = i18n.str`Connecting…`; // Translators: Button that manually retries the connection to Challenger. const retryLabel = i18n.str`Try again`; + const technicalDetails = formatErrorDetails(status.error); return ( <VerificationCard title={unavailableTitle}> <InlineNotice tone="error" title={connectionFailureTitle}> @@ -216,8 +220,8 @@ function ChallengerConnectionStatus({ {retryLabel} </ActionButton> </div> - <TechnicalDetails> - {status.error.errorDetail.code}: {status.error.message} + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} </TechnicalDetails> </VerificationCard> ); diff --git a/packages/challenger-webui/src/challenger-flow.test.ts b/packages/challenger-webui/src/challenger-flow.test.ts @@ -8,7 +8,11 @@ import { getLocalizedRestrictionHint, INT_PHONE_REGEX, } from "./pages/AskChallenge.js"; -import { safeRedirectURL } from "./pages/CallengeCompleted.js"; +import { + ReturnTimerDriver, + safeRedirectURL, + scheduleAutomaticReturn, +} from "./pages/CallengeCompleted.js"; import { formatCountdown } from "./pages/AnswerChallenge.js"; function status( @@ -33,9 +37,30 @@ test("challenge status routes to the correct next page", () => { ); assert.equal(getChallengeNextPage(status()), "ask"); assert.equal( - getChallengeNextPage(status({ changes_left: 0, auth_attempts_left: 0 })), + getChallengeNextPage( + status({ + changes_left: 0, + auth_attempts_left: 0, + pin_transmissions_left: 0, + }), + ), "exhausted", ); + assert.equal( + getChallengeNextPage( + status({ + fix_address: true, + last_address: { + [TalerFormAttributes.CONTACT_EMAIL]: "dold@taler.net", + read_only: true, + }, + changes_left: 0, + auth_attempts_left: 0, + pin_transmissions_left: 3, + }), + ), + "ask", + ); }); test("OAuth state is optional and persisted state is session-scoped", () => { @@ -89,3 +114,109 @@ test("resend countdown uses a stable minute and second format", () => { assert.equal(formatCountdown(9), "0:09"); assert.equal(formatCountdown(61), "1:01"); }); + +test("successful verification counts down to one deadline before returning", () => { + let now = 0; + let callback: (() => void) | undefined; + let delay: number | undefined; + let redirectedTo: string | undefined; + const seconds: number[] = []; + const timerDriver: ReturnTimerDriver = { + now: () => now, + schedule: (next, delayMs) => { + callback = next; + delay = delayMs; + return 7; + }, + cancel: () => assert.fail("completed timer should not be cancelled"), + }; + const cleanup = scheduleAutomaticReturn( + "https://client.example/complete", + (url) => { + redirectedTo = url; + }, + (remaining) => seconds.push(remaining), + timerDriver, + ); + + assert.equal(delay, 1000); + assert.deepEqual(seconds, [3]); + assert.equal(redirectedTo, undefined); + + now = 1000; + callback?.(); + assert.equal(delay, 1000); + assert.deepEqual(seconds, [3, 2]); + + now = 2000; + callback?.(); + assert.equal(delay, 1000); + assert.deepEqual(seconds, [3, 2, 1]); + + now = 3000; + callback?.(); + assert.equal(redirectedTo, "https://client.example/complete"); + assert.deepEqual(seconds, [3, 2, 1]); + cleanup(); +}); + +test("successful verification countdown recovers from a delayed callback", () => { + let now = 0; + let callback: (() => void) | undefined; + let delay: number | undefined; + const seconds: number[] = []; + const timerDriver: ReturnTimerDriver = { + now: () => now, + schedule: (next, delayMs) => { + callback = next; + delay = delayMs; + return 8; + }, + cancel: () => undefined, + }; + scheduleAutomaticReturn( + "https://client.example/complete", + () => undefined, + (remaining) => seconds.push(remaining), + timerDriver, + ); + + assert.equal(delay, 1000); + assert.deepEqual(seconds, [3]); + + now = 1600; + callback?.(); + assert.deepEqual(seconds, [3, 2]); + assert.equal(delay, 400); + + now = 2000; + callback?.(); + assert.deepEqual(seconds, [3, 2, 1]); + assert.equal(delay, 1000); +}); + +test("successful verification cancels its pending return", () => { + let callback: (() => void) | undefined; + let redirected = false; + const cancelledTimers: number[] = []; + const cleanup = scheduleAutomaticReturn( + "https://client.example/complete", + () => { + redirected = true; + }, + () => undefined, + { + now: () => 0, + schedule: (next) => { + callback = next; + return 9; + }, + cancel: (timer) => cancelledTimers.push(timer), + }, + ); + + cleanup(); + callback?.(); + assert.deepEqual(cancelledTimers, [9]); + assert.equal(redirected, false); +}); diff --git a/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx @@ -18,6 +18,8 @@ import { useChallengeSession } from "../hooks/challenge.js"; import { SessionId } from "../hooks/session.js"; import { ActionButton, + formatErrorDetails, + formatHttpErrorDetails, InlineNotice, LoadingState, SecondaryAction, @@ -32,7 +34,8 @@ export function getChallengeNextPage( ): ChallengeNextPage { if (status.solved) return "completed"; if (status.last_address && status.auth_attempts_left > 0) return "answer"; - if (status.changes_left > 0) return "ask"; + if (status.pin_transmissions_left > 0 || status.changes_left > 0) + return "ask"; return "exhausted"; } @@ -97,6 +100,7 @@ export function CheckChallengeIsUpToDate({ const loadingLabel = i18n.str`Loading…`; // Translators: Button that retries a failed request to Challenger. const retryLabel = i18n.str`Try again`; + const technicalDetails = formatErrorDetails(result); return ( <VerificationCard title={unavailableTitle}> <InlineNotice tone="error" title={loadFailureTitle}> @@ -112,8 +116,8 @@ export function CheckChallengeIsUpToDate({ {retryLabel} </ActionButton> </div> - <TechnicalDetails> - {result.errorDetail.code}: {result.message} + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} </TechnicalDetails> </VerificationCard> ); @@ -153,6 +157,7 @@ export function CheckChallengeIsUpToDate({ : result.case === HttpStatusCode.InternalServerError ? temporaryFailureMessage : invalidRequestMessage; + const technicalDetails = formatHttpErrorDetails(result); return ( <VerificationCard title={title}> <InlineNotice tone="error" title={title}> @@ -173,7 +178,9 @@ export function CheckChallengeIsUpToDate({ {returnLabel} </SecondaryAction> </div> - <TechnicalDetails>HTTP {result.case}</TechnicalDetails> + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} + </TechnicalDetails> </VerificationCard> ); } diff --git a/packages/challenger-webui/src/components/VerificationUi.tsx b/packages/challenger-webui/src/components/VerificationUi.tsx @@ -7,9 +7,10 @@ Foundation; either version 3, or (at your option) any later version. */ -import { TranslatedString } from "@gnu-taler/taler-util"; +import { TalerError, TranslatedString } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, VNode, h } from "preact"; +import { useState } from "preact/hooks"; export type VerificationStep = "details" | "code" | "complete"; @@ -219,19 +220,88 @@ export function InlineNotice({ export function TechnicalDetails({ children, + copyText, }: { children: ComponentChildren; + copyText?: string; }): VNode { const { i18n } = useTranslationContext(); + const [copied, setCopied] = useState(false); // Translators: Label on an expandable section containing diagnostic // information intended mainly for support and developers. const summaryLabel = i18n.str`Technical details`; + // Translators: Button that copies diagnostic error details to the clipboard. + const copyLabel = i18n.str`Copy details`; + // Translators: Brief confirmation replacing “Copy details” after the + // diagnostic information has been copied to the clipboard. + const copiedLabel = i18n.str`Copied`; return ( <details class="mt-4 text-sm text-secondary dark:text-darkSecondary"> <summary class="cursor-pointer font-semibold">{summaryLabel}</summary> <div class="mt-2 break-words rounded-lg border border-outlineVariant p-3 font-mono text-xs dark:border-darkSecondaryContainer"> - {children} + <div class="whitespace-pre-wrap">{children}</div> + {copyText ? ( + <button + type="button" + class="mt-3 min-h-9 rounded-md border border-outline px-3 py-1.5 font-sans text-xs font-semibold text-onBackground hover:bg-secondaryContainer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-darkSecondary dark:text-darkOnBackground dark:hover:bg-darkSecondaryContainer dark:focus-visible:outline-darkPrimary" + onClick={async () => { + try { + await copyToClipboard(copyText); + setCopied(true); + } catch { + setCopied(false); + } + }} + > + <span aria-live="polite">{copied ? copiedLabel : copyLabel}</span> + </button> + ) : undefined} </div> </details> ); } + +async function copyToClipboard(text: string): Promise<void> { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const input = document.createElement("textarea"); + input.value = text; + input.setAttribute("readonly", ""); + input.style.position = "fixed"; + input.style.opacity = "0"; + document.body.appendChild(input); + input.select(); + try { + if (!document.execCommand("copy")) { + throw new Error("clipboard copy command failed"); + } + } finally { + input.remove(); + } +} + +export function formatHttpErrorDetails(failure: { + case: number; + detail?: unknown; +}): string { + return JSON.stringify( + { + httpStatus: failure.case, + error: failure.detail ?? null, + }, + undefined, + 2, + ); +} + +export function formatErrorDetails(error: unknown): string { + if (error instanceof TalerError) { + return JSON.stringify(error.errorDetail, undefined, 2); + } + if (error instanceof Error) { + return error.stack ?? error.message; + } + return String(error); +} diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx @@ -30,6 +30,8 @@ import { import { SessionId, useSessionState } from "../hooks/session.js"; import { ActionButton, + formatErrorDetails, + formatHttpErrorDetails, InlineNotice, LoadingState, SecondaryAction, @@ -71,6 +73,10 @@ export function AnswerChallenge({ 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 pinInput = useRef<HTMLInputElement | null>(null); const { result, retry, isRefreshing } = useChallengeSession(session); @@ -126,6 +132,9 @@ export function AnswerChallenge({ // Translators: Error-notice heading shown when Challenger rejects an entered // verification code. const codeNotAcceptedTitle = i18n.str`Code not accepted`; + // Translators: Error-notice heading after Challenger fails to resend a + // verification code. + const codeSendFailureTitle = i18n.str`Code could not be sent`; // Translators: Warning shown when only one code-entry attempt remains in the // current verification session. const lastAttemptWarning = i18n.str`This is your last attempt with the current code.`; @@ -142,6 +151,7 @@ export function AnswerChallenge({ onResult(operationResult) { if (operationResult.type === "ok") { setActionError(undefined); + setActionErrorDetails(undefined); if (operationResult.body.type === "completed") { completed(operationResult.body); onComplete(); @@ -152,11 +162,15 @@ export function AnswerChallenge({ return; } setActionError(challengeFailureMessage(i18n, operationResult)); + setActionErrorDetails(formatHttpErrorDetails(operationResult)); + setActionErrorSource("delivery"); }, - onError() { + onError(error) { setActionError( i18n.str`We could not contact the verification service. Check your connection and try again.`, ); + setActionErrorDetails(formatErrorDetails(error)); + setActionErrorSource("delivery"); }, }); @@ -168,12 +182,15 @@ export function AnswerChallenge({ if (operationResult.type === "ok") { if (operationResult.body.type === "completed") { setActionError(undefined); + setActionErrorDetails(undefined); completed(operationResult.body); onComplete(); return; } setPin(""); setActionError(incorrectCodeMessage); + setActionErrorDetails(undefined); + setActionErrorSource("code"); pinInput.current?.focus(); void revalidateChallengeSession(); return; @@ -182,16 +199,22 @@ export function AnswerChallenge({ if (pending) { setPin(""); setActionError(incorrectCodeMessage); + setActionErrorDetails(undefined); + setActionErrorSource("code"); pinInput.current?.focus(); void revalidateChallengeSession(); return; } setActionError(solveFailureMessage(i18n, operationResult.case)); + setActionErrorDetails(formatHttpErrorDetails(operationResult)); + setActionErrorSource("code"); }, - onError() { + onError(error) { setActionError( i18n.str`We could not contact the verification service. Check your connection and try again.`, ); + setActionErrorDetails(formatErrorDetails(error)); + setActionErrorSource("code"); }, }); @@ -199,6 +222,7 @@ export function AnswerChallenge({ return <LoadingState title={i18n.str`Loading verification…`} />; } if (result instanceof TalerError) { + const technicalDetails = formatErrorDetails(result); return ( <VerificationCard title={i18n.str`Verification service unavailable`}> <InlineNotice @@ -219,13 +243,14 @@ export function AnswerChallenge({ <i18n.Translate>Try again</i18n.Translate> </ActionButton> </div> - <TechnicalDetails> - {result.errorDetail.code}: {result.message} + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} </TechnicalDetails> </VerificationCard> ); } if (result.type === "fail") { + const technicalDetails = formatHttpErrorDetails(result); return ( <VerificationCard title={i18n.str`Could not load verification details`}> <InlineNotice tone="error" title={i18n.str`Verification unavailable`}> @@ -233,7 +258,9 @@ export function AnswerChallenge({ ? i18n.str`This verification has expired. Return to the application and start again.` : rejectedRequestMessage} </InlineNotice> - <TechnicalDetails>HTTP {result.case}</TechnicalDetails> + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} + </TechnicalDetails> </VerificationCard> ); } @@ -330,14 +357,27 @@ export function AnswerChallenge({ onInput={(event) => { setPin(event.currentTarget.value); setActionError(undefined); + setActionErrorDetails(undefined); }} /> {actionError ? ( <div id="verification-code-error" class="mt-4"> - <InlineNotice tone="error" title={codeNotAcceptedTitle}> + <InlineNotice + tone="error" + title={ + actionErrorSource === "delivery" + ? codeSendFailureTitle + : codeNotAcceptedTitle + } + > {actionError} </InlineNotice> + {actionErrorDetails ? ( + <TechnicalDetails copyText={actionErrorDetails}> + {actionErrorDetails} + </TechnicalDetails> + ) : undefined} </div> ) : undefined} diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx @@ -38,6 +38,8 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ActionButton, + formatErrorDetails, + formatHttpErrorDetails, InlineNotice, LoadingState, PrimaryActionLink, @@ -71,6 +73,7 @@ export function AskChallenge(props: Props): VNode { return <LoadingState title={loadingContactTitle} />; } if (result instanceof TalerError) { + const technicalDetails = formatErrorDetails(result); return ( <VerificationCard title={i18n.str`Verification service unavailable`}> <InlineNotice tone="error" title={loadFailureTitle}> @@ -88,8 +91,8 @@ export function AskChallenge(props: Props): VNode { <i18n.Translate>Try again</i18n.Translate> </ActionButton> </div> - <TechnicalDetails> - {result.errorDetail.code}: {result.message} + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} </TechnicalDetails> </VerificationCard> ); @@ -109,6 +112,7 @@ export function AskChallenge(props: Props): VNode { // Translators: Generic explanation when Challenger rejects the request to // load a verification session. const rejectedDescription = i18n.str`The service rejected this verification request.`; + const technicalDetails = formatHttpErrorDetails(result); return ( <VerificationCard title={detailsFailureTitle}> <InlineNotice tone="error" title={unavailableTitle}> @@ -118,7 +122,9 @@ export function AskChallenge(props: Props): VNode { ? exhaustedDescription : rejectedDescription} </InlineNotice> - <TechnicalDetails>HTTP {result.case}</TechnicalDetails> + <TechnicalDetails copyText={technicalDetails}> + {technicalDetails} + </TechnicalDetails> </VerificationCard> ); } @@ -138,7 +144,9 @@ export function AskChallenge(props: Props): VNode { <InlineNotice tone="error" title={configurationErrorTitle}> {configurationErrorDescription} </InlineNotice> - <TechnicalDetails>{invalidRule}</TechnicalDetails> + <TechnicalDetails copyText={invalidRule}> + {invalidRule} + </TechnicalDetails> </VerificationCard> ); } @@ -157,6 +165,7 @@ function AskChallengeInternal({ const { lib, config } = useChallengerApiContext(); const { i18n, lang } = useTranslationContext(); const [actionError, setActionError] = useState<TranslatedString>(); + const [actionErrorDetails, setActionErrorDetails] = useState<string>(); // Translators: Network-error message shown after an attempt to send a // verification code through Challenger. const connectionFailureMessage = i18n.str`We could not contact the verification service. Check your connection and try again.`; @@ -199,6 +208,7 @@ function AskChallengeInternal({ onResult(result) { if (result.type === "ok") { setActionError(undefined); + setActionErrorDetails(undefined); if (result.body.type === "completed") { completed(result.body); onComplete(); @@ -209,9 +219,11 @@ function AskChallengeInternal({ return; } setActionError(challengeFailureMessage(i18n, result)); + setActionErrorDetails(formatHttpErrorDetails(result)); }, - onError() { + onError(error) { setActionError(connectionFailureMessage); + setActionErrorDetails(formatErrorDetails(error)); }, }); @@ -308,6 +320,11 @@ function AskChallengeInternal({ <InlineNotice tone="error" title={sendFailureTitle}> {actionError} </InlineNotice> + {actionErrorDetails ? ( + <TechnicalDetails copyText={actionErrorDetails}> + {actionErrorDetails} + </TechnicalDetails> + ) : undefined} </div> ) : undefined} @@ -451,9 +468,9 @@ function getRestriction( ): { regex: undefined | RegExp; hint: TranslatedString } { const regexText = serverConfig && serverConfig.regex ? serverConfig.regex : undefined; - // Translators: Fallback validation message below an address form field when - // Challenger does not provide a more specific localized restriction hint. - const invalidFieldMessage = i18n.str`Invalid field`; + // Translators: Actionable fallback validation message below an address form + // field when Challenger does not provide a more specific localized hint. + const invalidFieldMessage = i18n.str`Enter a valid value.`; const hint = (getLocalizedRestrictionHint(serverConfig, lang) ?? invalidFieldMessage) as TranslatedString; diff --git a/packages/challenger-webui/src/pages/CallengeCompleted.tsx b/packages/challenger-webui/src/pages/CallengeCompleted.tsx @@ -8,7 +8,7 @@ */ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useEffect } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { InlineNotice, PrimaryActionLink, @@ -17,11 +17,73 @@ import { } from "../components/VerificationUi.js"; import { SessionId, useSessionState } from "../hooks/session.js"; +const AUTOMATIC_RETURN_DELAY_MS = 3000; +const AUTOMATIC_RETURN_DELAY_SECONDS = AUTOMATIC_RETURN_DELAY_MS / 1000; + +export interface ReturnTimerDriver { + now: () => number; + schedule: (callback: () => void, delayMs: number) => number; + cancel: (timer: number) => void; +} + +function browserReturnTimerDriver(): ReturnTimerDriver { + return { + now: () => window.performance.now(), + schedule: (callback, delayMs) => window.setTimeout(callback, delayMs), + cancel: (timer) => window.clearTimeout(timer), + }; +} + +export function scheduleAutomaticReturn( + targetUrl: string, + replace: (url: string) => void, + updateCountdown: (secondsRemaining: number) => void, + timerDriver: ReturnTimerDriver = browserReturnTimerDriver(), +): () => void { + const deadline = timerDriver.now() + AUTOMATIC_RETURN_DELAY_MS; + let active = true; + let pendingTimer: number | undefined; + let lastSecondsRemaining: number | undefined; + + const update = (): void => { + pendingTimer = undefined; + if (!active) return; + + const millisecondsRemaining = deadline - timerDriver.now(); + if (millisecondsRemaining <= 0) { + active = false; + replace(targetUrl); + return; + } + + const secondsRemaining = Math.ceil(millisecondsRemaining / 1000); + if (secondsRemaining !== lastSecondsRemaining) { + lastSecondsRemaining = secondsRemaining; + updateCountdown(secondsRemaining); + } + + const nextSecondBoundary = + millisecondsRemaining - (secondsRemaining - 1) * 1000; + pendingTimer = timerDriver.schedule(update, nextSecondBoundary); + }; + + update(); + return () => { + active = false; + if (pendingTimer !== undefined) { + timerDriver.cancel(pendingTimer); + } + }; +} + export function ChallengeCompleted({ session }: { session: SessionId }): VNode { const { state } = useSessionState(session); const { i18n } = useTranslationContext(); const completedURL = safeRedirectURL(state?.completedURL); const returnURL = safeRedirectURL(session.redirectURL); + const [secondsRemaining, setSecondsRemaining] = useState( + AUTOMATIC_RETURN_DELAY_SECONDS, + ); const destination = completedURL ? new URL(completedURL).host : undefined; // Translators: %1$s is the host name of the application that requested // verification. @@ -39,10 +101,24 @@ export function ChallengeCompleted({ session }: { session: SessionId }): VNode { const manualReturnDescription = i18n.str`Return to the application that started this verification.`; // Translators: Confirmation text shown immediately before returning to the // application that requested verification. - const continueDescription = i18n.str`You can safely continue to the application that requested the verification.`; + const continueDescription = i18n.str`Use the button below to continue immediately.`; + // Translators: %1$s is the host name of the application that requested + // verification. This sentence appears above a live redirect countdown. + const redirectDescription = i18n.str`You will be redirected to ${destination} automatically.`; + // Translators: Live countdown shown during the final second before the + // browser returns to the application that requested verification. + const redirectInOneSecond = i18n.str`Redirecting in 1 second…`; + // Translators: %1$s is the live number of seconds before the browser returns + // to the application that requested verification. + const redirectInSeveralSeconds = i18n.str`Redirecting in ${secondsRemaining} seconds…`; useEffect(() => { - if (completedURL) window.location.replace(completedURL); + if (!completedURL) return; + return scheduleAutomaticReturn( + completedURL, + (url) => window.location.replace(url), + setSecondsRemaining, + ); }, [completedURL]); if (!completedURL) { @@ -70,9 +146,18 @@ export function ChallengeCompleted({ session }: { session: SessionId }): VNode { step="complete" title={completeTitle} description={ - // Translators: %1$s is the host name of the application that requested - // verification; the browser is about to redirect there. - <i18n.Translate>Returning you to {destination}…</i18n.Translate> + <span> + <span class="block">{redirectDescription}</span> + <span + class="mt-1 block min-h-7 whitespace-nowrap font-semibold tabular-nums" + aria-live="polite" + aria-atomic="true" + > + {secondsRemaining === 1 + ? redirectInOneSecond + : redirectInSeveralSeconds} + </span> + </span> } > <InlineNotice diff --git a/packages/challenger-webui/src/pages/Frame.tsx b/packages/challenger-webui/src/pages/Frame.tsx @@ -43,7 +43,9 @@ export function Frame({ children }: { children: ComponentChildren }): VNode { {productAreaLabel} </span> </div> - <LangSelector type="icon" /> + <div class="relative"> + <LangSelector type="icon" /> + </div> </div> </header> diff --git a/packages/challenger-webui/src/pages/Setup.tsx b/packages/challenger-webui/src/pages/Setup.tsx @@ -24,7 +24,10 @@ import { useState } from "preact/hooks"; import { safeToURL } from "../Routing.js"; import { ActionButton, + formatErrorDetails, + formatHttpErrorDetails, InlineNotice, + TechnicalDetails, VerificationCard, } from "../components/VerificationUi.js"; import { doAutoFocus, undefinedIfEmpty } from "./AnswerChallenge.js"; @@ -42,6 +45,7 @@ export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode { const [password, setPassword] = useState(secret ?? ""); const [url, setUrl] = useState(redirectURL?.href ?? ""); const [actionError, setActionError] = useState<TranslatedString>(); + const [actionErrorDetails, setActionErrorDetails] = useState<string>(); // Translators: OAuth client credential used by a developer; this is not the // end user's password. const clientPasswordLabel = i18n.str`Client password`; @@ -97,6 +101,7 @@ export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode { ? unrecognizedClientMessage : setupFailureMessage, ); + setActionErrorDetails(formatHttpErrorDetails(result)); return; } const redirect = new URL(window.location.href); @@ -107,8 +112,9 @@ export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode { redirect.hash = "/ask"; window.location.href = redirect.href; }, - onError() { + onError(error) { setActionError(connectionFailureMessage); + setActionErrorDetails(formatErrorDetails(error)); }, }); @@ -179,6 +185,11 @@ export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode { <InlineNotice tone="error" title={sessionNotCreatedTitle}> {actionError} </InlineNotice> + {actionErrorDetails ? ( + <TechnicalDetails copyText={actionErrorDetails}> + {actionErrorDetails} + </TechnicalDetails> + ) : undefined} </div> ) : undefined} diff --git a/packages/taler-harness/README-challenger-tester.md b/packages/taler-harness/README-challenger-tester.md @@ -109,9 +109,10 @@ because it is also used in the OAuth callback URL. even when the simulated delivery fails. 6. For a successful delivery, return to the tester tab and copy the code from **Delivered challenges**. The table also shows the full message, address, - and helper result. It recognizes plain eight-digit codes, `1234-5678`, and - `T-1234-5678`; if a custom template uses another format, copy the code from - the displayed message. + and helper result. It recognizes Challenger's one-to-eight-digit TAN after a + `TAN`, `PIN`, or `verification code` label, as well as plain eight-digit + codes, `1234-5678`, and `T-1234-5678`. If a custom template uses another + format, copy the code from the displayed message. 7. Submit the code in Challenger. The callback exchanges the authorization code, calls `/info`, and shows a compact success or failure page with a link back to the tester dashboard. The dashboard separately updates the diff --git a/packages/taler-harness/src/challenger-tester.test.ts b/packages/taler-harness/src/challenger-tester.test.ts @@ -25,6 +25,7 @@ import { URLSearchParams as TalerUrlSearchParams } from "@gnu-taler/taler-util"; import { createPlatformHttpLib } from "@gnu-taler/taler-util/http"; import { createChallengerAdminRegistrar, + extractChallengeCode, formatChallengerTesterCommandError, runChallengerTesterHelper, startChallengerTester, @@ -37,6 +38,21 @@ const DEFINED_HELPER_EXIT_CODES = [ 50, ]; +test("challenge codes are extracted from default and custom messages", () => { + assert.equal( + extractChallengeCode( + "Please enter the TAN 1234567 to verify your address.", + ), + "1234567", + ); + assert.equal( + extractChallengeCode("Your Challenger verification code is T-1234-5678."), + "12345678", + ); + assert.equal(extractChallengeCode("Standalone code: 42"), "42"); + assert.equal(extractChallengeCode("Visit http://localhost:8080/"), undefined); +}); + function readRequestBody(request: IncomingMessage): Promise<string> { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/packages/taler-harness/src/challenger-tester.ts b/packages/taler-harness/src/challenger-tester.ts @@ -261,9 +261,18 @@ function parseAddressJson(rawAddress: string): Record<string, unknown> { return address as Record<string, unknown>; } -function extractChallengeCode(message: string): string | undefined { - const match = /(?:T-)?([0-9]{8}|[0-9]{4}-[0-9]{4})/.exec(message); - return match?.[1].replace("-", ""); +export function extractChallengeCode(message: string): string | undefined { + const labeled = + /\b(?:TAN|PIN|verification\s+code|code)\b(?:\s+is)?\s*[:#=-]?\s*(?:T-)?([0-9]{4}-[0-9]{4}|[0-9]{1,8})\b/i.exec( + message, + ); + if (labeled) return labeled[1].replace("-", ""); + + // Without a label, only accept the unambiguous formats that were supported + // originally. This avoids mistaking dates, ports, or URL components in a + // custom message template for the TAN. + const standalone = /(?:T-)?([0-9]{4}-[0-9]{4}|[0-9]{8})\b/.exec(message); + return standalone?.[1].replace("-", ""); } function normalizeListenHost(host: string): string {