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:
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 "{lastAddr}".
+ 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'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'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'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 "{invalidRestriction}" 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'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">
- "
- {completedURL}
- "
- </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: "{clientId}"
- </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"),