taler-typescript-core

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

commit 3ac3d3f882ef78c9dce260dafcb5eff213f12b78
parent 4a7596b72735efef4b745018e51100a523dcf22b
Author: Florian Dold <dold@taler.net>
Date:   Sat, 29 Aug 2026 14:38:18 +0200

challenger web UI: redesign address verification flow

Diffstat:
Mpackages/challenger-webui/src/Routing.tsx | 51+++++++++++++++++++++++++++++++++++----------------
Mpackages/challenger-webui/src/app.tsx | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mpackages/challenger-webui/src/challenger-flow.test.ts | 7+++++++
Mpackages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx | 223+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Apackages/challenger-webui/src/components/VerificationUi.tsx | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/challenger-webui/src/context/preferences.ts | 2++
Mpackages/challenger-webui/src/hooks/challenge.ts | 8+++++++-
Mpackages/challenger-webui/src/pages/AnswerChallenge.tsx | 785++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/challenger-webui/src/pages/AskChallenge.tsx | 544++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpackages/challenger-webui/src/pages/CallengeCompleted.tsx | 110++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/challenger-webui/src/pages/Frame.tsx | 160+++++++++++++++++++------------------------------------------------------------
Mpackages/challenger-webui/src/pages/NonceNotFound.tsx | 10++++++++--
Mpackages/challenger-webui/src/pages/Setup.tsx | 269++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/challenger-webui/src/scss/main.css | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/challenger-webui/tailwind.config.js | 315+++++++++++++++++--------------------------------------------------------------
Mpackages/taler-util/src/http-client/challenger.test.ts | 49++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/taler-util/src/http-client/challenger.ts | 24++++++++++++++++++++++++
Mpackages/web-util/src/context/challenger-api.ts | 106++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
18 files changed, 1781 insertions(+), 1306 deletions(-)

diff --git a/packages/challenger-webui/src/Routing.tsx b/packages/challenger-webui/src/Routing.tsx @@ -24,10 +24,11 @@ import { VNode, h } from "preact"; import { assertUnreachable } from "@gnu-taler/taler-util"; import { CheckChallengeIsUpToDate } from "./components/CheckChallengeIsUpToDate.js"; +import { InlineNotice, VerificationCard } from "./components/VerificationUi.js"; import { SessionId } from "./hooks/session.js"; import { AnswerChallenge } from "./pages/AnswerChallenge.js"; import { AskChallenge } from "./pages/AskChallenge.js"; -import { CallengeCompleted } from "./pages/CallengeCompleted.js"; +import { ChallengeCompleted } from "./pages/CallengeCompleted.js"; import { Frame } from "./pages/Frame.js"; import { Setup } from "./pages/Setup.js"; @@ -95,6 +96,10 @@ function PublicRouting(): VNode { const loc = useCurrentLocation(publicPages); const { i18n } = useTranslationContext(); const { navigateTo } = useNavigationContext(); + // Translators: "attempts" are tries to enter a verification code; "address + // changes" include changing an email address, phone number, or postal + // address. + const exhaustedDescription = i18n.str`No verification attempts or address changes remain.`; const location: typeof loc = loc.name === undefined @@ -106,12 +111,21 @@ function PublicRouting(): VNode { switch (location.name) { case "noinfo": { + // Translators: Page title shown when a verification URL does not contain + // enough session information to identify anything to verify. + const unavailableTitle = i18n.str`Verification information unavailable`; + // Translators: Warning heading for a verification URL that contains no + // usable verification request. + const nothingToVerifyTitle = i18n.str`Nothing to verify`; + // Translators: Recovery instruction for a verification URL with no usable + // session information. + const restartDescription = i18n.str`Return to the application that sent you here and start the verification again.`; return ( - <div> - <i18n.Translate> - No challenge information is available. - </i18n.Translate> - </div> + <VerificationCard title={unavailableTitle}> + <InlineNotice tone="warning" title={nothingToVerifyTitle}> + {restartDescription} + </InlineNotice> + </VerificationCard> ); } case "setup": { @@ -155,9 +169,7 @@ function PublicRouting(): VNode { } }} > - <i18n.Translate> - No verification attempts or address changes remain. - </i18n.Translate> + {exhaustedDescription} </CheckChallengeIsUpToDate> ); } @@ -202,7 +214,7 @@ function PublicRouting(): VNode { case "completed": { const sessionId = getSession(location.params); if (!sessionId) return <MissingSessionParameters />; - return <CallengeCompleted session={sessionId} />; + return <ChallengeCompleted session={sessionId} />; } default: assertUnreachable(location); @@ -219,12 +231,19 @@ function safeDecodeURIComponent(value: string): string { function MissingSessionParameters(): VNode { const { i18n } = useTranslationContext(); + // Translators: Page title for a malformed verification URL missing required + // Challenger session parameters. + const invalidLinkTitle = i18n.str`Invalid verification link`; + // Translators: Error-notice heading when a verification URL omits required + // session parameters. + const missingInformationTitle = i18n.str`Required information is missing`; + // Translators: Recovery instruction for a malformed verification URL. + const restartDescription = i18n.str`Return to the application that sent you here and start the verification again.`; return ( - <div> - <i18n.Translate> - The application needs to be loaded with client_id, redirect_uri and - nonce request parameters. One or more are missing or invalid. - </i18n.Translate> - </div> + <VerificationCard title={invalidLinkTitle}> + <InlineNotice tone="error" title={missingInformationTitle}> + {restartDescription} + </InlineNotice> + </VerificationCard> ); } diff --git a/packages/challenger-webui/src/app.tsx b/packages/challenger-webui/src/app.tsx @@ -25,10 +25,11 @@ import { import { BrowserHashNavigationProvider, ChallengerApiProvider, - Loading, + type ChallengerApiProviderStatus, NotificationProvider, TalerWalletIntegrationBrowserProvider, TranslationProvider, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -37,6 +38,13 @@ import { Routing } from "./Routing.js"; import { SettingsProvider } from "./context/settings.js"; import { revalidateChallengeSession } from "./hooks/challenge.js"; import { strings } from "./i18n/strings.js"; +import { + ActionButton, + InlineNotice, + LoadingState, + TechnicalDetails, + VerificationCard, +} from "./components/VerificationUi.js"; import { Frame } from "./pages/Frame.js"; import { ChallengerUiSettings, fetchSettings } from "./settings.js"; @@ -69,11 +77,21 @@ export function App(): VNode { } function ConfiguredApp(): VNode { + const { i18n } = useTranslationContext(); const [settings, setSettings] = useState<ChallengerUiSettings>(); useEffect(() => { fetchSettings(setSettings); }, []); - if (!settings) return <Loading />; + if (!settings) { + // Translators: Initial full-page loading title while the web UI reads its + // local configuration, before contacting Challenger. + const preparingTitle = i18n.str`Preparing verification…`; + return ( + <div class="flex min-h-screen items-center justify-center bg-secondaryContainer px-4 dark:bg-darkBackground"> + <LoadingState title={preparingTitle} /> + </div> + ); + } const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); return ( @@ -82,6 +100,9 @@ function ConfiguredApp(): VNode { <ChallengerApiProvider baseUrl={new URL(baseUrl)} frameOnError={Frame} + renderStatus={(status) => ( + <ChallengerConnectionStatus status={status} /> + )} evictors={{ challenger: evictBankSwrCache, }} @@ -125,6 +146,83 @@ function ConfiguredApp(): VNode { ); } +function ChallengerConnectionStatus({ + status, +}: { + status: ChallengerApiProviderStatus; +}): VNode { + const { i18n } = useTranslationContext(); + if (status.type === "loading") { + // Translators: %1$s is the current automatic connection retry; %2$s is + // the maximum number of automatic retries. + const retryDescription = i18n.str`Retry ${status.attempt} of ${status.maxAttempts}`; + // Translators: Loading title shown during an automatic reconnection attempt + // after Challenger could not be reached. + const reconnectingTitle = i18n.str`Reconnecting to the verification service…`; + // Translators: Loading title for the first connection from this web page to + // the Challenger service. + const connectingTitle = i18n.str`Connecting to the verification service…`; + return ( + <LoadingState + title={status.attempt ? reconnectingTitle : connectingTitle} + description={status.attempt ? retryDescription : undefined} + /> + ); + } + if (status.type === "incompatible") { + // Translators: Fallback adjective for an unavailable service protocol + // version in the technical details. + const unknownVersion = i18n.str`unknown`; + // Translators: %1$s is the web page's supported Challenger protocol + // version; %2$s is the service's protocol version or "unknown". + const versionDetails = i18n.str`Supported version: ${status.supported}. Service version: ${status.serverVersion ?? unknownVersion}.`; + // Translators: Page title indicating that this web UI and Challenger use + // incompatible protocol versions and one must be updated. + const updateRequiredTitle = i18n.str`Verification service update required`; + // Translators: Error-notice heading for incompatible web UI and Challenger + // protocol versions. + const incompatibleTitle = i18n.str`Versions are incompatible`; + // Translators: Explanation shown when incompatible protocol versions prevent + // this page from continuing the verification flow. + const incompatibleDescription = i18n.str`This verification page cannot communicate with the service. Please return to the application that sent you here.`; + return ( + <VerificationCard title={updateRequiredTitle}> + <InlineNotice tone="error" title={incompatibleTitle}> + {incompatibleDescription} + </InlineNotice> + <TechnicalDetails>{versionDetails}</TechnicalDetails> + </VerificationCard> + ); + } + // Translators: Page title shown when all attempts to connect to Challenger + // have failed. + const unavailableTitle = i18n.str`Verification service unavailable`; + // Translators: Short error-notice heading for a failed Challenger connection. + const connectionFailureTitle = i18n.str`We could not connect`; + // Translators: Reassures the user that a connection failure did not modify + // their verification data. + const connectionFailureDescription = i18n.str`Check your connection and try again. Your verification information has not been changed.`; + // Translators: Busy-state label on the manual reconnect button. + const connectingLabel = i18n.str`Connecting…`; + // Translators: Button that manually retries the connection to Challenger. + const retryLabel = i18n.str`Try again`; + return ( + <VerificationCard title={unavailableTitle}> + <InlineNotice tone="error" title={connectionFailureTitle}> + {connectionFailureDescription} + </InlineNotice> + <div class="mt-5"> + <ActionButton busyLabel={connectingLabel} onClick={status.retry}> + {retryLabel} + </ActionButton> + </div> + <TechnicalDetails> + {status.error.errorDetail.code}: {status.error.message} + </TechnicalDetails> + </VerificationCard> + ); +} + // @ts-expect-error creating a new property for window object window.setGlobalLogLevelFromString = setGlobalLogLevelFromString; // @ts-expect-error creating a new property for window object diff --git a/packages/challenger-webui/src/challenger-flow.test.ts b/packages/challenger-webui/src/challenger-flow.test.ts @@ -9,6 +9,7 @@ import { INT_PHONE_REGEX, } from "./pages/AskChallenge.js"; import { safeRedirectURL } from "./pages/CallengeCompleted.js"; +import { formatCountdown } from "./pages/AnswerChallenge.js"; function status( overrides: Record<string, unknown> = {}, @@ -82,3 +83,9 @@ test("server restrictions are localized and invalid expressions are rejected", ( assert.equal(INT_PHONE_REGEX.test("+123456789012345"), true); assert.equal(INT_PHONE_REGEX.test("+1234567890123456"), false); }); + +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"); +}); diff --git a/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx @@ -1,35 +1,29 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +*/ import { ChallengerApi, HttpStatusCode, TalerError, - assertUnreachable, } from "@gnu-taler/taler-util"; -import { - Attention, - Button, - ErrorLoading, - Loading, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { useChallengeSession } from "../hooks/challenge.js"; import { SessionId } from "../hooks/session.js"; +import { + ActionButton, + InlineNotice, + LoadingState, + SecondaryAction, + TechnicalDetails, + VerificationCard, +} from "./VerificationUi.js"; export type ChallengeNextPage = "ask" | "answer" | "completed" | "exhausted"; @@ -47,6 +41,7 @@ interface Props { children: ComponentChildren; onStatus?: (nextPage: ChallengeNextPage) => void; } + export function CheckChallengeIsUpToDate({ session, children, @@ -54,6 +49,16 @@ export function CheckChallengeIsUpToDate({ }: Props): VNode { const { i18n } = useTranslationContext(); const { result, retry } = useChallengeSession(session); + const returnHost = new URL(session.redirectURL).host; + // Translators: %1$s is the host name of the application that requested + // verification. + const returnLabel = i18n.str`Return to ${returnHost}`; + // Translators: The user has exhausted the verification attempts allowed for + // this session, not network retry attempts. + const noAttemptsTitle = i18n.str`No attempts remain`; + // Translators: The user has exhausted all tries to enter a verification + // code, rather than connection retry attempts. + const noVerificationAttemptsTitle = i18n.str`No verification attempts remain`; const onStatusRef = useRef(onStatus); onStatusRef.current = onStatus; @@ -69,90 +74,126 @@ export function CheckChallengeIsUpToDate({ }, [nextPage]); if (!result) { - return <Loading />; + // Translators: Initial loading screen while the existing verification + // session is fetched from Challenger. + const loadingTitle = i18n.str`Loading verification…`; + // Translators: Reassurance below the initial verification loading title. + const loadingDescription = i18n.str`This can take a few seconds.`; + return ( + <LoadingState title={loadingTitle} description={loadingDescription} /> + ); } if (result instanceof TalerError) { + // Translators: Page title shown when the browser cannot contact Challenger + // to retrieve an existing verification session. + const unavailableTitle = i18n.str`Verification service unavailable`; + // Translators: Error-notice heading for a failed attempt to retrieve the + // current verification session. + const loadFailureTitle = i18n.str`We could not load this verification`; + // Translators: Reassures the user that a connection failure did not discard + // the verification steps they already completed. + const loadFailureDescription = i18n.str`Check your connection and try again. Your progress is safe.`; + // Translators: Busy-state label while retrying a verification request. + const loadingLabel = i18n.str`Loading…`; + // Translators: Button that retries a failed request to Challenger. + const retryLabel = i18n.str`Try again`; return ( - <Fragment> - <ErrorLoading - title={i18n.str`Failed to load the session.`} - error={result} - /> - <Button class="button is-info mt-2" onClick={() => void retry()}> - <i18n.Translate>Retry</i18n.Translate> - </Button> - </Fragment> + <VerificationCard title={unavailableTitle}> + <InlineNotice tone="error" title={loadFailureTitle}> + {loadFailureDescription} + </InlineNotice> + <div class="mt-5"> + <ActionButton + busyLabel={loadingLabel} + onClick={async () => { + await retry(); + }} + > + {retryLabel} + </ActionButton> + </div> + <TechnicalDetails> + {result.errorDetail.code}: {result.message} + </TechnicalDetails> + </VerificationCard> ); } if (result.type === "fail") { - switch (result.case) { - case HttpStatusCode.BadRequest: { - return ( - <Attention type="danger" title={i18n.str`Bad request`}> - <i18n.Translate> - Could not start the challenge, check configuration. - </i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.NotFound: { - return ( - <Attention type="danger" title={i18n.str`Not found`}> - <i18n.Translate>Nonce not found</i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.NotAcceptable: { - return ( - <Attention type="danger" title={i18n.str`Not acceptable`}> - <i18n.Translate> - Server has wrong template configuration - </i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.InternalServerError: { - return ( - <Fragment> - <Attention type="danger" title={i18n.str`Internal error`}> - <i18n.Translate>Check logs</i18n.Translate> - </Attention> - <Button class="button is-info mt-2" onClick={() => void retry()}> - <i18n.Translate>Retry</i18n.Translate> - </Button> - </Fragment> - ); - } - case HttpStatusCode.TooManyRequests: { - return ( - <Fragment> - <Attention - type="danger" - title={i18n.str`Can't complete this challenge`} + const retryable = result.case === HttpStatusCode.InternalServerError; + // Translators: Error heading for a verification session that has expired or + // was already removed by Challenger. + const expiredTitle = i18n.str`Verification link expired`; + // Translators: Error heading for a malformed or otherwise unusable + // verification-session link. + const invalidTitle = i18n.str`Invalid verification link`; + // Translators: Explanation for an expired or removed verification session. + const unavailableMessage = i18n.str`This verification is no longer available. Return to the application and start again.`; + // Translators: Instruction shown after the user has exhausted all allowed + // verification attempts. + const exhaustedMessage = i18n.str`Return to the application and start a new verification.`; + // Translators: Retry instruction for a temporary server-side failure. + const temporaryFailureMessage = i18n.str`The service had a temporary problem. Try again.`; + // Translators: Explanation for a verification request that Challenger + // rejected because required data is absent or invalid. + const invalidRequestMessage = i18n.str`The verification request is incomplete or invalid.`; + const title = + result.case === HttpStatusCode.NotFound + ? expiredTitle + : result.case === HttpStatusCode.TooManyRequests + ? noAttemptsTitle + : result.case === HttpStatusCode.InternalServerError + ? i18n.str`Verification service unavailable` + : invalidTitle; + const message = + result.case === HttpStatusCode.NotFound + ? unavailableMessage + : result.case === HttpStatusCode.TooManyRequests + ? exhaustedMessage + : result.case === HttpStatusCode.InternalServerError + ? temporaryFailureMessage + : invalidRequestMessage; + return ( + <VerificationCard title={title}> + <InlineNotice tone="error" title={title}> + {message} + </InlineNotice> + <div class="mt-5 space-y-3"> + {retryable ? ( + <ActionButton + busyLabel={i18n.str`Loading…`} + onClick={async () => { + await retry(); + }} > - <i18n.Translate> - There have been too many attempts to send and verify the TAN - code. - </i18n.Translate> - </Attention> - - {session.redirectURL ? ( - <div class="mt-2"> - <a href={session.redirectURL}>{session.redirectURL}</a> - </div> - ) : undefined} - </Fragment> - ); - } - default: - assertUnreachable(result); - } + <i18n.Translate>Try again</i18n.Translate> + </ActionButton> + ) : undefined} + <SecondaryAction href={session.redirectURL}> + {returnLabel} + </SecondaryAction> + </div> + <TechnicalDetails>HTTP {result.case}</TechnicalDetails> + </VerificationCard> + ); } if (nextPage && nextPage !== "exhausted") { - return <Loading />; + // Translators: Brief transition screen while routing to the next step of + // the verification flow. + const continuingTitle = i18n.str`Continuing…`; + return <LoadingState title={continuingTitle} />; } - return <Fragment>{children}</Fragment>; + return ( + <VerificationCard + step="code" + title={noVerificationAttemptsTitle} + description={children} + > + <SecondaryAction href={session.redirectURL}> + {returnLabel} + </SecondaryAction> + </VerificationCard> + ); } diff --git a/packages/challenger-webui/src/components/VerificationUi.tsx b/packages/challenger-webui/src/components/VerificationUi.tsx @@ -0,0 +1,237 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import { TranslatedString } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; + +export type VerificationStep = "details" | "code" | "complete"; + +export function VerificationCard({ + title, + description, + step, + children, +}: { + title: TranslatedString; + description?: ComponentChildren; + step?: VerificationStep; + children?: ComponentChildren; +}): VNode { + return ( + <section class="challenger-card w-full max-w-xl rounded-2xl border border-outlineVariant bg-background px-5 py-7 text-onBackground shadow-sm dark:border-darkSecondaryContainer dark:bg-darkBackground dark:text-darkOnBackground sm:px-9 sm:py-10"> + {step ? <VerificationProgress current={step} /> : undefined} + <div class="text-center"> + <h1 class="text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1> + {description ? ( + <div class="mx-auto mt-3 max-w-lg text-base leading-7 text-secondary dark:text-darkSecondary"> + {description} + </div> + ) : undefined} + </div> + <div class="mt-7">{children}</div> + </section> + ); +} + +function VerificationProgress({ + current, +}: { + current: VerificationStep; +}): VNode { + const { i18n } = useTranslationContext(); + const active = current === "details" ? 1 : current === "code" ? 2 : 3; + // Translators: Short progress-step label for entering an email address, + // phone number, or postal address; this does not mean a contact person. + const contactStep = i18n.str`Contact`; + // Translators: Short progress-step label for entering the verification code. + const codeStep = i18n.str`Code`; + // Translators: Short progress-step label for the finished verification. + const completeStep = i18n.str`Complete`; + const steps = [contactStep, codeStep, completeStep]; + // Translators: Accessible label for the three-step contact, code, and + // completion progress indicator. + const progressLabel = i18n.str`Verification progress`; + return ( + <ol class="mb-7 grid grid-cols-3 gap-2" aria-label={progressLabel}> + {steps.map((label, index) => { + const number = index + 1; + const selected = number === active; + const done = number < active; + return ( + <li + key={label} + class={`border-t-4 pt-2 text-center text-xs font-semibold sm:text-sm ${ + selected || done + ? "border-primary text-primary dark:border-darkPrimary dark:text-darkPrimary" + : "border-outlineVariant text-secondary dark:border-outline dark:text-darkSecondary" + }`} + aria-current={selected ? "step" : undefined} + > + {label} + </li> + ); + })} + </ol> + ); +} + +export function LoadingState({ + title, + description, +}: { + title: TranslatedString; + description?: TranslatedString; +}): VNode { + return ( + <VerificationCard title={title} description={description}> + <div class="flex justify-center" role="status" aria-live="polite"> + <Spinner /> + </div> + </VerificationCard> + ); +} + +export function Spinner(): VNode { + return ( + <svg + class="h-7 w-7 animate-spin text-primary dark:text-darkPrimary" + viewBox="0 0 24 24" + fill="none" + aria-hidden="true" + > + <circle + class="opacity-25" + cx="12" + cy="12" + r="9" + stroke="currentColor" + stroke-width="3" + /> + <path + class="opacity-90" + fill="currentColor" + d="M12 3a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6V3z" + /> + </svg> + ); +} + +export function ActionButton({ + children, + busyLabel, + running = false, + disabled = false, + onClick, + submit = false, +}: { + children: ComponentChildren; + busyLabel: TranslatedString; + running?: boolean; + disabled?: boolean; + onClick?: () => void | Promise<void>; + submit?: boolean; +}): VNode { + return ( + <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} + aria-busy={running} + onClick={onClick} + > + {running ? <Spinner /> : undefined} + <span>{running ? busyLabel : children}</span> + </button> + ); +} + +export function SecondaryAction({ + href, + children, +}: { + href: string; + children: ComponentChildren; +}): VNode { + return ( + <a + href={href} + class="flex min-h-11 w-full items-center justify-center 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 dark:border-darkSecondary dark:bg-darkBackground dark:text-darkOnBackground dark:hover:bg-darkSecondaryContainer dark:focus-visible:outline-darkPrimary" + > + {children} + </a> + ); +} + +export function PrimaryActionLink({ + href, + children, +}: { + href: string; + children: ComponentChildren; +}): VNode { + return ( + <a + href={href} + class="flex min-h-11 w-full items-center justify-center rounded-lg bg-primary px-4 py-2.5 text-center 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 dark:bg-darkPrimary dark:text-darkOnPrimary dark:focus-visible:outline-darkPrimary" + > + {children} + </a> + ); +} + +export type NoticeTone = "info" | "error" | "warning" | "success"; + +export function InlineNotice({ + title, + tone = "info", + children, +}: { + title: TranslatedString; + tone?: NoticeTone; + children?: ComponentChildren; +}): VNode { + const toneClass = { + info: "border-primary bg-primaryContainer text-onPrimaryContainer dark:border-darkPrimary dark:bg-darkPrimaryContainer dark:text-darkOnPrimaryContainer", + error: + "border-error bg-errorContainer text-onErrorContainer dark:border-darkError dark:bg-darkErrorContainer dark:text-darkOnErrorContainer", + warning: + "border-warning bg-warningContainer text-onWarningContainer dark:border-darkWarning dark:bg-darkWarningContainer dark:text-darkOnWarningContainer", + success: + "border-success bg-successContainer text-onSuccessContainer dark:border-darkSuccess dark:bg-darkSuccessContainer dark:text-darkOnSuccessContainer", + }[tone]; + return ( + <div + class={`rounded-lg border-l-4 p-4 text-sm ${toneClass}`} + role={tone === "error" ? "alert" : "status"} + > + <p class="font-bold">{title}</p> + {children ? <div class="mt-1 leading-6">{children}</div> : undefined} + </div> + ); +} + +export function TechnicalDetails({ + children, +}: { + children: ComponentChildren; +}): VNode { + const { i18n } = useTranslationContext(); + // Translators: Label on an expandable section containing diagnostic + // information intended mainly for support and developers. + const summaryLabel = i18n.str`Technical details`; + 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> + </details> + ); +} diff --git a/packages/challenger-webui/src/context/preferences.ts b/packages/challenger-webui/src/context/preferences.ts @@ -75,6 +75,8 @@ export function getLabelForPreferences( ): TranslatedString { switch (k) { case "showChallangeSetup": + // Translators: Developer preference that exposes Challenger's manual + // OAuth session-setup screen; it is not part of the normal user flow. return i18n.str`Show challenger setup screen`; } } diff --git a/packages/challenger-webui/src/hooks/challenge.ts b/packages/challenger-webui/src/hooks/challenge.ts @@ -33,6 +33,8 @@ export function revalidateChallengeSession() { export function useChallengeSession(session: SessionId): { result: ChallengerResultByMethod<"login"> | undefined | TalerHttpError; retry: () => Promise<ChallengerResultByMethod<"login"> | undefined>; + isLoading: boolean; + isRefreshing: boolean; } { const { lib: { challenger: api }, @@ -45,6 +47,8 @@ export function useChallengeSession(session: SessionId): { data, error, mutate: retry, + isLoading, + isValidating, } = useSWR<ChallengerResultByMethod<"login">, TalerHttpError>( !session ? undefined : [session, "login"], fetcher, @@ -54,12 +58,14 @@ export function useChallengeSession(session: SessionId): { errorRetryCount: 2, errorRetryInterval: 1000, shouldRetryOnError: true, - keepPreviousData: false, + keepPreviousData: true, }, ); return { result: data ?? error, retry, + isLoading, + isRefreshing: isValidating && data !== undefined, }; } diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx @@ -1,18 +1,11 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +*/ import { AbsoluteTime, ChallengerApi, @@ -21,30 +14,29 @@ import { TalerError, TalerFormAttributes, TranslatedString, - assertUnreachable, } from "@gnu-taler/taler-util"; import { - Attention, - AsyncButton, - Button, - ErrorLoading, - Loading, RouteDefinition, - ShowInputErrorLabel, - Time, + useAsyncAction, useChallengerApiContext, - useNotificationContext, - useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useMemo } from "preact/compat"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { VNode, h } from "preact"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { revalidateChallengeSession, useChallengeSession, } from "../hooks/challenge.js"; import { SessionId, useSessionState } from "../hooks/session.js"; +import { + ActionButton, + InlineNotice, + LoadingState, + SecondaryAction, + TechnicalDetails, + VerificationCard, +} from "../components/VerificationUi.js"; +import { challengeFailureMessage } from "./AskChallenge.js"; type Props = { focus?: boolean; @@ -53,41 +45,18 @@ type Props = { routeAsk: RouteDefinition<EmptyObject>; }; -function useReloadOnDeadline(deadline: AbsoluteTime): void { - const [, set] = useState(false); - function toggle(): void { - set((s) => !s); - } - useEffect(() => { - if (AbsoluteTime.isExpired(deadline)) { - return; - } - const diff = AbsoluteTime.difference(AbsoluteTime.now(), deadline); - if (diff.d_ms === "forever") return; - const timer = setTimeout(toggle, diff.d_ms); - return () => { - clearTimeout(timer); - }; - }, [deadline]); -} - export function getAddressDescriptionFromAddrType( type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"], addr: Record<string, string>, ): string { switch (type) { - case "email": { + case "email": return addr[TalerFormAttributes.CONTACT_EMAIL]; - } - case "phone": { + case "phone": return addr[TalerFormAttributes.CONTACT_PHONE]; - } - case "postal": { - return addr[TalerFormAttributes.CONTACT_NAME]; - } - case "postal-ch": { + case "postal": + case "postal-ch": return addr[TalerFormAttributes.CONTACT_NAME]; - } } } @@ -99,400 +68,442 @@ export function AnswerChallenge({ }: Props): VNode { const { config, lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); - const { sent, failed, completed } = useSessionState(session); - const { showError, displayError } = useNotificationContext(); - - const [pin, setPin] = useState<string | undefined>(); + const { sent, completed } = useSessionState(session); + const [pin, setPin] = useState(""); + const [actionError, setActionError] = useState<TranslatedString>(); const pinInput = useRef<HTMLInputElement | null>(null); - const errors = undefinedIfEmpty({ - pin: !pin ? i18n.str`Can't be empty` : undefined, - }); - - const { result, retry } = useChallengeSession(session); + const { result, retry, isRefreshing } = useChallengeSession(session); const lastStatus = - result && !(result instanceof TalerError) && result.type !== "fail" + result && !(result instanceof TalerError) && result.type === "ok" ? result.body : undefined; - - const deadlineTS = - lastStatus == undefined ? undefined : lastStatus.retransmission_time; - - const deadline = useMemo(() => { - return !deadlineTS - ? AbsoluteTime.never() - : AbsoluteTime.fromProtocolTimestamp(deadlineTS); - }, [deadlineTS]); - - useReloadOnDeadline(deadline); - - const lastAddr = !lastStatus?.last_address - ? undefined - : getAddressDescriptionFromAddrType( + const retransmissionTime = lastStatus?.retransmission_time.t_s; + const deadline = useMemo( + () => + retransmissionTime !== undefined + ? AbsoluteTime.fromProtocolTimestamp({ t_s: retransmissionTime }) + : AbsoluteTime.never(), + [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, lastStatus.last_address, - ); - - const unableToChangeAddr = !lastStatus || lastStatus.changes_left < 1; - const contact = lastStatus?.last_address; + ) + : undefined; + // Translators: Validation error shown when the user enters a one-time code + // that does not match the current Challenger session. + const incorrectCodeMessage = i18n.str`The verification code is incorrect.`; + // Translators: Generic explanation when Challenger rejects the request to + // load the code-entry step. + const rejectedRequestMessage = i18n.str`The verification service rejected this request.`; + // Translators: Loading title or inline status while refreshing the current + // verification session after a code-related action. + const updatingStatusLabel = i18n.str`Updating verification status…`; + // Translators: Page title after the current code has no remaining entry + // attempts and can no longer be accepted. + const unusableCodeTitle = i18n.str`This verification code can no longer be used`; + // Translators: Recovery instruction after all attempts for the current code + // have been exhausted. + const exhaustedCodeDescription = i18n.str`Choose another available option or return to the application and start again.`; + // Translators: Page title above the form where the user enters the one-time + // verification code they received. + const enterCodeTitle = i18n.str`Enter your verification code`; + // 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: Form-field label for the one-time numeric code sent by email, + // SMS, or postal mail. + const verificationCodeLabel = i18n.str`Verification code`; + // Translators: Error-notice heading shown when Challenger rejects an entered + // verification code. + const codeNotAcceptedTitle = i18n.str`Code not accepted`; + // 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.`; + // Translators: Busy-state label while Challenger checks an entered code. + const verifyingLabel = i18n.str`Verifying…`; + // Translators: Primary button that submits the entered one-time code for + // verification. + const verifyCodeLabel = i18n.str`Verify code`; - // i18n.str`create challenge`, - const sendAgainArgs = - contact === undefined || - lastStatus === undefined || - lastStatus.pin_transmissions_left === 0 || - !AbsoluteTime.isExpired(deadline) - ? undefined - : ([session.nonce, contact] as const); - const sendAgain = useNotifiedOperation< + const resend = useAsyncAction< Awaited<ReturnType<typeof lib.challenger.challenge>>, [string, Record<string, string>] - >((ct, n, b) => lib.challenger.challenge(n, b), { - onSuccess(success) { - if (success.type === "completed") { - completed(success); - onComplete(); - } else { - sent(success); + >((ct, nonce, address) => lib.challenger.challenge(nonce, address), { + onResult(operationResult) { + if (operationResult.type === "ok") { + setActionError(undefined); + if (operationResult.body.type === "completed") { + completed(operationResult.body); + onComplete(); + } else { + sent(operationResult.body); + void revalidateChallengeSession(); + } + return; } + setActionError(challengeFailureMessage(i18n, operationResult)); + }, + onError() { + setActionError( + i18n.str`We could not contact the verification service. Check your connection and try again.`, + ); }, - onFail: showError(i18n.str`Failed to create a new challenge.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The request was not accepted. Try reloading the app.`; - case HttpStatusCode.NotFound: - return i18n.str`Challenge not found.`; - case HttpStatusCode.NotAcceptable: - return i18n.str`Server templates are missing due to misconfiguration.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`There have been too many attempts to send the TAN code.`; - case HttpStatusCode.InternalServerError: - return i18n.str`Server is unable to respond due to internal problems.`; - default: - assertUnreachable(fail); - } - }), }); - // i18n.str`solve challenge`, - const checkArgs = - errors !== undefined || - lastStatus == undefined || - lastStatus.auth_attempts_left === 0 || - !pin - ? undefined - : ([session.nonce, { pin }] as const); - const check = useNotifiedOperation< + const solve = useAsyncAction< Awaited<ReturnType<typeof lib.challenger.solve>>, [string, Record<string, string>] - >((ct, n, b) => lib.challenger.solve(n, b), { - onSuccess(success) { - if (success.type === "completed") { - completed(success); - onComplete(); - } else { - failed(success); - setPin(undefined); + >((ct, nonce, body) => lib.challenger.solve(nonce, body), { + onResult(operationResult) { + if (operationResult.type === "ok") { + if (operationResult.body.type === "completed") { + setActionError(undefined); + completed(operationResult.body); + onComplete(); + return; + } + setPin(""); + setActionError(incorrectCodeMessage); pinInput.current?.focus(); void revalidateChallengeSession(); - displayError( - i18n.str`Failed to solve the challenge.`, - success, - success.hint as TranslatedString, - ); + return; } - }, - onFail: showError(i18n.str`Failed to solve the challenge.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The request was not accepted. Try reloading the app.`; - case HttpStatusCode.Forbidden: { - setPin(undefined); - pinInput.current?.focus(); - void revalidateChallengeSession(); - return ( - (fail.body.hint as TranslatedString) || i18n.str`Invalid TAN code.` - ); - } - case HttpStatusCode.NotFound: - return i18n.str`Challenge not found.`; - case HttpStatusCode.NotAcceptable: - return i18n.str`Server templates are missing due to misconfiguration.`; - case HttpStatusCode.TooManyRequests: { - void revalidateChallengeSession(); - return i18n.str`There have been too many attempts to send the TAN code.`; - } - case HttpStatusCode.InternalServerError: - return i18n.str`Server is unable to respond due to internal problems.`; - default: - assertUnreachable(fail); + const pending = getPendingResponse(operationResult); + if (pending) { + setPin(""); + setActionError(incorrectCodeMessage); + pinInput.current?.focus(); + void revalidateChallengeSession(); + return; } - }), + setActionError(solveFailureMessage(i18n, operationResult.case)); + }, + onError() { + setActionError( + i18n.str`We could not contact the verification service. Check your connection and try again.`, + ); + }, }); - if (!result) return <Loading />; + if (!result) { + return <LoadingState title={i18n.str`Loading verification…`} />; + } if (result instanceof TalerError) { return ( - <Fragment> - <ErrorLoading - title={i18n.str`Failed to load the session.`} - error={result} - /> - <Button class="button is-info mt-2" onClick={() => void retry()}> - <i18n.Translate>Retry</i18n.Translate> - </Button> - </Fragment> + <VerificationCard title={i18n.str`Verification service unavailable`}> + <InlineNotice + tone="error" + title={i18n.str`We could not load your verification`} + > + <i18n.Translate> + Check your connection and try again. Your progress is safe. + </i18n.Translate> + </InlineNotice> + <div class="mt-5"> + <ActionButton + busyLabel={i18n.str`Loading…`} + onClick={async () => { + await retry(); + }} + > + <i18n.Translate>Try again</i18n.Translate> + </ActionButton> + </div> + <TechnicalDetails> + {result.errorDetail.code}: {result.message} + </TechnicalDetails> + </VerificationCard> ); } if (result.type === "fail") { return ( - <Fragment> - <Attention - type="danger" - title={i18n.str`Could not load the verification details. Please try again.`} - > - <i18n.Translate>The server rejected the request.</i18n.Translate> - </Attention> - <Button class="button is-info mt-2" onClick={() => void retry()}> - <i18n.Translate>Retry</i18n.Translate> - </Button> - </Fragment> + <VerificationCard title={i18n.str`Could not load verification details`}> + <InlineNotice tone="error" title={i18n.str`Verification unavailable`}> + {result.case === HttpStatusCode.NotFound + ? i18n.str`This verification has expired. Return to the application and start again.` + : rejectedRequestMessage} + </InlineNotice> + <TechnicalDetails>HTTP {result.case}</TechnicalDetails> + </VerificationCard> ); } - const cantTryAnymore = lastStatus?.auth_attempts_left === 0; + if (!lastStatus) { + return <LoadingState title={updatingStatusLabel} />; + } - function LastContactSent(): VNode { + 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 canVerify = pin.length > 0 && !cantTry; + + if (cantTry) { return ( - <p class="mt-2 text-lg leading-8 text-gray-600"> - {!lastStatus ? ( - <i18n.Translate> - The challenge details are currently unavailable. - </i18n.Translate> - ) : AbsoluteTime.isExpired(deadline) || - AbsoluteTime.isNever(deadline) ? ( + <VerificationCard + step="code" + title={unusableCodeTitle} + description={exhaustedCodeDescription} + > + <CodeActions + routeAsk={routeAsk.url({})} + canChange={canChange} + canResend={canResend} + resendRunning={resend.running} + onResend={ + lastStatus.last_address + ? () => resend.run(session.nonce, lastStatus.last_address!) + : undefined + } + /> + </VerificationCard> + ); + } + + return ( + <VerificationCard + step="code" + title={enterCodeTitle} + description={ + lastAddress ? ( + // Translators: %1$s is the email address, phone number, or postal + // addressee to which the one-time verification code was sent. <i18n.Translate> - You may request a new TAN code for &quot;{lastAddr}&quot;. + We sent a one-time verification code to {lastAddress}. </i18n.Translate> ) : ( - <Attention title={i18n.str`A TAN code was sent to "${lastAddr}"`}> - <i18n.Translate> - Please wait until{" "} - <Time format="dd/MM/yyyy HH:mm:ss" timestamp={deadline} /> before - requesting a new one. - </i18n.Translate> - </Attention> - )} - </p> - ); - } + enterReceivedCodeDescription + ) + } + > + {isRefreshing ? ( + <p + class="mb-3 text-center text-sm text-secondary dark:text-darkSecondary" + role="status" + > + {updatingStatusLabel} + </p> + ) : undefined} - function TryAnotherCode(): VNode { - return ( - <div class="mx-auto mt-4 max-w-xl flex justify-between"> - <div> - {unableToChangeAddr ? ( - <span class="relative inline-flex cursor-not-allowed items-center rounded-md bg-gray-300 px-3 py-2 text-sm font-semibold text-white ring-1 ring-inset ring-gray-300"> - <i18n.Translate>Try with another address</i18n.Translate> - </span> - ) : ( - <a - href={routeAsk.url({})} - class="relative inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" - > - <i18n.Translate>Try with another address</i18n.Translate> - </a> - )} - {lastStatus === undefined ? undefined : ( - <p class="mt-2 text-sm leading-6 text-gray-400"> - {lastStatus.changes_left < 1 ? ( - <i18n.Translate> - You can&#39;t change the contact address anymore. - </i18n.Translate> - ) : lastStatus.changes_left === 1 ? ( - <i18n.Translate> - You can change the contact address one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can change the contact address {lastStatus.changes_left}{" "} - more times. - </i18n.Translate> - )} - </p> - )} - </div> - <div> - <AsyncButton + {remainingSeconds > 0 ? ( + <InlineNotice title={codeSentTitle}>{resendCountdown}</InlineNotice> + ) : undefined} + + <form + class="mt-6" + onSubmit={(event) => { + event.preventDefault(); + if (canVerify) void solve.run(session.nonce, { pin }); + }} + > + <label htmlFor="pin" class="block text-sm font-semibold"> + {verificationCodeLabel} + </label> + <input + ref={(element) => { + pinInput.current = element; + if (focus) doAutoFocus(element); + }} + id="pin" + name="pin" + type="text" + inputMode="numeric" + autoComplete="one-time-code" + pattern="[0-9]*" + maxLength={64} + 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} + onInput={(event) => { + setPin(event.currentTarget.value); + setActionError(undefined); + }} + /> + + {actionError ? ( + <div id="verification-code-error" class="mt-4"> + <InlineNotice tone="error" title={codeNotAcceptedTitle}> + {actionError} + </InlineNotice> + </div> + ) : undefined} + + {lastStatus.auth_attempts_left === 1 ? ( + <p class="mt-3 text-sm font-semibold text-onWarningContainer dark:text-darkWarning"> + {lastAttemptWarning} + </p> + ) : undefined} + + <div class="mt-6"> + <ActionButton submit - class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + busyLabel={verifyingLabel} + running={solve.running} + disabled={!canVerify} onClick={ - sendAgainArgs ? () => sendAgain.run(...sendAgainArgs) : undefined + canVerify ? () => solve.run(session.nonce, { pin }) : undefined } > - <i18n.Translate>Send new code</i18n.Translate> - </AsyncButton> - {lastStatus === undefined ? undefined : ( - <p class="mt-2 text-sm leading-6 text-gray-400"> - {lastStatus.pin_transmissions_left < 1 ? ( - <i18n.Translate> - We can&#39;t send you the code anymore. - </i18n.Translate> - ) : lastStatus.pin_transmissions_left === 1 ? ( - <i18n.Translate> - We can send the code one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - We can send the code {lastStatus.pin_transmissions_left} more - times. - </i18n.Translate> - )} - </p> - )} + {verifyCodeLabel} + </ActionButton> </div> - </div> - ); - } + </form> - if (cantTryAnymore) { - return ( - <Fragment> - <div class="isolate bg-white px-6 py-12"> - <div class="mx-auto max-w-2xl text-center"> - <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> - <i18n.Translate> - The last TAN code can no longer be used. - </i18n.Translate> - </h2> + <CodeActions + routeAsk={routeAsk.url({})} + canChange={canChange} + canResend={canResend} + resendRunning={resend.running} + onResend={ + lastStatus.last_address + ? () => resend.run(session.nonce, lastStatus.last_address!) + : undefined + } + /> + </VerificationCard> + ); +} - <LastContactSent /> - </div> +function CodeActions({ + routeAsk, + canChange, + canResend, + resendRunning, + onResend, +}: { + routeAsk: string; + canChange: boolean; + canResend: boolean; + resendRunning: boolean; + onResend?: () => Promise<void>; +}): 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> + )} + <ActionButton + busyLabel={sendingCodeLabel} + running={resendRunning} + disabled={!canResend} + onClick={canResend ? onResend : undefined} + > + {resendCodeLabel} + </ActionButton> + </div> + ); +} - <TryAnotherCode /> - </div> - </Fragment> - ); +function getPendingResponse( + result: unknown, +): ChallengerApi.InvalidPinResponse | undefined { + if (!result || typeof result !== "object" || !("body" in result)) { + return undefined; } + const body = (result as { body?: ChallengerApi.InvalidPinResponse }).body; + return body?.type === "pending" ? body : undefined; +} - return ( - <Fragment> - <div class="isolate bg-white px-6 py-12"> - <div class="mx-auto max-w-2xl text-center"> - <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> - <i18n.Translate> - Enter the TAN you received to authenticate. - </i18n.Translate> - </h2> - <LastContactSent /> - - {lastStatus === undefined ? undefined : ( - <p class="mt-2 text-lg leading-8 text-gray-600"> - {lastStatus.auth_attempts_left < 1 ? ( - <i18n.Translate> - You can&#39;t check the TAN code anymore. - </i18n.Translate> - ) : lastStatus.auth_attempts_left === 1 ? ( - <i18n.Translate> - You can check the TAN code one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can check the TAN code {lastStatus.auth_attempts_left}{" "} - more times. - </i18n.Translate> - )} - </p> - )} - </div> +function solveFailureMessage( + i18n: ReturnType<typeof useTranslationContext>["i18n"], + status: HttpStatusCode, +): TranslatedString { + switch (status) { + case HttpStatusCode.BadRequest: + // Translators: Validation error when the verification-code field contains + // characters other than decimal digits. + return i18n.str`The verification code must contain only numbers.`; + case HttpStatusCode.NotFound: + return i18n.str`This verification has expired. Return to the application and start again.`; + case HttpStatusCode.NotAcceptable: + case HttpStatusCode.UnsupportedMediaType: + return i18n.str`The verification page and service are incompatible.`; + case HttpStatusCode.PayloadTooLarge: + // Translators: Validation error when the entered one-time code exceeds the + // maximum length accepted by Challenger. + return i18n.str`The verification code is too long.`; + case HttpStatusCode.InternalServerError: + return i18n.str`The verification service had a temporary problem. Try again.`; + default: + // Translators: Generic retry instruction when Challenger could not check + // the entered one-time code for an unspecified reason. + return i18n.str`The verification code could not be checked. Try again.`; + } +} - <form - method="POST" - class="mx-auto mt-4 max-w-xl" - onSubmit={(e) => { - e.preventDefault(); - if (checkArgs) void check.run(...checkArgs); - }} - > - <div class="grid grid-cols-1 gap-x-8 gap-y-6"> - <div class="sm:col-span-2"> - <label - htmlFor="pin" - class="block text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>TAN code</i18n.Translate> - </label> - <div class="mt-2.5"> - <input - ref={(element) => { - pinInput.current = element; - if (focus) doAutoFocus(element); - }} - type="text" - inputMode="numeric" - autoComplete="one-time-code" - pattern="[0-9]*" - name="pin" - id="pin" - maxLength={64} - value={pin} - onChange={(e) => { - setPin(e.currentTarget.value); - }} - placeholder="12345678" - class="block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - /> - <ShowInputErrorLabel - message={errors?.pin} - isDirty={pin !== undefined} - /> - </div> - </div> - </div> +function useRemainingSeconds(deadline: AbsoluteTime): number { + const [remaining, setRemaining] = useState(() => + calculateRemainingSeconds(deadline), + ); + useEffect(() => { + setRemaining(calculateRemainingSeconds(deadline)); + if (AbsoluteTime.isNever(deadline) || AbsoluteTime.isExpired(deadline)) { + return; + } + const timer = setInterval( + () => setRemaining(calculateRemainingSeconds(deadline)), + 1000, + ); + return () => clearInterval(timer); + }, [deadline]); + return remaining; +} - <div class="mt-10"> - <AsyncButton - submit - class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={ - checkArgs - ? (event) => { - event.preventDefault(); - return check.run(...checkArgs); - } - : undefined - } - > - <i18n.Translate>Check</i18n.Translate> - </AsyncButton> - </div> - </form> +function calculateRemainingSeconds(deadline: AbsoluteTime): number { + const remaining = AbsoluteTime.remaining(deadline).d_ms; + return remaining === "forever" ? 0 : Math.ceil(remaining / 1000); +} - <TryAnotherCode /> - </div> - </Fragment> - ); +export function formatCountdown(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; } -/** - * Show the element when the load ended - * @param element - */ export function doAutoFocus(element: HTMLElement | null): void { - if (element) { - setTimeout(() => { - element.focus({ preventScroll: true }); - element.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center", - }); - }, 100); - } + if (!element) return; + setTimeout(() => element.focus({ preventScroll: true }), 100); } export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { return Object.keys(obj).some( - (k) => (obj as Record<string, T>)[k] !== undefined, + (key) => (obj as Record<string, T>)[key] !== undefined, ) ? obj : undefined; diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx @@ -19,25 +19,31 @@ import { EmptyObject, HttpStatusCode, InternationalizationAPI, + TalerErrorCode, TalerError, TalerFormAttributes, TranslatedString, } from "@gnu-taler/taler-util"; import { - Attention, - AsyncButton, countryNameList, - ErrorLoading, FormDesign, FormUI, RouteDefinition, useChallengerApiContext, useForm, - useNotifiedOperation, - useNotificationContext, + useAsyncAction, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { + ActionButton, + InlineNotice, + LoadingState, + PrimaryActionLink, + TechnicalDetails, + VerificationCard, +} from "../components/VerificationUi.js"; import { useChallengeSession } from "../hooks/challenge.js"; import { SessionId, useSessionState } from "../hooks/session.js"; import { getAddressDescriptionFromAddrType } from "./AnswerChallenge.js"; @@ -54,95 +60,86 @@ export function AskChallenge(props: Props): VNode { const { i18n } = useTranslationContext(); const { config } = useChallengerApiContext(); const { result, retry } = useChallengeSession(props.session); + // Translators: Loading title while Challenger retrieves any previously + // entered email address, phone number, or postal address. + const loadingContactTitle = i18n.str`Loading contact details…`; + // Translators: Error-notice heading when the address-entry screen cannot load + // the user's current verification session. + const loadFailureTitle = i18n.str`We could not load your verification`; if (!result) { - return ( - <div> - <i18n.Translate>loading...</i18n.Translate> - </div> - ); + return <LoadingState title={loadingContactTitle} />; } if (result instanceof TalerError) { return ( - <Fragment> - <ErrorLoading - title={i18n.str`Failed to load the session.`} - error={result} - /> - <AsyncButton - class="button is-info mt-2" - onClick={async () => { - await retry(); - }} - > - <i18n.Translate>Retry</i18n.Translate> - </AsyncButton> - </Fragment> + <VerificationCard title={i18n.str`Verification service unavailable`}> + <InlineNotice tone="error" title={loadFailureTitle}> + <i18n.Translate> + Check your connection and try again. Your progress is safe. + </i18n.Translate> + </InlineNotice> + <div class="mt-5"> + <ActionButton + busyLabel={i18n.str`Loading…`} + onClick={async () => { + await retry(); + }} + > + <i18n.Translate>Try again</i18n.Translate> + </ActionButton> + </div> + <TechnicalDetails> + {result.errorDetail.code}: {result.message} + </TechnicalDetails> + </VerificationCard> ); } if (result.type === "fail") { - switch (result.case) { - case HttpStatusCode.BadRequest: { - return ( - <Attention - type="danger" - title={i18n.str`Could not load the verification details. Please try again.`} - > - <i18n.Translate>Bad request</i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.NotFound: { - return ( - <Attention - type="danger" - title={i18n.str`Could not load the verification details. Please try again.`} - > - <i18n.Translate>Not found</i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.NotAcceptable: { - return ( - <Attention - type="danger" - title={i18n.str`Could not load the verification details. Please try again.`} - > - <i18n.Translate>Not acceptable</i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.TooManyRequests: { - return ( - <Attention - type="danger" - title={i18n.str`Could not load the verification details. Please try again.`} - > - <i18n.Translate>Too many requests</i18n.Translate> - </Attention> - ); - } - case HttpStatusCode.InternalServerError: { - return ( - <Attention - type="danger" - title={i18n.str`Could not load the verification details. Please try again.`} - > - <i18n.Translate>Server error</i18n.Translate> - </Attention> - ); - } - } + // Translators: Page title when Challenger rejects the request to load the + // address-entry step of an existing verification. + const detailsFailureTitle = i18n.str`Could not load verification details`; + // Translators: Error-notice heading when the current verification session + // cannot be used. + const unavailableTitle = i18n.str`Verification unavailable`; + // Translators: Recovery instruction for an expired verification session. + const expiredDescription = i18n.str`This verification has expired. Return to the application and start again.`; + // Translators: Recovery instruction after all code-entry attempts have been + // exhausted for the current verification session. + const exhaustedDescription = i18n.str`No attempts remain. Return to the application and start a new verification.`; + // Translators: Generic explanation when Challenger rejects the request to + // load a verification session. + const rejectedDescription = i18n.str`The service rejected this verification request.`; + return ( + <VerificationCard title={detailsFailureTitle}> + <InlineNotice tone="error" title={unavailableTitle}> + {result.case === HttpStatusCode.NotFound + ? expiredDescription + : result.case === HttpStatusCode.TooManyRequests + ? exhaustedDescription + : rejectedDescription} + </InlineNotice> + <TechnicalDetails>HTTP {result.case}</TechnicalDetails> + </VerificationCard> + ); } const invalidRestriction = findInvalidRestriction(config.restrictions ?? {}); if (invalidRestriction) { + // Translators: %1$s is the name of a Challenger configuration field whose + // regular-expression validation rule is invalid. + const invalidRule = i18n.str`Invalid validation rule: ${invalidRestriction}`; + // Translators: Error-notice heading for a problem in the Challenger + // administrator's address-validation configuration. + const configurationErrorTitle = i18n.str`Service configuration error`; + // Translators: User-facing explanation for a server configuration problem; + // “address” may mean an email address, phone number, or postal address. + const configurationErrorDescription = i18n.str`The service cannot validate this address right now. Please return to the application and try again later.`; return ( - <Attention type="danger" title={i18n.str`Server configuration error`}> - <i18n.Translate> - The validation rule for &quot;{invalidRestriction}&quot; is invalid. - Please contact the service administrator. - </i18n.Translate> - </Attention> + <VerificationCard title={i18n.str`Verification service unavailable`}> + <InlineNotice tone="error" title={configurationErrorTitle}> + {configurationErrorDescription} + </InlineNotice> + <TechnicalDetails>{invalidRule}</TechnicalDetails> + </VerificationCard> ); } return <AskChallengeInternal {...props} lastStatus={result.body} />; @@ -158,9 +155,11 @@ function AskChallengeInternal({ }: Props & { lastStatus: ChallengerApi.ChallengeStatus }): VNode { const { sent, completed } = useSessionState(session); const { lib, config } = useChallengerApiContext(); - const { i18n, lang } = useTranslationContext(); - const { showError } = useNotificationContext(); + const [actionError, setActionError] = useState<TranslatedString>(); + // 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.`; const initial = { ...(lastStatus.last_address ?? {}) }; if (config.address_type === "postal-ch") { @@ -193,165 +192,208 @@ function AskChallengeInternal({ // i18n.str`create challenge`, const sendArgs = form.status.errors || !info ? undefined : ([session.nonce, info] as const); - const send = useNotifiedOperation< + const send = useAsyncAction< Awaited<ReturnType<typeof lib.challenger.challenge>>, [string, Record<string, string>] >((ct, n, i) => lib.challenger.challenge(n, i), { - onSuccess(ok) { - if (ok.type === "completed") { - completed(ok); - onComplete(); - } else { - sent(ok); - onSendSuccesful(); + onResult(result) { + if (result.type === "ok") { + setActionError(undefined); + if (result.body.type === "completed") { + completed(result.body); + onComplete(); + } else { + sent(result.body); + onSendSuccesful(); + } + return; } + setActionError(challengeFailureMessage(i18n, result)); + }, + onError() { + setActionError(connectionFailureMessage); }, - onFail: showError(i18n.str`Failed to create a challenge.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The request was not accepted. Try reloading the app.`; - case HttpStatusCode.NotFound: - return i18n.str`Challenge not found.`; - case HttpStatusCode.NotAcceptable: - return i18n.str`Server templates are missing due to misconfiguration.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`There have been too many attempts to send the TAN code.`; - case HttpStatusCode.InternalServerError: - return i18n.str`Server is unable to respond due to internal problems.`; - default: - assertUnreachable(fail); - } - }), }); + // Translators: Heading for verifying the email address where the one-time + // code will be delivered. + const emailTitle = i18n.str`Verify your email address`; + // Translators: Heading for verifying the phone number where the one-time + // code will be delivered. + const phoneTitle = i18n.str`Verify your phone number`; + // Translators: Heading for verifying the postal address where a letter + // containing the one-time code will be delivered. + const postalTitle = i18n.str`Verify your postal address`; + // Translators: Description below the email-verification heading explaining + // how the one-time code will be delivered. + const emailDescription = i18n.str`We will email you a one-time verification code.`; + // Translators: Description below the phone-verification heading explaining + // that the one-time code will arrive in a text message. + const phoneDescription = i18n.str`We will send you a one-time verification code by SMS.`; + // 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 = + config.address_type === "email" + ? emailTitle + : config.address_type === "phone" + ? phoneTitle + : postalTitle; + const description = + 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`; + // Translators: Error-notice heading after Challenger fails to send a + // verification code. + const sendFailureTitle = i18n.str`Code could not be sent`; + // Translators: Busy-state label on the button while Challenger sends a + // verification code. + const sendingCodeLabel = i18n.str`Sending code…`; + // 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`; + return ( - <Fragment> - <div class="isolate bg-white px-6 py-12"> - <div class="mx-auto max-w-2xl text-center"> - <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> - <i18n.Translate>Enter contact details</i18n.Translate> - </h2> - {config.address_type === "email" ? ( - <p class="mt-2 text-lg leading-8 text-gray-600"> - <i18n.Translate> - You will receive an email with a TAN code that must be provided - on the next page. - </i18n.Translate> - </p> - ) : config.address_type === "phone" ? ( - <p class="mt-2 text-lg leading-8 text-gray-600"> - <i18n.Translate> - You will receive an SMS with a TAN code that must be provided on - the next page. - </i18n.Translate> - </p> - ) : ( - <p class="mt-2 text-lg leading-8 text-gray-600"> - <i18n.Translate> - You will receive a message with a TAN code that must be provided - on the next page. - </i18n.Translate> - </p> - )} + <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 && - lastStatus.last_address && - lastStatus.auth_attempts_left > 0 && ( - <Fragment> - <Attention title={i18n.str`A code has been sent to ${prevAddr}`}> - <i18n.Translate> - <a href={routeSolveChallenge.url({})} class="underline"> - <i18n.Translate> - Complete the challenge here. - </i18n.Translate> - </a> - </i18n.Translate> - </Attention> - </Fragment> - )} - - <div class="mx-auto mt-4 max-w-xl "> - <FormUI - design={design} - model={form.model} - onSubmit={sendArgs ? () => send.run(...sendArgs) : undefined} - /> + {lastStatus.fix_address && prevAddr ? ( + <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> </div> + ) : ( + <FormUI + design={design} + model={form.model} + onSubmit={sendArgs ? () => send.run(...sendArgs) : undefined} + /> + )} - {lastStatus === undefined ? undefined : ( - <p class="mt-2 text-sm leading-6 text-gray-400"> - {lastStatus.changes_left < 1 ? ( - <i18n.Translate> - You can&#39;t change the contact address anymore. - </i18n.Translate> - ) : lastStatus.changes_left === 1 ? ( - <i18n.Translate> - You can change the contact address one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can change the contact address {lastStatus.changes_left}{" "} - more times. - </i18n.Translate> - )} - </p> - )} + {lastStatus.changes_left === 1 ? ( + <p class="mt-3 text-sm font-semibold text-onWarningContainer dark:text-darkWarning"> + {oneChangeLeft} + </p> + ) : undefined} - <div class="mx-auto mt-4 max-w-xl "> - {!prevAddr ? ( - <div class="mt-10"> - <AsyncButton - submit - class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={sendArgs ? () => send.run(...sendArgs) : undefined} - > - {(function (): TranslatedString { - switch (config.address_type) { - case "email": - return i18n.str`Send email`; - case "postal": - case "postal-ch": - return i18n.str`Send letter`; - case "phone": - return i18n.str`Send SMS`; - } - })()} - </AsyncButton> - </div> - ) : ( - <div class="mt-10"> - <AsyncButton - submit - class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={sendArgs ? () => send.run(...sendArgs) : undefined} - > - {(function (): TranslatedString { - switch (config.address_type) { - case "email": - return lastStatus.fix_address - ? i18n.str`Confirm email` - : i18n.str`Change email`; - case "postal": - case "postal-ch": - return lastStatus.fix_address - ? i18n.str`Confirm address` - : i18n.str`Change address`; - case "phone": - return lastStatus.fix_address - ? i18n.str`Confirm phone` - : i18n.str`Change phone`; - } - })()} - </AsyncButton> - </div> - )} + {actionError ? ( + <div class="mt-5"> + <InlineNotice tone="error" title={sendFailureTitle}> + {actionError} + </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> - </div> - </Fragment> + ) : undefined} + </VerificationCard> ); } +export function challengeFailureMessage( + i18n: InternationalizationAPI, + failure: { + case: HttpStatusCode; + detail?: { code?: number }; + }, +): TranslatedString { + if ( + failure.case === HttpStatusCode.ServiceUnavailable || + failure.detail?.code === TalerErrorCode.CHALLENGER_ADDRESS_UNREACHABLE + ) { + // Translators: Delivery failure for an unreachable email address, phone + // number, or postal address; the user may edit it or choose another one. + return i18n.str`The code could not be delivered to this destination. Check it or use a different one.`; + } + switch (failure.case) { + case HttpStatusCode.BadRequest: { + if ( + failure.detail?.code === + TalerErrorCode.CHALLENGER_ADDRESS_RESTRICTION_VIOLATED + ) { + // Translators: Address-validation error when one or more form fields + // violate restrictions configured by the Challenger administrator. + return i18n.str`This destination does not meet the service requirements. Check the highlighted fields.`; + } + // Translators: Generic validation error for an email address, phone + // number, or postal address submitted to Challenger. + return i18n.str`This destination could not be used. Check it and try again.`; + } + case HttpStatusCode.Forbidden: + // Translators: The application fixed the verification address, so the + // user is not permitted to edit or replace it. + return i18n.str`This destination is fixed and cannot be changed.`; + case HttpStatusCode.NotFound: + // Translators: Recovery instruction after the current verification + // session has expired. + return i18n.str`This verification has expired. Return to the application and start again.`; + case HttpStatusCode.NotAcceptable: + // Translators: Server configuration error indicating that no delivery + // helper can send the requested verification code. + return i18n.str`The verification service is not configured to send this code.`; + case HttpStatusCode.PayloadTooLarge: + // Translators: Validation error for an excessively long email address, + // phone number, or postal address. + return i18n.str`The destination is too long. Shorten it and try again.`; + case HttpStatusCode.UnsupportedMediaType: + // Translators: Compatibility error between this web UI and Challenger. + return i18n.str`The verification page and service are incompatible.`; + case HttpStatusCode.TooManyRequests: + // Translators: Rate-limit error after all permitted code deliveries have + // been used for this verification session. + return i18n.str`No more codes can be sent for this verification. Use a different destination or start again.`; + case HttpStatusCode.BadGateway: + // Translators: Temporary outage of the external email, SMS, or postal + // delivery provider used by Challenger. + return i18n.str`The delivery service is temporarily unavailable. Try again in a moment.`; + case HttpStatusCode.InternalServerError: + // Translators: Retry instruction after a temporary internal Challenger + // failure while sending a verification code. + return i18n.str`The verification service had a temporary problem. Try again.`; + default: + // Translators: Generic failure after Challenger could not send a + // verification code for an unspecified reason. + return i18n.str`The code could not be sent. Try again.`; + } +} + export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { return Object.keys(obj).some( (k) => (obj as Record<string, T>)[k] !== undefined, @@ -360,16 +402,6 @@ export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { : undefined; } -const ADDRESS_EXAMPLE_INTERNATIONAL = `Street name 1 -2. OG xxxx -12345 City_name -country_name `; - -const ADDRESS_EXAMPLE_CH = `Street name 1 -5. OG xxxx -12345 City_name -country_name `; - export const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; export const INT_PHONE_REGEX = /^\+?[1-9]\d{6,14}$/; // E.164 International Phone Number @@ -419,8 +451,11 @@ 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`; const hint = (getLocalizedRestrictionHint(serverConfig, lang) ?? - i18n.str`Invalid field`) as TranslatedString; + invalidFieldMessage) as TranslatedString; let regex; if (regexText) { @@ -460,6 +495,8 @@ 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`, disabled: read_only, validator(text) { @@ -489,6 +526,8 @@ 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`, disabled: read_only, validator(text) { @@ -519,7 +558,10 @@ function getFormDesignBasedOnAddressType( id: TalerFormAttributes.CONTACT_NAME, required: true, disabled: read_only, + // Translators: Postal-address form label for the recipient's name; + // this may name either a person or a business. label: i18n.str`Contact name`, + // Translators: Placeholder in the postal recipient-name field. placeholder: i18n.str`Full name of the person or business`, validator(text) { const restriction = getRestriction( @@ -539,8 +581,14 @@ function getFormDesignBasedOnAddressType( id: TalerFormAttributes.ADDRESS_LINES, required: true, disabled: read_only, + // Translators: Label for a multiline postal street-address field. label: i18n.str`Address`, - placeholder: ADDRESS_EXAMPLE_INTERNATIONAL, + // Translators: Three-line example for a postal-address text area. + // Preserve the line breaks and keep the components in local + // postal-address order. + placeholder: i18n.str`Street and house number +Additional address information +Postal code and city`, validator(text) { const restriction = getRestriction( i18n, @@ -556,6 +604,8 @@ function getFormDesignBasedOnAddressType( }, { id: TalerFormAttributes.ADDRESS_COUNTRY, + // Translators: Label for the country selector in a postal-address + // verification form. label: i18n.str`Country`, type: "selectOne", disabled: read_only, @@ -586,7 +636,11 @@ function getFormDesignBasedOnAddressType( id: TalerFormAttributes.CONTACT_NAME, required: true, disabled: read_only, + // Translators: Postal-address form label for the recipient's name; + // this may name either a person or a business. label: i18n.str`Contact name`, + // Translators: Placeholder in the recipient-name field of the Swiss + // postal-address form. placeholder: i18n.str`Your full name`, validator(text) { const restriction = getRestriction( @@ -605,9 +659,17 @@ function getFormDesignBasedOnAddressType( id: TalerFormAttributes.ADDRESS_LINES, required: true, disabled: read_only, + // Translators: Label for a multiline postal-address field restricted + // to addresses in Switzerland. label: i18n.str`Swiss address`, + // Translators: Help text below the Swiss postal-address field. help: i18n.str`Make sure this is a Swiss address.`, - placeholder: ADDRESS_EXAMPLE_CH, + // Translators: Three-line example for a Swiss postal-address text + // area. Preserve the line breaks and keep the components in local + // postal-address order. + placeholder: i18n.str`Street and house number +Additional address information +Postal code and city`, validator(text) { const restriction = getRestriction( i18n, @@ -622,6 +684,8 @@ function getFormDesignBasedOnAddressType( }, { id: TalerFormAttributes.ADDRESS_COUNTRY, + // Translators: Label for the country field, fixed to Switzerland in + // this version of the postal-address form. label: i18n.str`Country`, type: "selectOne", choices: countryNameList(i18n), diff --git a/packages/challenger-webui/src/pages/CallengeCompleted.tsx b/packages/challenger-webui/src/pages/CallengeCompleted.tsx @@ -1,66 +1,100 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; +*/ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useSessionState } from "../hooks/session.js"; import { useEffect } from "preact/hooks"; -import { SessionId } from "../hooks/session.js"; +import { + InlineNotice, + PrimaryActionLink, + SecondaryAction, + VerificationCard, +} from "../components/VerificationUi.js"; +import { SessionId, useSessionState } from "../hooks/session.js"; -export function CallengeCompleted({ session }: { session: SessionId }): VNode { +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 destination = completedURL ? new URL(completedURL).host : undefined; + // Translators: %1$s is the host name of the application that requested + // verification. + const returnLabel = returnURL + ? i18n.str`Return to ${new URL(returnURL).host}` + : undefined; + // Translators: %1$s is the host name of the application that requested + // verification. + const continueLabel = i18n.str`Continue to ${destination ?? ""}`; + // Translators: Page title shown after Challenger has successfully verified + // the user's email address, phone number, or postal address. + const completeTitle = i18n.str`Verification complete`; + // Translators: Instruction shown when Challenger cannot automatically redirect + // the browser back to the application that requested verification. + 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.`; useEffect(() => { - if (completedURL) { - window.location.href = completedURL; - } + if (completedURL) window.location.replace(completedURL); }, [completedURL]); if (!completedURL) { return ( - <div class="m-4"> - <Attention title={i18n.str`Redirect unavailable`} type="danger"> - <i18n.Translate> - The challenge was completed, but the redirect URL is unavailable. - Return to the application that started this verification. - </i18n.Translate> - </Attention> - </div> + <VerificationCard step="complete" title={completeTitle}> + <InlineNotice + // Translators: "return" means a browser redirect back to the + // application that requested verification. + title={i18n.str`Automatic return is unavailable`} + tone="warning" + > + {manualReturnDescription} + </InlineNotice> + {returnURL ? ( + <div class="mt-5"> + <SecondaryAction href={returnURL}>{returnLabel}</SecondaryAction> + </div> + ) : undefined} + </VerificationCard> ); } return ( - <div class="m-4"> - <Attention title={i18n.str`Challenge completed`} type="success"> - <i18n.Translate> - You will be redirected to{" "} - <a href={completedURL} class="break-all"> - &quot; - {completedURL} - &quot; - </a> - </i18n.Translate> - </Attention> - </div> + <VerificationCard + 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> + } + > + <InlineNotice + tone="success" + // Translators: "Address" is generic here: it may be an email address, + // phone number, or postal address. + title={i18n.str`Address verified`} + > + {continueDescription} + </InlineNotice> + <div class="mt-5"> + <PrimaryActionLink href={completedURL}> + {continueLabel} + </PrimaryActionLink> + </div> + </VerificationCard> ); } +// Keep the old export for downstream imports while correcting the name. +export const CallengeCompleted = ChallengeCompleted; + export function safeRedirectURL(value: string | undefined): string | undefined { if (!value) return undefined; try { diff --git a/packages/challenger-webui/src/pages/Frame.tsx b/packages/challenger-webui/src/pages/Frame.tsx @@ -1,150 +1,66 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +*/ import { - Footer, - Header, + LangSelector, ToastBanner, - useCommonPreferences, useRenderErrorReport, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, VNode, h } from "preact"; -import { - getAllBooleanPreferences, - getLabelForPreferences, - usePreferences, -} from "../context/preferences.js"; +import logo from "../../../../contrib/taler-assets/svg/logo/taler-logo-dark.svg"; +import { VerificationCard } from "../components/VerificationUi.js"; -const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; -const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; +const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : "unknown"; +const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : "unknown"; export function Frame({ children }: { children: ComponentChildren }): VNode { - const [preferences, updatePreferences] = usePreferences(); - const [commonPreferences, updateCommonPreferences] = useCommonPreferences(); - const { i18n } = useTranslationContext(); - + // Translators: Product-area label in the header of every Challenger page. + const productAreaLabel = i18n.str`Identity verification`; + // Translators: Generic page title used after an unexpected rendering error. + const unexpectedErrorTitle = i18n.str`Something went wrong`; + // Translators: Recovery instruction shown after an unexpected web UI error. + const unexpectedErrorDescription = i18n.str`Reload this page. If the problem continues, return to the application that started the verification.`; const failed = useRenderErrorReport({ - hash: __GIT_HASH__, - version: __VERSION__, + hash: GIT_HASH, + version: VERSION, }); return ( - <div - class="min-h-full flex flex-col m-0 bg-slate-200" - style="min-height: 100vh;" - > - <Header - title="Challenger" - onLogout={undefined} - iconLinkURL="#" - sites={ - preferences.showChallangeSetup - ? [[i18n.str`New challenge`, "#/setup/1"]] - : [] - } - > - <li> - <div class="text-xs font-semibold leading-6 text-gray-400"> - <i18n.Translate>Preferences</i18n.Translate> + <div class="flex min-h-screen flex-col bg-secondaryContainer text-onBackground dark:bg-darkBackground dark:text-darkOnBackground"> + <header class="border-b border-primary bg-primary text-onPrimary shadow-sm dark:border-darkPrimary dark:bg-darkPrimaryContainer dark:text-darkOnPrimaryContainer"> + <div class="mx-auto flex h-16 w-full max-w-5xl items-center justify-between px-4 sm:px-6"> + <div class="flex min-w-0 items-center gap-3"> + <img class="h-9 w-auto" src={logo} alt="GNU Taler" /> + <span class="truncate border-l border-onPrimary/40 pl-3 text-sm font-semibold sm:text-base"> + {productAreaLabel} + </span> </div> - <ul class="space-y-4"> - {getAllBooleanPreferences().map((set) => { - const isOn: boolean = !!preferences[set]; - return ( - <li key={set} class="pl-2"> - <div class="flex items-center justify-between"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id={`${set}-label`} - > - {getLabelForPreferences(set, i18n)} - </span> - </span> - <button - type="button" - name={`${set} switch`} - data-enabled={isOn} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked={isOn} - aria-labelledby={`${set}-label`} - onClick={() => { - updatePreferences(set, !isOn); - }} - > - <span - aria-hidden="true" - data-enabled={isOn} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - </li> - ); - })} - <li class="pl-2"> - <div class="flex items-center justify-between"> - <span - class="text-sm text-black font-medium leading-6" - id="show-debug-info-label" - > - <i18n.Translate>Show debug info</i18n.Translate> - </span> - <button - type="button" - data-enabled={commonPreferences.showDebugInfo} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked={commonPreferences.showDebugInfo} - aria-labelledby="show-debug-info-label" - onClick={() => - updateCommonPreferences( - "showDebugInfo", - !commonPreferences.showDebugInfo, - ) - } - > - <span - aria-hidden="true" - data-enabled={commonPreferences.showDebugInfo} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - /> - </button> - </div> - </li> - </ul> - </li> - </Header> + <LangSelector type="icon" /> + </div> + </header> - <div class="fixed z-20 top-14 w-full"> - <div class="mx-auto w-4/5"> + <main class="flex flex-1 flex-col items-center px-4 py-6 sm:px-6 sm:py-12"> + <div class="mb-3 w-full max-w-xl"> <ToastBanner /> </div> - </div> - - <main class="flex-1">{!failed ? children : undefined}</main> - - <Footer - testingUrlKey="challenger-base-url" - GIT_HASH={GIT_HASH} - VERSION={VERSION} - /> + {!failed ? ( + children + ) : ( + <VerificationCard title={unexpectedErrorTitle}> + <p class="text-center text-secondary dark:text-darkSecondary"> + {unexpectedErrorDescription} + </p> + </VerificationCard> + )} + </main> </div> ); } diff --git a/packages/challenger-webui/src/pages/NonceNotFound.tsx b/packages/challenger-webui/src/pages/NonceNotFound.tsx @@ -18,16 +18,22 @@ import { Fragment, VNode, h } from "preact"; export function NonceNotFound(): VNode { const { i18n } = useTranslationContext(); + // Translators: Error heading for a malformed verification URL or a URL whose + // session identifier Challenger no longer recognizes. + const invalidUrlTitle = i18n.str`The URL is wrong`; + // Translators: Possible explanation shown below the invalid verification-URL + // heading. + const expiredUrlDescription = i18n.str`Maybe the validation check expired.`; return ( <Fragment> <div class="isolate bg-white px-6 py-12"> <div class="mx-auto max-w-2xl text-center"> <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> - <i18n.Translate>The URL is wrong</i18n.Translate> + {invalidUrlTitle} </h2> <p class="mt-2 text-lg leading-8 text-gray-600"> - <i18n.Translate>Maybe the validation check expired.</i18n.Translate> + {expiredUrlDescription} </p> </div> </div> diff --git a/packages/challenger-webui/src/pages/Setup.tsx b/packages/challenger-webui/src/pages/Setup.tsx @@ -1,37 +1,32 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +*/ import { AccessToken, HttpStatusCode, - assertUnreachable, + TranslatedString, createRFC8959AccessTokenEncoded, encodeCrock, randomBytes, } from "@gnu-taler/taler-util"; import { - AsyncButton, - ShowInputErrorLabel, + useAsyncAction, useChallengerApiContext, - useNotifiedOperation, - useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { safeToURL } from "../Routing.js"; +import { + ActionButton, + InlineNotice, + VerificationCard, +} from "../components/VerificationUi.js"; import { doAutoFocus, undefinedIfEmpty } from "./AnswerChallenge.js"; type Props = { @@ -44,139 +39,161 @@ type Props = { export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode { const { i18n } = useTranslationContext(); const { lib } = useChallengerApiContext(); - const { showError } = useNotificationContext(); - const [password, setPassword] = useState<string | undefined>(secret); - const [url, setUrl] = useState<string | undefined>(redirectURL?.href); - + const [password, setPassword] = useState(secret ?? ""); + const [url, setUrl] = useState(redirectURL?.href ?? ""); + const [actionError, setActionError] = useState<TranslatedString>(); + // Translators: OAuth client credential used by a developer; this is not the + // end user's password. + const clientPasswordLabel = i18n.str`Client password`; + // Translators: OAuth redirect URL configured by a developer. + const redirectUrlLabel = i18n.str`Redirect URL`; + // Translators: Short form-validation message shown below an empty required + // developer-setup field. + const requiredMessage = i18n.str`Required`; + // Translators: Validation message for the OAuth redirect-URL field on the + // developer session-setup screen. + const invalidUrlMessage = i18n.str`Enter a valid HTTP or HTTPS URL`; + // Translators: Authentication error on the developer session-setup screen; + // “client” means an OAuth client application. + const unrecognizedClientMessage = i18n.str`The client ID or password is not recognized by this service.`; + // Translators: Generic server-rejection error while creating a developer + // verification session. + const setupFailureMessage = i18n.str`The verification session could not be created.`; + // Translators: Network-error message on the developer session-setup screen. + const connectionFailureMessage = i18n.str`We could not contact the verification service. Check the service URL and try again.`; + // Translators: Page title for the developer form that manually creates a + // Challenger verification session. + const setupTitle = i18n.str`Create a verification session`; + // Translators: Error-notice heading when the developer form fails to create a + // verification session. + const sessionNotCreatedTitle = i18n.str`Session not created`; + // Translators: Busy-state label on the developer form's session-creation + // button. + const creatingSessionLabel = i18n.str`Creating session…`; + // Translators: Button on the developer setup screen that submits the form and + // creates a new verification session. + const createSessionLabel = i18n.str`Create session`; const errors = undefinedIfEmpty({ - password: !password ? i18n.str`Required` : undefined, + password: !password ? requiredMessage : undefined, url: !url - ? i18n.str`Required` + ? requiredMessage : !safeToURL(url) - ? i18n.str`Invalid format` + ? invalidUrlMessage : undefined, }); - const startArgs = - !!errors || password === undefined || url === undefined + errors || !password || !url ? undefined : ([createRFC8959AccessTokenEncoded(password), url] as const); - const doStart = useNotifiedOperation< + + const start = useAsyncAction< Awaited<ReturnType<typeof lib.challenger.setup>>, [AccessToken, string] - >((ct, token, url) => lib.challenger.setup(clientId, token), { - onSuccess(ok, token, redirect_uri) { + >((ct, token) => lib.challenger.setup(clientId, token), { + onResult(result, _token, redirectUri) { + if (result.type === "fail") { + setActionError( + result.case === HttpStatusCode.NotFound + ? unrecognizedClientMessage + : setupFailureMessage, + ); + return; + } const redirect = new URL(window.location.href); redirect.searchParams.set("client_id", clientId); - redirect.searchParams.set("redirect_uri", redirect_uri); + redirect.searchParams.set("redirect_uri", redirectUri); redirect.searchParams.set("state", encodeCrock(randomBytes(32))); - redirect.searchParams.set("nonce", ok.nonce); + redirect.searchParams.set("nonce", result.body.nonce); redirect.hash = "/ask"; window.location.href = redirect.href; }, - onFail: showError(i18n.str`Failed to setup a new challenge.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.NotFound: - return i18n.str`The server doesn't know about this client. Either the URL or the secret is wrong.`; - default: - assertUnreachable(fail.case); - } - }), + onError() { + setActionError(connectionFailureMessage); + }, }); return ( - <Fragment> - <div class="isolate bg-white px-6 py-12"> - <div class="mx-auto max-w-2xl text-center"> - <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> - <i18n.Translate> - Setup new challenge with client ID: &quot;{clientId}&quot; - </i18n.Translate> - </h2> + <VerificationCard + title={setupTitle} + description={ + // Translators: This screen is only for developers. %1$s is an OAuth + // client identifier configured in Challenger. + <i18n.Translate>Developer setup for client {clientId}</i18n.Translate> + } + > + <form + onSubmit={(event) => { + event.preventDefault(); + if (startArgs) void start.run(...startArgs); + }} + > + <div> + <label for="password" class="block text-sm font-semibold"> + {clientPasswordLabel} + </label> + <input + ref={focus ? doAutoFocus : undefined} + id="password" + name="password" + type="password" + maxLength={512} + autoComplete="current-password" + value={password} + readOnly={secret !== undefined} + aria-invalid={!!errors?.password} + class="mt-2 block w-full rounded-lg border px-3.5 py-2.5 shadow-sm focus:ring-2 read-only:opacity-70" + onInput={(event) => setPassword(event.currentTarget.value)} + /> + {errors?.password ? ( + <p class="mt-1 text-sm text-error dark:text-darkError"> + {errors.password} + </p> + ) : undefined} </div> - <form - method="POST" - class="mx-auto mt-4 max-w-xl sm:mt-20" - onSubmit={(e) => { - e.preventDefault(); - if (startArgs) void doStart.run(...startArgs); - }} - > - <div class="sm:col-span-2"> - <label - htmlFor="password" - class="block text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Password</i18n.Translate> - </label> - <div class="mt-2.5"> - <input - type="password" - name="password" - id="password" - ref={focus ? doAutoFocus : undefined} - maxLength={512} - autocomplete="current-password" - value={password} - onChange={(e) => { - setPassword(e.currentTarget.value); - }} - readOnly={secret !== undefined} - class="block w-full read-only:bg-slate-200 rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - /> - <ShowInputErrorLabel - message={errors?.password} - isDirty={password !== undefined} - /> - </div> - </div> + <div class="mt-5"> + <label for="redirect_url" class="block text-sm font-semibold"> + {redirectUrlLabel} + </label> + <input + id="redirect_url" + name="redirect_url" + type="url" + maxLength={512} + autoComplete="url" + value={url} + readOnly={redirectURL !== undefined} + aria-invalid={!!errors?.url} + class="mt-2 block w-full rounded-lg border px-3.5 py-2.5 shadow-sm focus:ring-2 read-only:opacity-70" + onInput={(event) => setUrl(event.currentTarget.value)} + /> + {errors?.url ? ( + <p class="mt-1 text-sm text-error dark:text-darkError"> + {errors.url} + </p> + ) : undefined} + </div> - <div class="sm:col-span-2"> - <label - htmlFor="redirect_url" - class="block text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Redirect URL</i18n.Translate> - </label> - <div class="mt-2.5"> - <input - type="text" - name="redirect_url" - id="redirect_url" - maxLength={512} - autocomplete="url" - value={url} - onChange={(e) => { - setUrl(e.currentTarget.value); - }} - readOnly={redirectURL !== undefined} - class="block w-full read-only:bg-slate-200 rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - /> - <ShowInputErrorLabel - message={errors?.url} - isDirty={url !== undefined} - /> - </div> + {actionError ? ( + <div class="mt-5"> + <InlineNotice tone="error" title={sessionNotCreatedTitle}> + {actionError} + </InlineNotice> </div> - <div class="mt-10"> - <AsyncButton - submit - class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={ - startArgs - ? (event) => { - event.preventDefault(); - return doStart.run(...startArgs); - } - : undefined - } - > - <i18n.Translate>Start</i18n.Translate> - </AsyncButton> - </div> - </form> - </div> - </Fragment> + ) : undefined} + + <div class="mt-6"> + <ActionButton + submit + busyLabel={creatingSessionLabel} + running={start.running} + disabled={!startArgs} + onClick={startArgs ? () => start.run(...startArgs) : undefined} + > + {createSessionLabel} + </ActionButton> + </div> + </form> + </VerificationCard> ); } diff --git a/packages/challenger-webui/src/scss/main.css b/packages/challenger-webui/src/scss/main.css @@ -1,3 +1,88 @@ @tailwind base; @tailwind components; @tailwind utilities; + +:root { + color-scheme: light; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", sans-serif; + background: #fdfdff; +} + +body { + margin: 0; + min-width: 320px; + background: #fdfdff; + color: #1a1c1f; +} + +.challenger-card input, +.challenger-card select, +.challenger-card textarea { + min-height: 44px; + border-color: #767880 !important; + background: #fdfdff !important; + color: #1a1c1f !important; +} + +.challenger-card input::placeholder, +.challenger-card textarea::placeholder { + color: #586a88 !important; + opacity: 1; +} + +.challenger-card input:focus, +.challenger-card select:focus, +.challenger-card textarea:focus { + --tw-ring-color: #0042b3 !important; +} + +.challenger-card label { + color: #1a1c1f !important; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + background: #11131a; + } + + body { + background: #11131a; + color: #e2e2eb; + } + + .challenger-card input, + .challenger-card select, + .challenger-card textarea { + border-color: #a4c9ff !important; + background: #11131a !important; + color: #e2e2eb !important; + } + + .challenger-card input::placeholder, + .challenger-card textarea::placeholder { + color: #a4c9ff !important; + } + + .challenger-card input:focus, + .challenger-card select:focus, + .challenger-card textarea:focus { + --tw-ring-color: #b4c5ff !important; + } + + .challenger-card label { + color: #e2e2eb !important; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/packages/challenger-webui/tailwind.config.js b/packages/challenger-webui/tailwind.config.js @@ -1,267 +1,82 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. +*/ +import { default as twForm } from "@tailwindcss/forms"; - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { default as tw_form } from '@tailwindcss/forms'; +// Accepted Taler semantic roles from DD 66 and DD 90. +const colors = { + primary: "#0042b3", + onPrimary: "#ffffff", + primaryContainer: "#d3deff", + onPrimaryContainer: "#00134a", + darkPrimary: "#b4c5ff", + darkOnPrimary: "#002a78", + darkPrimaryContainer: "#0042b3", + darkOnPrimaryContainer: "#e5ebff", + secondary: "#586a88", + onSecondary: "#ffffff", + secondaryContainer: "#d9e3f9", + onSecondaryContainer: "#111c2b", + darkSecondary: "#a4c9ff", + darkOnSecondary: "#00315d", + darkSecondaryContainer: "#72a3e5", + darkOnSecondaryContainer: "#003869", + tertiary: "#338af0", + onTertiary: "#ffffff", + tertiaryContainer: "#d1e4ff", + onTertiaryContainer: "#001c39", + darkTertiary: "#8dd1e5", + darkOnTertiary: "#003641", + darkTertiaryContainer: "#166577", + darkOnTertiaryContainer: "#9ce0f5", + error: "#b3261e", + onError: "#ffffff", + errorContainer: "#f9dedc", + onErrorContainer: "#410e0b", + darkError: "#ffb4aa", + darkOnError: "#690003", + darkErrorContainer: "#b3261e", + darkOnErrorContainer: "#ffcbc4", + success: "#337a40", + onSuccess: "#ffffff", + successContainer: "#2e8534", + onSuccessContainer: "#f7fff1", + darkSuccess: "#337a40", + darkOnSuccess: "#ffffff", + darkSuccessContainer: "#1d3522", + darkOnSuccessContainer: "#eaf6ec", + warning: "#f99c06", + onWarning: "#000000", + warningContainer: "#fdedd3", + onWarningContainer: "#6b4706", + darkWarning: "#f99c06", + darkOnWarning: "#000000", + darkWarningContainer: "#664200", + darkOnWarningContainer: "#fdedd3", + background: "#fdfdff", + onBackground: "#1a1c1f", + darkBackground: "#11131a", + darkOnBackground: "#e2e2eb", + outline: "#767880", + outlineVariant: "#c4c6d0", +}; export default { + darkMode: "media", content: { relative: true, files: [ "./src/**/*.{html,tsx}", - "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}" + "./node_modules/@gnu-taler/web-util/src/**/*.{html,tsx}", ], }, theme: { - extend: { - colors: { - // https://docs.taler.net/design-documents/066-wallet-color-scheme.html - - // PRIMARY - /** - * Main action color (e.g. filled buttons, tabs, icons) - */ - 'primary': '#0042b3', - /** - * Text/icons placed on top of primary - */ - 'onPrimary': '#ffffff', - /** - * Background for FABs, cards, filled fields - */ - 'primaryContainer': '#d3deff', - /** - * Foreground for primaryContainer - */ - 'onPrimaryContainer': '#00134a', - /** - * primary in dark mode - */ - 'darkPrimary': '#b4c5ff', - /** - * Text/icons on darkPrimary - */ - 'darkOnPrimary': '#002a78', - /** - * Container in dark mode - */ - 'darkPrimaryContainer': '#0042b3', - /** - * Foreground on container in dark - */ - 'darkOnPrimaryContainer': '#e5ebff', - - // SECONDARY - /** - * Secondary buttons, chips, and passive UI states - */ - 'secondary': '#586a88', - /** - * Foreground on secondary - */ - 'onSecondary': '#ffffff', - /** - * Background for secondary surfaces - */ - 'secondaryContainer': '#d9e3f9', - /** - * Foreground on secondaryContainer - */ - 'onSecondaryContainer': '#111c2b', - /** - * Secondary color in dark mode - */ - 'darkSecondary': '#a4c9ff', - /** - * Text/icons on darkPrimary - */ - 'darkOnSecondary': '#00315d', - /** - * Container in dark mode - */ - 'darkSecondaryContainer': '#72a3e5', - /** - * Foreground on container in dark - */ - 'darkOnSecondaryContainer': '#003869', - }, - - // TERTIARY - /** - * Used for tags, emphasis markers - */ - 'tertiary': '#338af0', - /** - * Text/icons on tertiary - */ - 'onTertiary': '#ffffff', - /** - * Input field backgrounds, selected indicators - */ - 'tertiaryContainer': '#d1e4ff', - /** - * Text/icons on tertiaryContainer - */ - 'onTertiaryContainer': '#001c39', - /** - * Accent color in dark mode - */ - 'darkTertiary': '#8dd1e5', - /** - * Foreground in dark - */ - 'darkOnTertiary': '#003641', - /** - * Container fill in dark - */ - 'darkTertiaryContainer': '#166577', - /** - * Text/icons on dark container - */ - 'darkOnTertiaryContainer': '#9ce0f5', - - // ERROR - /** - * Main error color for messages or outlines - */ - 'error': '#b3261e', - /** - * Text/icons on error surfaces - */ - 'onError': '#ffffff', - /** - * - */ - 'errorContainer': '#f9dedc', - /** - * - */ - 'onErrorContainer': '#410e0b', - /** - * - */ - 'darkError': '#ffb4aa', - /** - * - */ - 'darkOnError': '#690003', - /** - * - */ - 'darkErrorContainer': '#b3261e', - /** - * - */ - 'darkOnErrorContainer': '#ffcbc4', - - // SUCCESS - /** - * - */ - 'success': '#337a40', - /** - * - */ - 'onSuccess': '#ffffff', - /** - * - */ - 'successContainer': '#2e8534', - /** - * - */ - 'onSuccessContainer': '#f7fff1', - /** - * - */ - 'darkSuccess': '#337a40', - /** - * - */ - 'darkOnSuccess': '#ffffff', - /** - * - */ - 'darkSuccessContainer': '#1d3522', - /** - * - */ - 'darkOnSuccessContainer': '#eaf6ec', - - // WARNING - /** - * Alert banners, passive warnings - */ - 'warning': '#f99c06', - /** - * - */ - 'onWarning': '#000000', - /** - * - */ - 'warningContainer': '#fdedd3', - /** - * - */ - 'onWarningContainer': '#6b4706', - /** - * - */ - 'darkWarning': '#f99c06', - /** - * - */ - 'darkOnWarning': '#000000', - /** - * - */ - 'darkWarningContainer': '#664200', - /** - * - */ - 'darkOnWarningContainer': '#fdedd3', - - // BACKGROUND - /** - * App-wide background color - */ - 'background': '#fdfdff', - /** - * - */ - 'onBackground': '#1a1c1f', - /** - * Background in dark mode - */ - 'darkBackground': '#11131a', - /** - * - */ - 'darkOnBackground': '#e2e2eb', - - // OUTLINE - /** - * Used for input borders, field outlines - */ - 'outline': '#767880', - /** - * Decorative borders, dividers - */ - 'outlineVariant': '#c4c6d0', - - }, + extend: { colors }, }, - plugins: [tw_form], - }; + plugins: [twForm], +}; diff --git a/packages/taler-util/src/http-client/challenger.test.ts b/packages/taler-util/src/http-client/challenger.test.ts @@ -30,6 +30,7 @@ import { ChallengerHttpClient } from "./challenger.js"; function fixedLib( status: number, headers: Record<string, string> = {}, + responseBody: object = { code: 1 }, ): { lib: HttpRequestLibrary; lastOpt: () => HttpRequestOptions | undefined } { let seen: HttpRequestOptions | undefined; const lib: HttpRequestLibrary = { @@ -40,7 +41,7 @@ function fixedLib( for (const [k, v] of Object.entries(headers)) h.set(k, v); // A minimal well-formed Taler error body, so failure branches that parse // the response body do not choke. - const body = { code: 1 }; + const body = responseBody; return { requestUrl: url, requestMethod: opt?.method ?? "GET", @@ -105,3 +106,49 @@ test("token reports 401 for a bad client secret", async (t) => { assert.ok(isOperationFail(res), "401 must be a known failure"); assert.strictEqual(res.case, HttpStatusCode.Unauthorized); }); + +test("challenge exposes helper delivery failures as known failures", async () => { + for (const status of [ + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + ]) { + const { lib } = fixedLib(status); + const client = new ChallengerHttpClient( + "https://challenger.example.com/", + lib, + ); + const result = await client.challenge("nonce", { + CONTACT_EMAIL: "alice@example.com", + }); + assert.ok(isOperationFail(result)); + assert.strictEqual(result.case, status); + } +}); + +test("solve decodes recoverable 409 and 429 responses", async () => { + const pending = { + type: "pending", + code: 9761, + hint: "No code was transmitted.", + addresses_left: 1, + pin_transmissions_left: 1, + auth_attempts_left: 1, + exhausted: false, + no_challenge: true, + }; + for (const status of [ + HttpStatusCode.Conflict, + HttpStatusCode.TooManyRequests, + ]) { + const { lib } = fixedLib(status, {}, pending); + const client = new ChallengerHttpClient( + "https://challenger.example.com/", + lib, + ); + const result = await client.solve("nonce", { pin: "1234" }); + assert.ok(isOperationFail(result)); + assert.strictEqual(result.case, status); + assert.ok("body" in result); + assert.deepStrictEqual(result.body, { ...pending, ec: undefined }); + } +}); diff --git a/packages/taler-util/src/http-client/challenger.ts b/packages/taler-util/src/http-client/challenger.ts @@ -191,14 +191,24 @@ export class ChallengerHttpClient { } case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotAcceptable: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.TooManyRequests: return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.PayloadTooLarge: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.UnsupportedMediaType: + return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.InternalServerError: return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.BadGateway: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.ServiceUnavailable: + return opKnownHttpFailure(resp.status, resp); default: return opUnknownHttpFailure(resp); } @@ -246,11 +256,25 @@ export class ChallengerHttpClient { HttpStatusCode.Forbidden, codecForChallengeInvalidPinResponse(), ); + case HttpStatusCode.Conflict: + return opKnownAlternativeHttpFailure( + resp, + HttpStatusCode.Conflict, + codecForChallengeInvalidPinResponse(), + ); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotAcceptable: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.TooManyRequests: + return opKnownAlternativeHttpFailure( + resp, + HttpStatusCode.TooManyRequests, + codecForChallengeInvalidPinResponse(), + ); + case HttpStatusCode.PayloadTooLarge: + return opKnownHttpFailure(resp.status, resp); + case HttpStatusCode.UnsupportedMediaType: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.InternalServerError: return opKnownHttpFailure(resp.status, resp); diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts @@ -80,23 +80,31 @@ type ConfigResult<T> = | { type: "incompatible"; serverVersion?: string; supported: string } | { type: "error"; error: TalerError }; -const CONFIG_FAIL_TRY_AGAIN_MS = 5000; +export type ChallengerApiProviderStatus = + | { type: "loading"; attempt: number; maxAttempts: number } + | { type: "error"; error: TalerError; retry: () => void } + | { type: "incompatible"; serverVersion?: string; supported: string }; + +const CONFIG_RETRY_DELAYS_MS = [1000, 3000] as const; const CONFIG_AUTO_RETRY_COUNT = 2; export const ChallengerApiProvider = ({ baseUrl, children, frameOnError, + renderStatus, evictors = NO_EVICTORS, }: { baseUrl: URL; children: ComponentChildren; evictors?: Evictors; frameOnError: FunctionComponent<{ children: ComponentChildren }>; + renderStatus?: (status: ChallengerApiProviderStatus) => ComponentChildren; }): VNode => { const [checked, setChecked] = useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>(); const [retryRequest, setRetryRequest] = useState(0); + const [attempt, setAttempt] = useState(0); const checkedFor = useRef<string>(); const endpointKey = baseUrl.href; const { i18n } = useTranslationContext(); @@ -111,6 +119,7 @@ export const ChallengerApiProvider = ({ let retryTimer: ReturnType<typeof setTimeout> | undefined; let attempts = 0; setChecked(undefined); + setAttempt(0); checkedFor.current = undefined; async function testConfig(): Promise<void> { try { @@ -118,6 +127,7 @@ export const ChallengerApiProvider = ({ if (!active) return; checkedFor.current = endpointKey; if (LibtoolVersion.isCompatible(VERSION, config.version)) { + setAttempt(0); setChecked({ type: "ok", config, hints: [] }); } else { setChecked({ @@ -146,9 +156,19 @@ export const ChallengerApiProvider = ({ } else { setChecked({ type: "error", error: TalerError.fromException(error) }); } - if (attempts < CONFIG_AUTO_RETRY_COUNT) { + const currentError = + error instanceof TalerError ? error : TalerError.fromException(error); + if ( + attempts < CONFIG_AUTO_RETRY_COUNT && + isRetriableConfigError(currentError) + ) { attempts++; - retryTimer = setTimeout(testConfig, CONFIG_FAIL_TRY_AGAIN_MS); + setAttempt(attempts); + setChecked(undefined); + retryTimer = setTimeout( + testConfig, + CONFIG_RETRY_DELAYS_MS[attempts - 1], + ); } } } @@ -165,40 +185,51 @@ export const ChallengerApiProvider = ({ if (currentChecked === undefined) { return h(frameOnError, { - children: h( - "div", - {}, - i18n.str`Checking compatibility of this webapp with backend service...`, - ), + children: renderStatus + ? renderStatus({ + type: "loading", + attempt, + maxAttempts: CONFIG_AUTO_RETRY_COUNT, + }) + : h( + "div", + {}, + i18n.str`Checking compatibility of this webapp with backend service...`, + ), }); } if (currentChecked.type === "error") { + const retry = () => setRetryRequest((current) => current + 1); return h(frameOnError, { - children: h(Fragment, null, [ - h(ErrorLoading, { - title: i18n.str`There was an error trying to contact the backend service.`, - error: currentChecked.error, - }), - h( - Button, - { - class: "button is-info mt-2", - onClick: () => setRetryRequest((current) => current + 1), - }, - i18n.str`Retry`, - ), - ]), + children: renderStatus + ? renderStatus({ type: "error", error: currentChecked.error, retry }) + : h(Fragment, null, [ + h(ErrorLoading, { + title: i18n.str`There was an error trying to contact the backend service.`, + error: currentChecked.error, + }), + h( + Button, + { + class: "button is-info mt-2", + onClick: retry, + }, + i18n.str`Retry`, + ), + ]), }); } if (currentChecked.type === "incompatible") { return h(frameOnError, { - children: h( - "div", - {}, - currentChecked.serverVersion - ? i18n.str`The server version is not supported. Supported version "${currentChecked.supported}", server version "${currentChecked.serverVersion}"` - : i18n.str`The server version is not supported. Supported version "${currentChecked.supported}".`, - ), + children: renderStatus + ? renderStatus(currentChecked) + : h( + "div", + {}, + currentChecked.serverVersion + ? i18n.str`The server version is not supported. Supported version "${currentChecked.supported}", server version "${currentChecked.serverVersion}"` + : i18n.str`The server version is not supported. Supported version "${currentChecked.supported}".`, + ), }); } @@ -216,6 +247,18 @@ export const ChallengerApiProvider = ({ }); }; +function isRetriableConfigError(error: TalerError): boolean { + const status = ( + error.errorDetail as TalerError["errorDetail"] & { + httpStatusCode?: unknown; + } + ).httpStatusCode; + return ( + status === undefined || + (typeof status === "number" && status >= 500 && status <= 599) + ); +} + function extractServerVersion(error: TalerError): string | undefined { const detail = error.errorDetail.detail; if (typeof detail !== "string") return undefined; @@ -247,7 +290,10 @@ function buildChallengerApiClient( const resp = await challenger.getConfig(); if (resp.type === "fail") { if (resp.detail) { - throw TalerError.fromUncheckedDetail(resp.detail); + throw TalerError.fromUncheckedDetail({ + ...resp.detail, + httpStatusCode: resp.case, + }); } else { throw TalerError.fromException( new Error("failed to get challenger remote config"),