commit 340c0b36f1e339124e6530c97b426e6c611b05cc
parent 1a7c417b7ad7ce1e3463de28b1658b152f64366c
Author: Florian Dold <dold@taler.net>
Date: Thu, 27 Aug 2026 16:45:23 +0200
challenger web UI: fix verification flow and shared controls
Diffstat:
27 files changed, 949 insertions(+), 350 deletions(-)
diff --git a/packages/challenger-webui/src/Routing.tsx b/packages/challenger-webui/src/Routing.tsx
@@ -20,7 +20,7 @@ import {
useNavigationContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
+import { VNode, h } from "preact";
import { assertUnreachable } from "@gnu-taler/taler-util";
import { CheckChallengeIsUpToDate } from "./components/CheckChallengeIsUpToDate.js";
@@ -36,7 +36,7 @@ export function Routing(): VNode {
// public routing or private
return (
<Frame>
- <PublicRounting />
+ <PublicRouting />
</Frame>
);
}
@@ -48,8 +48,8 @@ const publicPages = {
answer: urlPattern(/\/answer/, () => `#/answer`),
completed: urlPattern(/\/completed/, () => `#/completed`),
setup: urlPattern<{ client: string }>(
- /\/setup\/(?<client>[0-9]+)/,
- ({ client }) => `#/setup/${client}`,
+ /\/setup\/(?<client>[^/?#]+)/,
+ ({ client }) => `#/setup/${encodeURIComponent(client)}`,
),
};
@@ -64,20 +64,23 @@ function safeGetParam(
export function safeToURL(s: string | undefined): URL | undefined {
if (s === undefined) return undefined;
try {
- return new URL(s);
- } catch (e) {
+ const url = new URL(s);
+ return url.protocol === "https:" || url.protocol === "http:"
+ ? url
+ : undefined;
+ } catch {
return undefined;
}
}
-function getSession(params: Record<string, string[]>) {
+export function getSession(params: Record<string, string[]>) {
const clientId = safeGetParam(params, "client_id");
const redirectURL = safeToURL(safeGetParam(params, "redirect_uri"));
const state = safeGetParam(params, "state");
const nonce = safeGetParam(params, "nonce");
const sessionId: SessionId | undefined =
- !clientId || !redirectURL || !state || !nonce
+ !clientId || !redirectURL || !nonce
? undefined
: {
clientId,
@@ -88,7 +91,7 @@ function getSession(params: Record<string, string[]>) {
return sessionId;
}
-function PublicRounting(): VNode {
+function PublicRouting(): VNode {
const loc = useCurrentLocation(publicPages);
const { i18n } = useTranslationContext();
const { navigateTo } = useNavigationContext();
@@ -103,7 +106,13 @@ function PublicRounting(): VNode {
switch (location.name) {
case "noinfo": {
- return <div>no info</div>;
+ return (
+ <div>
+ <i18n.Translate>
+ No challenge information is available.
+ </i18n.Translate>
+ </div>
+ );
}
case "setup": {
const secret = safeGetParam(location.params, "secret");
@@ -113,12 +122,9 @@ function PublicRounting(): VNode {
return (
<Setup
- clientId={location.values.client}
+ clientId={safeDecodeURIComponent(location.values.client)}
secret={secret}
redirectURL={redirectURL}
- onCreated={() => {
- navigateTo(publicPages.ask.url({}));
- }}
/>
);
}
@@ -126,30 +132,32 @@ function PublicRounting(): VNode {
const sessionId = getSession(location.params);
if (!sessionId) {
- return (
- <div>
- <i18n.Translate>
- The application needs to be loaded with 4 request parameters. One
- or more are missing:
- </i18n.Translate>
- {JSON.stringify({ params: location.params }, undefined, 2)}
- </div>
- );
+ return <MissingSessionParameters />;
}
return (
<CheckChallengeIsUpToDate
session={sessionId}
- onCompleted={() => {
- navigateTo(publicPages.completed.url({}));
- }}
- onChangeLeft={() => {
- navigateTo(publicPages.ask.url({}));
- }}
- onNoMoreChanges={() => {
- navigateTo(publicPages.ask.url({}));
+ onStatus={(nextPage) => {
+ switch (nextPage) {
+ case "ask":
+ navigateTo(publicPages.ask.url({}));
+ return;
+ case "answer":
+ navigateTo(publicPages.answer.url({}));
+ return;
+ case "completed":
+ navigateTo(publicPages.completed.url({}));
+ return;
+ case "exhausted":
+ return;
+ default:
+ assertUnreachable(nextPage);
+ }
}}
>
- <i18n.Translate>No nonce has been found</i18n.Translate>
+ <i18n.Translate>
+ No verification attempts or address changes remain.
+ </i18n.Translate>
</CheckChallengeIsUpToDate>
);
}
@@ -157,15 +165,7 @@ function PublicRounting(): VNode {
const sessionId = getSession(location.params);
if (!sessionId) {
- return (
- <div>
- <i18n.Translate>
- The application needs to be loaded with 4 request parameters. One
- or more are missing:
- </i18n.Translate>
- {JSON.stringify({ params: location.params }, undefined, 2)}
- </div>
- );
+ return <MissingSessionParameters />;
}
return (
<AskChallenge
@@ -175,6 +175,9 @@ function PublicRounting(): VNode {
onSendSuccesful={() => {
navigateTo(publicPages.answer.url({}));
}}
+ onComplete={() => {
+ navigateTo(publicPages.completed.url({}));
+ }}
/>
);
}
@@ -182,15 +185,7 @@ function PublicRounting(): VNode {
const sessionId = getSession(location.params);
if (!sessionId) {
- return (
- <div>
- <i18n.Translate>
- The application needs to be loaded with 4 request parameters. One
- or more are missing:
- </i18n.Translate>
- {JSON.stringify({ params: location.params }, undefined, 2)}
- </div>
- );
+ return <MissingSessionParameters />;
}
return (
@@ -205,9 +200,31 @@ function PublicRounting(): VNode {
);
}
case "completed": {
- return <CallengeCompleted />;
+ const sessionId = getSession(location.params);
+ if (!sessionId) return <MissingSessionParameters />;
+ return <CallengeCompleted session={sessionId} />;
}
default:
assertUnreachable(location);
}
}
+
+function safeDecodeURIComponent(value: string): string {
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return value;
+ }
+}
+
+function MissingSessionParameters(): VNode {
+ const { i18n } = useTranslationContext();
+ 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>
+ );
+}
diff --git a/packages/challenger-webui/src/app.tsx b/packages/challenger-webui/src/app.tsx
@@ -73,7 +73,7 @@ export function App(): VNode {
<TranslationProvider source={strings}>
<NotificationProvider>
<ChallengerApiProvider
- baseUrl={new URL("/", baseUrl)}
+ baseUrl={new URL(baseUrl)}
frameOnError={Frame}
evictors={{
challenger: evictBankSwrCache,
@@ -86,7 +86,7 @@ export function App(): VNode {
: undefined,
// normally, do not revalidate
revalidateOnFocus: false,
- revalidateOnReconnect: false,
+ revalidateOnReconnect: true,
revalidateIfStale: false,
revalidateOnMount: undefined,
focusThrottleInterval: undefined,
@@ -103,7 +103,7 @@ export function App(): VNode {
errorRetryInterval: undefined,
// do not go to loading again if already has data
- keepPreviousData: true,
+ keepPreviousData: false,
}}
>
<TalerWalletIntegrationBrowserProvider>
@@ -134,7 +134,7 @@ function localStorageProvider(): Map<unknown, unknown> {
return map;
}
-function getInitialBackendBaseURL(
+export function getInitialBackendBaseURL(
backendFromSettings: string | undefined,
): string {
const overrideUrl =
@@ -159,7 +159,7 @@ function getInitialBackendBaseURL(
}
try {
return canonicalizeBaseUrl(result);
- } catch (e) {
+ } catch {
// fall back
return canonicalizeBaseUrl(window.origin);
}
diff --git a/packages/challenger-webui/src/challenger-flow.test.ts b/packages/challenger-webui/src/challenger-flow.test.ts
@@ -0,0 +1,84 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { getSession, safeToURL } from "./Routing.js";
+import { getChallengeNextPage } from "./components/CheckChallengeIsUpToDate.js";
+import { getSessionStateKey } from "./hooks/session.js";
+import {
+ findInvalidRestriction,
+ getLocalizedRestrictionHint,
+ INT_PHONE_REGEX,
+} from "./pages/AskChallenge.js";
+import { safeRedirectURL } from "./pages/CallengeCompleted.js";
+
+function status(
+ overrides: Record<string, unknown> = {},
+): Parameters<typeof getChallengeNextPage>[0] {
+ return {
+ fix_address: false,
+ changes_left: 1,
+ solved: false,
+ retransmission_time: { t_s: 0 },
+ pin_transmissions_left: 1,
+ auth_attempts_left: 1,
+ ...overrides,
+ } as Parameters<typeof getChallengeNextPage>[0];
+}
+
+test("challenge status routes to the correct next page", () => {
+ assert.equal(getChallengeNextPage(status({ solved: true })), "completed");
+ assert.equal(
+ getChallengeNextPage(status({ last_address: { email: "a@b.example" } })),
+ "answer",
+ );
+ assert.equal(getChallengeNextPage(status()), "ask");
+ assert.equal(
+ getChallengeNextPage(status({ changes_left: 0, auth_attempts_left: 0 })),
+ "exhausted",
+ );
+});
+
+test("OAuth state is optional and persisted state is session-scoped", () => {
+ const session = getSession({
+ client_id: ["client/with spaces"],
+ redirect_uri: ["https://client.example/callback"],
+ nonce: ["nonce"],
+ });
+ assert.ok(session);
+ assert.equal(session.state, undefined);
+ assert.match(getSessionStateKey(session), /client%2Fwith%20spaces/);
+ assert.notEqual(
+ getSessionStateKey(session),
+ getSessionStateKey({ ...session, nonce: "another" }),
+ );
+ assert.notEqual(
+ getSessionStateKey({ ...session, clientId: "a-b", nonce: "c" }),
+ getSessionStateKey({ ...session, clientId: "a", nonce: "b-c" }),
+ );
+ assert.equal(
+ getSession({
+ client_id: ["client"],
+ redirect_uri: ["not a URL"],
+ nonce: ["nonce"],
+ }),
+ undefined,
+ );
+ assert.equal(safeToURL("javascript:alert(1)"), undefined);
+ assert.equal(safeRedirectURL("data:text/html,unsafe"), undefined);
+ assert.equal(
+ safeRedirectURL("https://client.example/callback"),
+ "https://client.example/callback",
+ );
+});
+
+test("server restrictions are localized and invalid expressions are rejected", () => {
+ const restriction = {
+ hint: "fallback",
+ hint_i18n: { de: "Deutsch", en: "English" },
+ };
+ assert.equal(getLocalizedRestrictionHint(restriction, "de-DE"), "Deutsch");
+ assert.equal(getLocalizedRestrictionHint(restriction, "es"), "English");
+ assert.equal(findInvalidRestriction({ email: { regex: "[" } }), "email");
+ assert.equal(findInvalidRestriction({ email: { regex: "^.+$" } }), undefined);
+ assert.equal(INT_PHONE_REGEX.test("+123456789012345"), true);
+ assert.equal(INT_PHONE_REGEX.test("+1234567890123456"), false);
+});
diff --git a/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx
@@ -14,48 +14,74 @@
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 { useEffect, useRef } from "preact/hooks";
import { useChallengeSession } from "../hooks/challenge.js";
-import { SessionId, useSessionState } from "../hooks/session.js";
+import { SessionId } from "../hooks/session.js";
+
+export type ChallengeNextPage = "ask" | "answer" | "completed" | "exhausted";
+
+export function getChallengeNextPage(
+ status: ChallengerApi.ChallengeStatus,
+): ChallengeNextPage {
+ if (status.solved) return "completed";
+ if (status.last_address && status.auth_attempts_left > 0) return "answer";
+ if (status.changes_left > 0) return "ask";
+ return "exhausted";
+}
interface Props {
session: SessionId;
children: ComponentChildren;
- onCompleted?: () => void;
- onChangeLeft?: () => void;
- onNoMoreChanges?: () => void;
+ onStatus?: (nextPage: ChallengeNextPage) => void;
}
export function CheckChallengeIsUpToDate({
session,
children,
- onCompleted,
- onChangeLeft,
- onNoMoreChanges,
+ onStatus,
}: Props): VNode {
- const { state } = useSessionState();
const { i18n } = useTranslationContext();
+ const { result, retry } = useChallengeSession(session);
+ const onStatusRef = useRef(onStatus);
+ onStatusRef.current = onStatus;
+
+ const nextPage =
+ result && !(result instanceof TalerError) && result.type === "ok"
+ ? getChallengeNextPage(result.body)
+ : undefined;
- const result = useChallengeSession(session);
+ useEffect(() => {
+ if (nextPage && nextPage !== "exhausted") {
+ onStatusRef.current?.(nextPage);
+ }
+ }, [nextPage]);
if (!result) {
return <Loading />;
}
if (result instanceof TalerError) {
return (
- <ErrorLoading
- title={i18n.str`Failed to load the session.`}
- error={result}
- />
+ <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>
);
}
@@ -88,9 +114,14 @@ export function CheckChallengeIsUpToDate({
}
case HttpStatusCode.InternalServerError: {
return (
- <Attention type="danger" title={i18n.str`Internal error`}>
- <i18n.Translate>Check logs</i18n.Translate>
- </Attention>
+ <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: {
@@ -106,9 +137,11 @@ export function CheckChallengeIsUpToDate({
</i18n.Translate>
</Attention>
- <div class="mt-2">
- <a href={session.redirectURL ?? ""}>{session.redirectURL}</a>
- </div>
+ {session.redirectURL ? (
+ <div class="mt-2">
+ <a href={session.redirectURL}>{session.redirectURL}</a>
+ </div>
+ ) : undefined}
</Fragment>
);
}
@@ -117,18 +150,7 @@ export function CheckChallengeIsUpToDate({
}
}
- if (onCompleted && result.body.solved) {
- onCompleted();
- return <Loading />;
- }
-
- if (onNoMoreChanges && !result.body.changes_left) {
- onNoMoreChanges();
- return <Loading />;
- }
-
- if (onChangeLeft && result.body.changes_left) {
- onChangeLeft();
+ if (nextPage && nextPage !== "exhausted") {
return <Loading />;
}
diff --git a/packages/challenger-webui/src/context/preferences.ts b/packages/challenger-webui/src/context/preferences.ts
@@ -28,18 +28,16 @@ import {
interface Preferences {
showChallangeSetup: boolean;
- showDebugInfo: boolean;
}
export const codecForPreferences = (): Codec<Preferences> =>
buildCodecForObject<Preferences>()
+ .allowExtra()
.property("showChallangeSetup", codecForBoolean())
- .property("showDebugInfo", codecForBoolean())
.build("Preferences");
const defaultPreferences: Preferences = {
showChallangeSetup: false,
- showDebugInfo: false,
};
const PREFERENCES_KEY = buildStorageKey(
@@ -68,7 +66,7 @@ export function usePreferences(): [
}
export function getAllBooleanPreferences(): Array<keyof Preferences> {
- return ["showChallangeSetup", "showDebugInfo"];
+ return ["showChallangeSetup"];
}
export function getLabelForPreferences(
@@ -78,7 +76,5 @@ export function getLabelForPreferences(
switch (k) {
case "showChallangeSetup":
return i18n.str`Show challenger setup screen`;
- case "showDebugInfo":
- return i18n.str`Show debug info`;
}
}
diff --git a/packages/challenger-webui/src/hooks/challenge.ts b/packages/challenger-webui/src/hooks/challenge.ts
@@ -30,9 +30,10 @@ export function revalidateChallengeSession() {
);
}
-export function useChallengeSession(
- session: SessionId,
-): ChallengerResultByMethod<"login"> | undefined | TalerHttpError {
+export function useChallengeSession(session: SessionId): {
+ result: ChallengerResultByMethod<"login"> | undefined | TalerHttpError;
+ retry: () => Promise<ChallengerResultByMethod<"login"> | undefined>;
+} {
const {
lib: { challenger: api },
} = useChallengerApiContext();
@@ -40,18 +41,25 @@ export function useChallengeSession(
async function fetcher([s]: [SessionId]) {
return await api.login(s.nonce, s.clientId, s.redirectURL, s.state);
}
- const { data, error } = useSWR<
- ChallengerResultByMethod<"login">,
- TalerHttpError
- >(!session ? undefined : [session, "login"], fetcher, {
- revalidateIfStale: false,
- errorRetryCount: 0,
- errorRetryInterval: 1,
- shouldRetryOnError: false,
- keepPreviousData: true,
- });
+ const {
+ data,
+ error,
+ mutate: retry,
+ } = useSWR<ChallengerResultByMethod<"login">, TalerHttpError>(
+ !session ? undefined : [session, "login"],
+ fetcher,
+ {
+ revalidateIfStale: false,
+ revalidateOnReconnect: true,
+ errorRetryCount: 2,
+ errorRetryInterval: 1000,
+ shouldRetryOnError: true,
+ keepPreviousData: false,
+ },
+ );
- if (data) return data;
- if (error) return error;
- return undefined;
+ return {
+ result: data ?? error,
+ retry,
+ };
}
diff --git a/packages/challenger-webui/src/hooks/session.ts b/packages/challenger-webui/src/hooks/session.ts
@@ -36,7 +36,7 @@ export type SessionId = {
nonce: string;
clientId: string;
redirectURL: string;
- state: string;
+ state?: string;
};
export type LastChallengeResponse = {
@@ -73,7 +73,6 @@ export const codecForSessionState = (): Codec<SessionState> =>
export interface SessionStateHandler {
state: SessionState | undefined;
- start(): void;
saveAddress(type: string, address: Record<string, string>): void;
removeAddress(index: number): void;
sent(info: ChallengerApi.ChallengeCreateResponse): void;
@@ -86,22 +85,26 @@ const SESSION_STATE_KEY = buildStorageKey(
codecForSessionState(),
);
+export function getSessionStateKey(session: SessionId): string {
+ return `challenger-session-${encodeURIComponent(
+ JSON.stringify([session.clientId, session.nonce]),
+ )}`;
+}
+
/**
* Return getters and setters for
* login credentials and backend's
* base URL.
*/
-export function useSessionState(): SessionStateHandler {
- const { value: state, update } = useLocalStorage(SESSION_STATE_KEY);
+export function useSessionState(session: SessionId): SessionStateHandler {
+ const sessionStateKey = {
+ ...SESSION_STATE_KEY,
+ id: getSessionStateKey(session),
+ };
+ const { value: state, update } = useLocalStorage(sessionStateKey);
return {
state,
- start() {
- update({
- completedURL: undefined,
- lastAddress: state?.lastAddress ?? [],
- });
- },
removeAddress(index) {
const lastAddr = [...(state?.lastAddress ?? [])];
lastAddr.splice(index, 1);
diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx
@@ -20,11 +20,15 @@ import {
HttpStatusCode,
TalerError,
TalerFormAttributes,
+ TranslatedString,
assertUnreachable,
} from "@gnu-taler/taler-util";
import {
Attention,
AsyncButton,
+ Button,
+ ErrorLoading,
+ Loading,
RouteDefinition,
ShowInputErrorLabel,
Time,
@@ -35,7 +39,7 @@ import {
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useMemo } from "preact/compat";
-import { useEffect, useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import {
revalidateChallengeSession,
useChallengeSession,
@@ -95,15 +99,16 @@ export function AnswerChallenge({
}: Props): VNode {
const { config, lib } = useChallengerApiContext();
const { i18n } = useTranslationContext();
- const { sent, failed, completed } = useSessionState();
- const { showError } = useNotificationContext();
+ const { sent, failed, completed } = useSessionState(session);
+ const { showError, displayError } = useNotificationContext();
const [pin, setPin] = useState<string | undefined>();
+ const pinInput = useRef<HTMLInputElement | null>(null);
const errors = undefinedIfEmpty({
pin: !pin ? i18n.str`Can't be empty` : undefined,
});
- const result = useChallengeSession(session);
+ const { result, retry } = useChallengeSession(session);
const lastStatus =
result && !(result instanceof TalerError) && result.type !== "fail"
@@ -117,7 +122,7 @@ export function AnswerChallenge({
return !deadlineTS
? AbsoluteTime.never()
: AbsoluteTime.fromProtocolTimestamp(deadlineTS);
- }, [deadlineTS?.t_s]);
+ }, [deadlineTS]);
useReloadOnDeadline(deadline);
@@ -146,6 +151,7 @@ export function AnswerChallenge({
onSuccess(success) {
if (success.type === "completed") {
completed(success);
+ onComplete();
} else {
sent(success);
}
@@ -183,25 +189,37 @@ export function AnswerChallenge({
onSuccess(success) {
if (success.type === "completed") {
completed(success);
+ onComplete();
} else {
failed(success);
+ setPin(undefined);
+ pinInput.current?.focus();
+ void revalidateChallengeSession();
+ displayError(
+ i18n.str`Failed to solve the challenge.`,
+ success,
+ success.hint as TranslatedString,
+ );
}
- onComplete();
},
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: {
- revalidateChallengeSession();
- return i18n.str`Invalid TAN code.`;
+ 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: {
- revalidateChallengeSession();
+ void revalidateChallengeSession();
return i18n.str`There have been too many attempts to send the TAN code.`;
}
case HttpStatusCode.InternalServerError:
@@ -211,17 +229,49 @@ export function AnswerChallenge({
}
}),
});
+
+ if (!result) return <Loading />;
+ 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>
+ );
+ }
+ 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>
+ );
+ }
const cantTryAnymore = lastStatus?.auth_attempts_left === 0;
function LastContactSent(): VNode {
return (
<p class="mt-2 text-lg leading-8 text-gray-600">
- {!lastStatus ||
- AbsoluteTime.isExpired(deadline) ||
- AbsoluteTime.isNever(deadline) ? (
+ {!lastStatus ? (
+ <i18n.Translate>
+ The challenge details are currently unavailable.
+ </i18n.Translate>
+ ) : AbsoluteTime.isExpired(deadline) ||
+ AbsoluteTime.isNever(deadline) ? (
<i18n.Translate>
- The last TAN code sent to "{lastAddr}
- " is no longer valid.
+ You may request a new TAN code for "{lastAddr}".
</i18n.Translate>
) : (
<Attention title={i18n.str`A TAN code was sent to "${lastAddr}"`}>
@@ -240,13 +290,18 @@ export function AnswerChallenge({
return (
<div class="mx-auto mt-4 max-w-xl flex justify-between">
<div>
- <a
- data-disabled={unableToChangeAddr}
- href={unableToChangeAddr ? undefined : routeAsk.url({})}
- class="relative data-[disabled=true]:bg-gray-300 data-[disabled=true]:text-white data-[disabled=true]:cursor-default 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>
+ {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 ? (
@@ -355,21 +410,27 @@ export function AnswerChallenge({
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
- for="pin"
+ 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
- autoFocus
- ref={focus ? doAutoFocus : undefined}
- type="number"
+ 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}
@@ -392,7 +453,14 @@ export function AnswerChallenge({
<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 ? () => check.run(...checkArgs) : undefined}
+ onClick={
+ checkArgs
+ ? (event) => {
+ event.preventDefault();
+ return check.run(...checkArgs);
+ }
+ : undefined
+ }
>
<i18n.Translate>Check</i18n.Translate>
</AsyncButton>
diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx
@@ -44,6 +44,7 @@ import { getAddressDescriptionFromAddrType } from "./AnswerChallenge.js";
type Props = {
onSendSuccesful: () => void;
+ onComplete: () => void;
session: SessionId;
routeSolveChallenge: RouteDefinition<EmptyObject>;
focus?: boolean;
@@ -51,7 +52,8 @@ type Props = {
export function AskChallenge(props: Props): VNode {
const { i18n } = useTranslationContext();
- const result = useChallengeSession(props.session);
+ const { config } = useChallengerApiContext();
+ const { result, retry } = useChallengeSession(props.session);
if (!result) {
return (
@@ -62,10 +64,20 @@ export function AskChallenge(props: Props): VNode {
}
if (result instanceof TalerError) {
return (
- <ErrorLoading
- title={i18n.str`Failed to load the session.`}
- error={result}
- />
+ <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>
);
}
if (result.type === "fail") {
@@ -122,32 +134,44 @@ export function AskChallenge(props: Props): VNode {
}
}
}
+ const invalidRestriction = findInvalidRestriction(config.restrictions ?? {});
+ if (invalidRestriction) {
+ 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>
+ );
+ }
return <AskChallengeInternal {...props} lastStatus={result.body} />;
}
function AskChallengeInternal({
onSendSuccesful,
+ onComplete,
routeSolveChallenge,
session,
focus,
lastStatus,
}: Props & { lastStatus: ChallengerApi.ChallengeStatus }): VNode {
- const { sent, completed } = useSessionState();
+ const { sent, completed } = useSessionState(session);
const { lib, config } = useChallengerApiContext();
- const { i18n } = useTranslationContext();
+ const { i18n, lang } = useTranslationContext();
const { showError } = useNotificationContext();
- const initial = lastStatus.last_address ?? {};
+ const initial = { ...(lastStatus.last_address ?? {}) };
if (config.address_type === "postal-ch") {
initial[TalerFormAttributes.ADDRESS_COUNTRY] = "CH";
}
const design = getFormDesignBasedOnAddressType(
i18n,
+ lang,
config.address_type,
config.restrictions ?? {},
- initial,
lastStatus.fix_address,
);
const form = useForm(design, initial);
@@ -176,10 +200,11 @@ function AskChallengeInternal({
onSuccess(ok) {
if (ok.type === "completed") {
completed(ok);
+ onComplete();
} else {
sent(ok);
+ onSendSuccesful();
}
- onSendSuccesful();
},
onFail: showError(i18n.str`Failed to create a challenge.`, (fail) => {
switch (fail.case) {
@@ -347,7 +372,7 @@ 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 = /^\+?\d{1,3}\d{6,14}$/; // E.164 International Phone Number
+export const INT_PHONE_REGEX = /^\+?[1-9]\d{6,14}$/; // E.164 International Phone Number
export const US_PHONE_REGEX =
/^(\+\d{1,2}\s?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}$/;
export const AR_PHONE_REGEX =
@@ -358,17 +383,44 @@ export const CONTACT_REGEX = /.*/;
export const ZIPCODE_REGEX = /.*/;
export const ADDR_LINES_REGEX = /.*/;
+export function getLocalizedRestrictionHint(
+ serverConfig: ChallengerApi.Restriction | undefined,
+ lang: string,
+): string | undefined {
+ const translated = serverConfig?.hint_i18n;
+ const baseLang = lang.split(/[-_]/)[0];
+ return (
+ translated?.[lang] ??
+ translated?.[baseLang] ??
+ translated?.en ??
+ serverConfig?.hint
+ );
+}
+
+export function findInvalidRestriction(
+ restrictions: Record<string, ChallengerApi.Restriction | undefined>,
+): string | undefined {
+ for (const [field, restriction] of Object.entries(restrictions)) {
+ if (!restriction?.regex) continue;
+ try {
+ new RegExp(restriction.regex);
+ } catch {
+ return field;
+ }
+ }
+ return undefined;
+}
+
function getRestriction(
i18n: InternationalizationAPI,
+ lang: string,
serverConfig: ChallengerApi.Restriction | undefined,
fallback?: RegExp,
): { regex: undefined | RegExp; hint: TranslatedString } {
const regexText =
serverConfig && serverConfig.regex ? serverConfig.regex : undefined;
- const hint =
- serverConfig && serverConfig.hint
- ? (serverConfig.hint as TranslatedString)
- : i18n.str`Invalid field`;
+ const hint = (getLocalizedRestrictionHint(serverConfig, lang) ??
+ i18n.str`Invalid field`) as TranslatedString;
let regex;
if (regexText) {
@@ -394,9 +446,9 @@ function getRestriction(
}
function getFormDesignBasedOnAddressType(
i18n: InternationalizationAPI,
+ lang: string,
type: ChallengerApi.ChallengerTermsOfServiceResponse["address_type"],
restrictions: Record<string, ChallengerApi.Restriction | undefined>,
- prevValue: Record<string, string>,
read_only: boolean,
): FormDesign {
switch (type) {
@@ -413,6 +465,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.CONTACT_EMAIL],
EMAIL_REGEX,
);
@@ -441,6 +494,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.CONTACT_PHONE],
PHONE_REGEX,
);
@@ -470,6 +524,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.CONTACT_NAME],
CONTACT_REGEX,
);
@@ -489,6 +544,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.ADDRESS_LINES],
ADDR_LINES_REGEX,
);
@@ -509,6 +565,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.ADDRESS_COUNTRY],
);
if (restriction.regex && !restriction.regex.test(text)) {
@@ -534,6 +591,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.CONTACT_NAME],
);
if (restriction.regex && !restriction.regex.test(text)) {
@@ -553,6 +611,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.ADDRESS_LINES],
);
if (restriction.regex && !restriction.regex.test(text)) {
@@ -572,6 +631,7 @@ function getFormDesignBasedOnAddressType(
validator(text) {
const restriction = getRestriction(
i18n,
+ lang,
restrictions[TalerFormAttributes.ADDRESS_COUNTRY],
);
if (restriction.regex && !restriction.regex.test(text)) {
diff --git a/packages/challenger-webui/src/pages/CallengeCompleted.tsx b/packages/challenger-webui/src/pages/CallengeCompleted.tsx
@@ -14,27 +14,45 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
+import { VNode, h } from "preact";
import { useSessionState } from "../hooks/session.js";
import { useEffect } from "preact/hooks";
+import { SessionId } from "../hooks/session.js";
-export function CallengeCompleted(): VNode {
- const { state } = useSessionState();
+export function CallengeCompleted({ session }: { session: SessionId }): VNode {
+ const { state } = useSessionState(session);
const { i18n } = useTranslationContext();
+ const completedURL = safeRedirectURL(state?.completedURL);
+
useEffect(() => {
- window.location.href = state?.completedURL ?? "#";
- }, []);
+ if (completedURL) {
+ window.location.href = 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>
+ );
+ }
return (
<div class="m-4">
<Attention title={i18n.str`Challenge completed`} type="success">
<i18n.Translate>
You will be redirected to{" "}
- <a href={state?.completedURL} class="break-all">
+ <a href={completedURL} class="break-all">
"
- {state?.completedURL}
+ {completedURL}
"
</a>
</i18n.Translate>
@@ -42,3 +60,15 @@ export function CallengeCompleted(): VNode {
</div>
);
}
+
+export function safeRedirectURL(value: string | undefined): string | undefined {
+ if (!value) return undefined;
+ try {
+ const url = new URL(value);
+ return url.protocol === "https:" || url.protocol === "http:"
+ ? url.href
+ : undefined;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/packages/challenger-webui/src/pages/Frame.tsx b/packages/challenger-webui/src/pages/Frame.tsx
@@ -18,10 +18,11 @@ import {
Footer,
Header,
ToastBanner,
+ useCommonPreferences,
useRenderErrorReport,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { ComponentChildren, VNode, h } from "preact";
import {
getAllBooleanPreferences,
getLabelForPreferences,
@@ -33,6 +34,7 @@ const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
export function Frame({ children }: { children: ComponentChildren }): VNode {
const [preferences, updatePreferences] = usePreferences();
+ const [commonPreferences, updateCommonPreferences] = useCommonPreferences();
const { i18n } = useTranslationContext();
@@ -51,14 +53,16 @@ export function Frame({ children }: { children: ComponentChildren }): VNode {
onLogout={undefined}
iconLinkURL="#"
sites={
- preferences.showChallangeSetup ? [["New challenge", "#/setup/1"]] : []
+ 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>
- <ul role="list" class="space-y-4">
+ <ul class="space-y-4">
{getAllBooleanPreferences().map((set) => {
const isOn: boolean = !!preferences[set];
return (
@@ -67,7 +71,7 @@ export function Frame({ children }: { children: ComponentChildren }): VNode {
<span class="flex flex-grow flex-col">
<span
class="text-sm text-black font-medium leading-6 "
- id="availability-label"
+ id={`${set}-label`}
>
{getLabelForPreferences(set, i18n)}
</span>
@@ -78,9 +82,8 @@ export function Frame({ children }: { children: ComponentChildren }): VNode {
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="false"
- aria-labelledby="availability-label"
- aria-describedby="availability-description"
+ aria-checked={isOn}
+ aria-labelledby={`${set}-label`}
onClick={() => {
updatePreferences(set, !isOn);
}}
@@ -95,6 +98,36 @@ export function Frame({ children }: { children: ComponentChildren }): VNode {
</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>
diff --git a/packages/challenger-webui/src/pages/NonceNotFound.tsx b/packages/challenger-webui/src/pages/NonceNotFound.tsx
@@ -16,10 +16,6 @@
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
-type Form = {
- email: string;
-};
-
export function NonceNotFound(): VNode {
const { i18n } = useTranslationContext();
diff --git a/packages/challenger-webui/src/pages/Setup.tsx b/packages/challenger-webui/src/pages/Setup.tsx
@@ -32,28 +32,19 @@ import {
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { safeToURL } from "../Routing.js";
-import { useSessionState } from "../hooks/session.js";
import { doAutoFocus, undefinedIfEmpty } from "./AnswerChallenge.js";
type Props = {
clientId: string;
secret: string | undefined;
redirectURL: URL | undefined;
- onCreated: () => void;
focus?: boolean;
};
-export function Setup({
- clientId,
- secret,
- redirectURL,
- focus,
- onCreated,
-}: Props): VNode {
+export function Setup({ clientId, secret, redirectURL, focus }: Props): VNode {
const { i18n } = useTranslationContext();
const { lib } = useChallengerApiContext();
const { showError } = useNotificationContext();
- const { start } = useSessionState();
const [password, setPassword] = useState<string | undefined>(secret);
const [url, setUrl] = useState<string | undefined>(redirectURL?.href);
@@ -75,15 +66,13 @@ export function Setup({
[AccessToken, string]
>((ct, token, url) => lib.challenger.setup(clientId, token), {
onSuccess(ok, token, redirect_uri) {
- start();
const redirect = new URL(window.location.href);
redirect.searchParams.set("client_id", clientId);
redirect.searchParams.set("redirect_uri", redirect_uri);
redirect.searchParams.set("state", encodeCrock(randomBytes(32)));
redirect.searchParams.set("nonce", ok.nonce);
- redirect.hash = "";
+ redirect.hash = "/ask";
window.location.href = redirect.href;
- onCreated();
},
onFail: showError(i18n.str`Failed to setup a new challenge.`, (fail) => {
switch (fail.case) {
@@ -111,11 +100,12 @@ export function Setup({
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
- for="email"
+ htmlFor="password"
class="block text-sm font-semibold leading-6 text-gray-900"
>
<i18n.Translate>Password</i18n.Translate>
@@ -127,7 +117,7 @@ export function Setup({
id="password"
ref={focus ? doAutoFocus : undefined}
maxLength={512}
- autocomplete="password"
+ autocomplete="current-password"
value={password}
onChange={(e) => {
setPassword(e.currentTarget.value);
@@ -144,7 +134,7 @@ export function Setup({
<div class="sm:col-span-2">
<label
- for="email"
+ htmlFor="redirect_url"
class="block text-sm font-semibold leading-6 text-gray-900"
>
<i18n.Translate>Redirect URL</i18n.Translate>
@@ -155,7 +145,7 @@ export function Setup({
name="redirect_url"
id="redirect_url"
maxLength={512}
- autocomplete="redirect_url"
+ autocomplete="url"
value={url}
onChange={(e) => {
setUrl(e.currentTarget.value);
@@ -173,7 +163,14 @@ export function Setup({
<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 ? () => doStart.run(...startArgs) : undefined}
+ onClick={
+ startArgs
+ ? (event) => {
+ event.preventDefault();
+ return doStart.run(...startArgs);
+ }
+ : undefined
+ }
>
<i18n.Translate>Start</i18n.Translate>
</AsyncButton>
diff --git a/packages/challenger-webui/tsconfig.json b/packages/challenger-webui/tsconfig.json
@@ -7,6 +7,7 @@
"jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
+ "types": ["node"],
"noEmit": true, /* Do not emit outputs. */
"skipLibCheck": true /* Skip type checking of declaration files. */
},
diff --git a/packages/web-util/src/components/Accessibility.test.tsx b/packages/web-util/src/components/Accessibility.test.tsx
@@ -0,0 +1,82 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { i18n, setupI18n } from "@gnu-taler/taler-util";
+import { Window } from "happy-dom";
+import { h } from "preact";
+import { act } from "preact/test-utils";
+import { InputSelectOne } from "../forms/fields/InputSelectOne.js";
+import { Loading } from "./Loading.js";
+
+setupI18n("en", {});
+
+function installDom(): Window {
+ const window = new Window({ url: "https://example.com/" });
+ for (const [key, value] of Object.entries({
+ window,
+ document: window.document,
+ navigator: window.navigator,
+ Node: window.Node,
+ Element: window.Element,
+ Event: window.Event,
+ KeyboardEvent: window.KeyboardEvent,
+ HTMLElement: window.HTMLElement,
+ HTMLInputElement: window.HTMLInputElement,
+ MutationObserver: window.MutationObserver,
+ })) {
+ Object.defineProperty(globalThis, key, {
+ configurable: true,
+ writable: true,
+ value,
+ });
+ }
+ return window;
+}
+
+test("loading indicator exposes an accessible status", async () => {
+ const window = installDom();
+ const { cleanup, render } = await import("@testing-library/preact");
+ const view = render(<Loading />);
+ assert.match(view.getByRole("status").textContent, /Loading/);
+ cleanup();
+ await window.happyDOM.abort();
+});
+
+test("single-select choices can be selected with the keyboard", async () => {
+ const window = installDom();
+ const { cleanup, render } = await import("@testing-library/preact");
+ let selected: string | undefined;
+ const view = render(
+ <InputSelectOne
+ name="country"
+ label={i18n.str`Country`}
+ choices={[
+ { value: "DE", label: i18n.str`Germany` },
+ { value: "CH", label: i18n.str`Switzerland` },
+ ]}
+ handler={{
+ name: "country",
+ value: undefined,
+ onChange(value) {
+ selected = value;
+ },
+ }}
+ />,
+ );
+ const input = view.getByRole("combobox");
+ await act(() => {
+ input.focus();
+ });
+ await act(() => {
+ input.dispatchEvent(
+ new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
+ );
+ });
+ await act(() => {
+ input.dispatchEvent(
+ new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ });
+ assert.equal(selected, "CH");
+ cleanup();
+ await window.happyDOM.abort();
+});
diff --git a/packages/web-util/src/components/Attention.tsx b/packages/web-util/src/components/Attention.tsx
@@ -3,9 +3,10 @@ import {
TranslatedString,
assertUnreachable,
} from "@gnu-taler/taler-util";
-import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { ComponentChildren, VNode, h } from "preact";
import { useRef } from "preact/hooks";
import { composeRef, saveRef } from "./utils.js";
+import { useTranslationContext } from "../context/translation.js";
interface Props {
type?: "info" | "success" | "warning" | "danger" | "error" | "low";
@@ -14,6 +15,7 @@ interface Props {
children?: ComponentChildren;
timeout?: Duration;
copy?: boolean;
+ copyText?: string;
}
export function Attention({
type = "info",
@@ -21,12 +23,17 @@ export function Attention({
children,
onClose,
copy,
+ copyText,
timeout = Duration.getForever(),
}: Props): VNode {
+ const { i18n } = useTranslationContext();
const divHtml = useRef<HTMLDivElement>();
if (type == "error") type = "danger";
return (
- <div class={`z-100 group attention-${type} mt-2 shadow-lg`}>
+ <div
+ class={`z-100 group attention-${type} mt-2 shadow-lg`}
+ role={type === "danger" ? "alert" : "status"}
+ >
{/* {timeout.d_ms === "forever" ? undefined : <style>{`
.progress {
animation: notificationTimeoutBar ${Math.round(timeout.d_ms / 1000)}s ease-in-out;
@@ -108,11 +115,13 @@ export function Attention({
class="font-semibold items-center rounded bg-transparent px-2 py-1 text-xs text-gray-900 hover:bg-gray-50"
onClick={(e) => {
e.preventDefault();
-
- navigator.clipboard.writeText(
- fromNodeToText(divHtml.current),
- );
+ void navigator.clipboard
+ ?.writeText(copyText ?? fromNodeToText(divHtml.current))
+ .catch((error) =>
+ console.error("Could not copy notification", error),
+ );
}}
+ aria-label={i18n.str`Copy details`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -138,6 +147,7 @@ export function Attention({
e.preventDefault();
onClose();
}}
+ aria-label={i18n.str`Dismiss notification`}
>
<svg
class="h-5 w-5"
diff --git a/packages/web-util/src/components/ErrorLoading.tsx b/packages/web-util/src/components/ErrorLoading.tsx
@@ -21,7 +21,7 @@ import {
TalerError,
TranslatedString,
} from "@gnu-taler/taler-util";
-import { Fragment, VNode, h } from "preact";
+import { VNode, h } from "preact";
import { useCommonPreferences } from "../context/common-preferences.js";
import { useTranslationContext } from "../context/translation.js";
import { translateTalerError } from "../hooks/useNotifications.js";
@@ -37,24 +37,31 @@ export function ErrorLoading({
const { i18n } = useTranslationContext();
const description = translateTalerError(error, i18n);
const [{ showDebugInfo }] = useCommonPreferences();
+ const debug = showDebugInfo
+ ? JSON.stringify(
+ error.errorDetail,
+ function excludePrivate(key, value) {
+ if (key.startsWith("__")) return "...";
+ return value;
+ },
+ 2,
+ )
+ : undefined;
return (
- <Attention type="danger" copy title={title}>
+ <Attention
+ type="danger"
+ copy
+ copyText={[title, ...(description ?? []), debug]
+ .filter((value): value is string => !!value)
+ .join("\n\n")}
+ title={title}
+ >
{(description ?? []).map((d, idx) => (
<p key={idx}>{d}</p>
))}
- <pre
- class="whitespace-break-spaces text-black"
- style={{ display: showDebugInfo ? "block" : "none" }}
- >
- {JSON.stringify(
- error.errorDetail,
- function excludePrivate(key, value) {
- if (key.startsWith("__")) return "...";
- return value;
- },
- 2,
- )}
- </pre>
+ {debug ? (
+ <pre class="whitespace-break-spaces text-black">{debug}</pre>
+ ) : undefined}
</Attention>
);
}
@@ -70,22 +77,22 @@ export function FailLoading<T>({
}): VNode {
const { i18n } = useTranslationContext();
const [{ showDebugInfo }] = useCommonPreferences();
+ const debug = showDebugInfo
+ ? JSON.stringify(
+ operation,
+ function excludePrivate(key, value) {
+ if (key.startsWith("__")) return "...";
+ return value;
+ },
+ 2,
+ )
+ : undefined;
return (
- <Attention type="danger" copy title={title}>
+ <Attention type="danger" copy copyText={debug ?? title} title={title}>
{translate(operation, i18n)}
- <pre
- class="whitespace-break-spaces text-black"
- style={{ display: showDebugInfo ? "block" : "none" }}
- >
- {JSON.stringify(
- operation,
- function excludePrivate(key, value) {
- if (key.startsWith("__")) return "...";
- return value;
- },
- 2,
- )}
- </pre>
+ {debug ? (
+ <pre class="whitespace-break-spaces text-black">{debug}</pre>
+ ) : undefined}
</Attention>
);
}
diff --git a/packages/web-util/src/components/Header.tsx b/packages/web-util/src/components/Header.tsx
@@ -1,5 +1,5 @@
import { ComponentChildren, Fragment, VNode, h } from "preact";
-import { useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import logo from "../assets/taler-logo-white.png";
import { useTranslationContext } from "../context/translation.js";
import { LangSelector } from "./LangSelector.js";
@@ -29,6 +29,20 @@ export function Header({
}: Props): VNode {
const { i18n } = useTranslationContext();
const [open, setOpen] = useState(false);
+ const menuButton = useRef<HTMLButtonElement>(null);
+ const closeButton = useRef<HTMLButtonElement>(null);
+ useEffect(() => {
+ if (!open) return;
+ closeButton.current?.focus();
+ function closeOnEscape(event: KeyboardEvent): void {
+ if (event.key === "Escape") {
+ setOpen(false);
+ menuButton.current?.focus();
+ }
+ }
+ document.addEventListener("keydown", closeOnEscape);
+ return () => document.removeEventListener("keydown", closeOnEscape);
+ }, [open]);
// const ns = useNotifications();
return (
<Fragment>
@@ -141,11 +155,12 @@ export function Header({
{showMenu ? (
<button
+ ref={menuButton}
type="button"
name="toggle sidebar"
class="relative inline-flex items-center justify-center rounded-md bg-primary p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600"
- aria-controls="mobile-menu"
- aria-expanded="false"
+ aria-controls="settings-panel"
+ aria-expanded={open}
onClick={() => setOpen(!open)}
>
<span class="absolute -inset-0.5"></span>
@@ -174,28 +189,25 @@ export function Header({
{showMenu && open && (
<div
+ id="settings-panel"
class="relative z-10"
name="sidebar overlay"
aria-labelledby="slide-over-title"
role="dialog"
aria-modal="true"
- onClick={() => {
- setOpen(false);
- }}
>
- <div class="fixed inset-0"></div>
+ <button
+ type="button"
+ class="fixed inset-0 cursor-default"
+ aria-label={i18n.str`Close panel`}
+ onClick={() => setOpen(false)}
+ />
<div class="fixed inset-0 overflow-hidden">
<div class="absolute inset-0 overflow-hidden">
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<div class="pointer-events-auto w-screen max-w-md">
- <div
- class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl"
- onClick={(e) => {
- //do not trigger close if clicking inside the sidebar
- e.stopPropagation();
- }}
- >
+ <div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl">
<div class="px-4 sm:px-6">
<div class="flex items-start justify-between">
<h2
@@ -206,6 +218,7 @@ export function Header({
</h2>
<div class="ml-3 flex h-7 items-center">
<button
+ ref={closeButton}
type="button"
name="close sidebar"
class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
@@ -236,14 +249,17 @@ export function Header({
</div>
</div>
<div class="relative mt-6 flex-1 px-4 sm:px-6">
- <nav class="flex flex-1 flex-col" aria-label="Sidebar">
- <ul role="list" class="flex flex-1 flex-col gap-y-7">
+ <nav
+ class="flex flex-1 flex-col"
+ aria-label={i18n.str`Sidebar`}
+ >
+ <ul class="flex flex-1 flex-col gap-y-7">
{onLogout ? (
<li>
- <a
- href="#"
+ <button
+ type="button"
name="logout"
- class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
+ class="w-full text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
onClick={() => {
onLogout();
setOpen(false);
@@ -264,7 +280,7 @@ export function Header({
/>
</svg>
<i18n.Translate>Log out</i18n.Translate>
- </a>
+ </button>
</li>
) : undefined}
<li>
@@ -278,7 +294,7 @@ export function Header({
<div class="text-xs font-semibold leading-6 text-gray-400">
<i18n.Translate>Sites</i18n.Translate>
</div>
- <ul role="list" class="space-y-1">
+ <ul class="space-y-1">
{sites.map(([name, url]) => {
return (
<li key={url}>
diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx
@@ -55,7 +55,7 @@ export function LangSelector({
}: {
type?: "select" | "icon" | "plain";
}): VNode {
- const { lang, changeLanguage, completeness, supportedLang } =
+ const { lang, changeLanguage, completeness, supportedLang, i18n } =
useTranslationContext();
const [hidden, setHidden] = useState(true);
@@ -82,9 +82,9 @@ export function LangSelector({
<button
type="button"
class="relative w-full rounded-md bg-white py-1.5 pl-3 pr-10 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6"
- aria-haspopup="listbox"
- aria-expanded="true"
- aria-labelledby="listbox-label"
+ aria-haspopup="true"
+ aria-expanded={!hidden}
+ aria-label={i18n.str`Select language`}
onClick={(e) => {
setHidden(!hidden);
e.stopPropagation();
@@ -92,7 +92,7 @@ export function LangSelector({
>
<span class="flex items-center">
<img
- alt="language"
+ alt=""
class="h-5 w-5 flex-shrink-0 rounded-full"
src={langIcon}
/>
@@ -120,6 +120,9 @@ export function LangSelector({
<button
type="button"
class="relative w-full rounded-md bg-white p-2 text-left text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-600"
+ aria-haspopup="true"
+ aria-expanded={!hidden}
+ aria-label={i18n.str`Select language`}
onClick={(e) => {
setHidden(!hidden);
e.stopPropagation();
@@ -127,7 +130,7 @@ export function LangSelector({
>
<div class="flex h-7 w-7">
<img
- alt="language"
+ alt=""
class="h-7 w-7 flex-shrink-0 rounded-full"
src={langIcon}
/>
@@ -141,8 +144,9 @@ export function LangSelector({
<button
type="button"
class="inline-flex min-h-11 items-center border-0 bg-transparent p-0 font-semibold text-onBackground hover:text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 max-md:min-h-9 max-md:w-10 max-md:overflow-hidden max-md:whitespace-nowrap"
- aria-haspopup="listbox"
+ aria-haspopup="true"
aria-expanded={!hidden}
+ aria-label={i18n.str`Select language`}
onClick={(e) => {
setHidden(!hidden);
e.stopPropagation();
@@ -171,15 +175,13 @@ export function LangSelector({
}`}
tabIndex={-1}
style={type === "icon" ? { marginLeft: -110 } : {}}
- role="listbox"
- aria-labelledby="listbox-label"
- aria-activedescendant="listbox-option-3"
+ aria-label={i18n.str`Languages`}
>
{type === "icon" ? (
<Fragment>
<li
class="text-gray-900 relative border-b boder-gray-200 select-none py-2 pl-3 pr-9"
- role="option"
+ aria-current="true"
>
<span class="font-normal truncate flex justify-between ">
<span>{getLangName(lang)}</span>
@@ -201,33 +203,38 @@ export function LangSelector({
.map((lang, idx) => (
<li
key={idx}
+ role="none"
class={
type === "plain"
? "relative flex min-h-[2.4rem] cursor-pointer select-none items-center px-3 text-onBackground hover:text-primary"
: "text-gray-900 hover:bg-primary hover:bg-gray-300 cursor-pointer relative select-none py-2 pl-3 pr-9"
}
- role="option"
- onClick={() => {
- changeLanguage(lang);
- setHidden(true);
- }}
>
- <span class="font-normal truncate flex justify-between ">
- <span>
- {type === "plain"
- ? getPlainLangName(lang)
- : getLangName(lang)}
+ <button
+ type="button"
+ class="flex w-full items-center justify-between text-left"
+ onClick={() => {
+ changeLanguage(lang);
+ setHidden(true);
+ }}
+ >
+ <span class="font-normal truncate flex justify-between w-full">
+ <span>
+ {type === "plain"
+ ? getPlainLangName(lang)
+ : getLangName(lang)}
+ </span>
+ {type === "plain" ? undefined : (
+ <span>{(completeness as any)[lang]}%</span>
+ )}
</span>
- {type === "plain" ? undefined : (
- <span>{(completeness as any)[lang]}%</span>
- )}
- </span>
- <span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4">
- {/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
+ <span class="text-indigo-600 absolute inset-y-0 right-0 flex items-center pr-4">
+ {/* <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg> */}
- </span>
+ </span>
+ </button>
</li>
))}
</ul>
diff --git a/packages/web-util/src/components/Loading.tsx b/packages/web-util/src/components/Loading.tsx
@@ -15,11 +15,15 @@
*/
import { h, VNode } from "preact";
+import { useTranslationContext } from "../context/translation.js";
export function Loading(): VNode {
+ const { i18n } = useTranslationContext();
return (
<div
class="columns is-centered is-vcentered"
+ role="status"
+ aria-live="polite"
style={{
width: "100%",
height: "200px",
@@ -29,17 +33,35 @@ export function Loading(): VNode {
}}
>
<Spinner />
+ <span class="sr-only">
+ <i18n.Translate>Loading</i18n.Translate>
+ </span>
</div>
);
}
function Spinner(): VNode {
return (
- <div class="lds-ring m-auto">
- <div />
- <div />
- <div />
- <div />
- </div>
+ <svg
+ aria-hidden="true"
+ class="m-auto h-8 w-8 animate-spin text-gray-500"
+ viewBox="0 0 24 24"
+ fill="none"
+ >
+ <circle
+ cx="12"
+ cy="12"
+ r="9"
+ stroke="currentColor"
+ stroke-width="3"
+ opacity="0.25"
+ />
+ <path
+ d="M21 12a9 9 0 00-9-9"
+ stroke="currentColor"
+ stroke-width="3"
+ stroke-linecap="round"
+ />
+ </svg>
);
}
diff --git a/packages/web-util/src/components/NotificationBanner.test.tsx b/packages/web-util/src/components/NotificationBanner.test.tsx
@@ -104,6 +104,31 @@ function FilteredHarness() {
);
}
+function RedactionHarness() {
+ const notifications = useNotificationContext();
+ const report = notifications.showError<
+ (failure: { reason: string }, secret: string) => void
+ >(
+ i18n.str`Request failed`,
+ (_failure: { reason: string }, _secret: string) => i18n.str`Try again.`,
+ );
+ const notification = notifications.notification.find(
+ (item) => item.message.type === "error",
+ )?.message;
+ const debug = notification?.type === "error" ? notification.debug : undefined;
+ return (
+ <div>
+ <button
+ type="button"
+ onClick={() => report({ reason: "rejected" }, "123456-secret")}
+ >
+ Report failure
+ </button>
+ <output>{JSON.stringify(debug)}</output>
+ </div>
+ );
+}
+
async function eventually(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 50; attempt++) {
@@ -187,3 +212,22 @@ test("filtered notification outlets replace and clear errors independently", asy
cleanup();
await window.happyDOM.abort();
});
+
+test("error notifications do not retain operation secrets", async () => {
+ const window = installDom();
+ const { cleanup, render } = await import("@testing-library/preact");
+ const view = render(
+ <NotificationProvider>
+ <RedactionHarness />
+ </NotificationProvider>,
+ );
+
+ await act(() => {
+ view.getByRole("button", { name: "Report failure" }).click();
+ });
+ assert.match(view.getByRole("status").textContent, /rejected/);
+ assert.doesNotMatch(view.getByRole("status").textContent, /123456-secret/);
+
+ cleanup();
+ await window.happyDOM.abort();
+});
diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx
@@ -105,9 +105,13 @@ export function ToastBanner({
<button
type="button"
class="underline hover:no-underline"
- onClick={() =>
- void navigator.clipboard?.writeText(copyText)
- }
+ onClick={() => {
+ void navigator.clipboard
+ ?.writeText(copyText)
+ .catch((error) =>
+ console.error("Could not copy notification", error),
+ );
+ }}
>
<i18n.Translate>Copy details</i18n.Translate>
</button>
@@ -139,11 +143,22 @@ export function ToastBanner({
}
const descriptions = notification.message.description ?? [];
+ const debug = showDebugInfo
+ ? JSON.stringify(
+ notification.message.debug,
+ (key, value) => (key.startsWith("__") ? "..." : value),
+ 2,
+ )
+ : undefined;
+ const copyText = [notification.message.title, ...descriptions, debug]
+ .filter((entry): entry is string => !!entry)
+ .join("\n\n");
return (
<Attention
type="danger"
title={notification.message.title}
copy
+ copyText={copyText}
onClose={() => {
notification.acknowledge();
setExpandedNotification(undefined);
@@ -165,16 +180,9 @@ export function ToastBanner({
<i18n.Translate>Show more info</i18n.Translate>
</button>
) : undefined}
- <pre
- class="whitespace-break-spaces text-black"
- style={{ display: showDebugInfo ? "block" : "none" }}
- >
- {JSON.stringify(
- notification.message.debug,
- (key, value) => (key.startsWith("__") ? "..." : value),
- 2,
- )}
- </pre>
+ {debug ? (
+ <pre class="whitespace-break-spaces text-black">{debug}</pre>
+ ) : undefined}
</Attention>
);
}
diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts
@@ -23,9 +23,11 @@ import {
ObservabilityEvent,
ObservableHttpClientLibrary,
TalerError,
+ TalerErrorCode,
} from "@gnu-taler/taler-util";
import {
ComponentChildren,
+ Fragment,
FunctionComponent,
VNode,
createContext,
@@ -33,6 +35,7 @@ import {
} from "preact";
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { ErrorLoading } from "../components/ErrorLoading.js";
+import { Button } from "../components/Button.js";
import { BrowserFetchHttpLib } from "../utils/http-impl.sw.js";
import {
APIClient,
@@ -74,10 +77,11 @@ const NO_EVICTORS: Evictors = {};
type ConfigResult<T> =
| undefined
| { type: "ok"; config: T; hints: VersionHint[] }
- | { type: "incompatible"; result: T; supported: string }
+ | { type: "incompatible"; serverVersion?: string; supported: string }
| { type: "error"; error: TalerError };
const CONFIG_FAIL_TRY_AGAIN_MS = 5000;
+const CONFIG_AUTO_RETRY_COUNT = 2;
export const ChallengerApiProvider = ({
baseUrl,
@@ -92,18 +96,20 @@ export const ChallengerApiProvider = ({
}): VNode => {
const [checked, setChecked] =
useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>();
+ const [retryRequest, setRetryRequest] = useState(0);
const checkedFor = useRef<string>();
const endpointKey = baseUrl.href;
const { i18n } = useTranslationContext();
const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = useMemo(
() => buildChallengerApiClient(baseUrl, evictors),
- [baseUrl.href, evictors.challenger],
+ [baseUrl, evictors],
);
useEffect(() => {
let active = true;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
+ let attempts = 0;
setChecked(undefined);
checkedFor.current = undefined;
async function testConfig(): Promise<void> {
@@ -111,12 +117,12 @@ export const ChallengerApiProvider = ({
const config = await getRemoteConfig();
if (!active) return;
checkedFor.current = endpointKey;
- if (LibtoolVersion.compare(VERSION, config.version)) {
+ if (LibtoolVersion.isCompatible(VERSION, config.version)) {
setChecked({ type: "ok", config, hints: [] });
} else {
setChecked({
type: "incompatible",
- result: config,
+ serverVersion: config.version,
supported: VERSION,
});
}
@@ -124,11 +130,26 @@ export const ChallengerApiProvider = ({
if (!active) return;
checkedFor.current = endpointKey;
if (error instanceof TalerError) {
- retryTimer = setTimeout(testConfig, CONFIG_FAIL_TRY_AGAIN_MS);
+ if (
+ error.hasErrorCode(
+ TalerErrorCode.GENERIC_CLIENT_UNSUPPORTED_PROTOCOL_VERSION,
+ )
+ ) {
+ setChecked({
+ type: "incompatible",
+ supported: VERSION,
+ serverVersion: extractServerVersion(error),
+ });
+ return;
+ }
setChecked({ type: "error", error });
} else {
setChecked({ type: "error", error: TalerError.fromException(error) });
}
+ if (attempts < CONFIG_AUTO_RETRY_COUNT) {
+ attempts++;
+ retryTimer = setTimeout(testConfig, CONFIG_FAIL_TRY_AGAIN_MS);
+ }
}
}
testConfig();
@@ -137,7 +158,7 @@ export const ChallengerApiProvider = ({
active = false;
if (retryTimer !== undefined) clearTimeout(retryTimer);
};
- }, [getRemoteConfig, VERSION, endpointKey]);
+ }, [getRemoteConfig, VERSION, endpointKey, retryRequest]);
const currentChecked =
checkedFor.current === endpointKey ? checked : undefined;
@@ -153,10 +174,20 @@ export const ChallengerApiProvider = ({
}
if (currentChecked.type === "error") {
return h(frameOnError, {
- children: h(ErrorLoading, {
- title: i18n.str`There was an error trying to contact the backend service.`,
- error: currentChecked.error,
- }),
+ 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`,
+ ),
+ ]),
});
}
if (currentChecked.type === "incompatible") {
@@ -164,7 +195,9 @@ export const ChallengerApiProvider = ({
children: h(
"div",
{},
- i18n.str`The server version is not supported. Supported version "${currentChecked.supported}", server version "${currentChecked.result.version}"`,
+ 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}".`,
),
});
}
@@ -183,6 +216,12 @@ export const ChallengerApiProvider = ({
});
};
+function extractServerVersion(error: TalerError): string | undefined {
+ const detail = error.errorDetail.detail;
+ if (typeof detail !== "string") return undefined;
+ return /server supports ([^,\s]+)/.exec(detail)?.[1];
+}
+
function buildChallengerApiClient(
url: URL,
evictors: Evictors,
diff --git a/packages/web-util/src/context/common-preferences.ts b/packages/web-util/src/context/common-preferences.ts
@@ -21,15 +21,11 @@ import {
codecOptionalDefault,
} from "@gnu-taler/taler-util";
import { buildStorageKey } from "../hooks/useLocalStorage.js";
-import { useMemoryStorage } from "../hooks/useMemoryStorage.js";
+import { useLocalStorage } from "../hooks/useLocalStorage.js";
interface Preferences {
showDebugInfo: boolean;
}
-interface Type extends Preferences {
- toggleShowDebugInfo(): void;
-}
-
const codecForPreferences = (): Codec<Preferences> =>
buildCodecForObject<Preferences>()
.allowExtra()
@@ -41,19 +37,15 @@ const COMMON_PREFERENCES_KEY = buildStorageKey(
codecForPreferences(),
);
-const initial: Type = {
+const initial: Preferences = {
showDebugInfo: false,
- toggleShowDebugInfo() {},
};
export function useCommonPreferences(): [
Readonly<Preferences>,
<T extends keyof Preferences>(key: T, value: Preferences[T]) => void,
] {
- const { value, update } = useMemoryStorage(
- COMMON_PREFERENCES_KEY.id,
- initial,
- );
+ const { value, update } = useLocalStorage(COMMON_PREFERENCES_KEY, initial);
function updateField<T extends keyof Preferences>(k: T, v: Preferences[T]) {
const newValue = { ...value, [k]: v };
diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts
@@ -16,7 +16,7 @@
import { i18n, setupI18n } from "@gnu-taler/taler-util";
import { ComponentChildren, createContext, h, VNode } from "preact";
-import { useContext, useEffect, useMemo } from "preact/hooks";
+import { useContext, useEffect, useMemo, useRef } from "preact/hooks";
import { strings as webUtilStrings, StringsType } from "../i18n/strings.js";
import { useLang } from "../hooks/index.js";
import { Locale } from "date-fns";
@@ -29,7 +29,7 @@ export type InternationalizationAPI = typeof i18n;
interface Type {
lang: string;
- supportedLang: { [id in keyof typeof SUPPORTED_LANGS]: string };
+ supportedLang: Record<string, string>;
changeLanguage: (l: string) => void;
i18n: InternationalizationAPI;
dateLocale: Locale;
@@ -37,9 +37,9 @@ interface Type {
}
const SUPPORTED_LANGS = {
- es: "Espanol [es]",
+ es: "Español [es]",
en: "English [en]",
- fr: "Francais [fr]",
+ fr: "Français [fr]",
de: "Deutsch [de]",
// sv: "Svenska [sv]",
// it: "Italiane [it]",
@@ -121,7 +121,13 @@ export const TranslationProvider = ({
source,
}: Props): VNode => {
const mergedSource = useMemo(() => mergeTranslationSources(source), [source]);
- const completeness = Object.keys(SUPPORTED_LANGS).reduce(
+ const availableLanguages = new Set(["en", ...Object.keys(source)]);
+ const supportedLang = Object.fromEntries(
+ Object.entries(SUPPORTED_LANGS).filter(([lang]) =>
+ availableLanguages.has(lang),
+ ),
+ );
+ const completeness = Object.keys(supportedLang).reduce(
(map, lang) => {
if (
lang !== "en" &&
@@ -139,10 +145,12 @@ export const TranslationProvider = ({
initial,
completeness,
);
+ const changeLanguageRef = useRef(changeLanguage);
+ changeLanguageRef.current = changeLanguage;
useEffect(() => {
if (forceLang) {
- changeLanguage(forceLang);
+ changeLanguageRef.current(forceLang);
}
}, [forceLang]);
const effectiveLang = forceLang ?? lang;
@@ -161,7 +169,7 @@ export const TranslationProvider = ({
value: {
lang: effectiveLang,
changeLanguage,
- supportedLang: SUPPORTED_LANGS,
+ supportedLang,
i18n,
dateLocale,
completeness,
diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role -- ARIA combobox popups use listbox semantics while the input retains keyboard focus. */
import { i18n } from "@gnu-taler/taler-util";
import { Fragment, VNode, h } from "preact";
import { useId, useRef, useState } from "preact/hooks";
@@ -20,6 +21,7 @@ export function InputSelectOne<Choices>(
props.handler ?? noHandlerPropsAndNoContextForField(props.name);
const [filter, setFilter] = useState<string | undefined>(undefined);
+ const [activeIndex, setActiveIndex] = useState(0);
const [dirty, setDirty] = useState<boolean>(); // FIXME: dirty state should come from handler
const id = `select-one-${useId()}`;
const inputRef = useRef<HTMLInputElement>(null);
@@ -64,7 +66,7 @@ export function InputSelectOne<Choices>(
label={label}
required={required}
tooltip={tooltip}
- name={props.name as string}
+ name={id}
/>
{value !== undefined ? (
<span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600">
@@ -77,6 +79,7 @@ export function InputSelectOne<Choices>(
setDirty(true);
}}
class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20 disabled:cursor-not-allowed"
+ aria-label={i18n.str`Clear selection`}
>
<svg
viewBox="0 0 14 14"
@@ -98,8 +101,34 @@ export function InputSelectOne<Choices>(
disabled={props.disabled}
onChange={(e) => {
setFilter(e.currentTarget.value);
+ setActiveIndex(0);
setDirty(true);
}}
+ onKeyDown={(event) => {
+ if (!filteredChoices?.length) return;
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault();
+ setActiveIndex((current) =>
+ Math.min(current + 1, filteredChoices.length - 1),
+ );
+ break;
+ case "ArrowUp":
+ event.preventDefault();
+ setActiveIndex((current) => Math.max(current - 1, 0));
+ break;
+ case "Enter":
+ event.preventDefault();
+ setFilter(undefined);
+ onChange(filteredChoices[activeIndex].value as any);
+ setDirty(true);
+ break;
+ case "Escape":
+ event.preventDefault();
+ setFilter(undefined);
+ break;
+ }
+ }}
onBlur={(e) => {
setFilter(undefined);
}}
@@ -110,7 +139,21 @@ export function InputSelectOne<Choices>(
class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
role="combobox"
aria-controls={`${id}-options`}
- aria-expanded="false"
+ aria-expanded={filter !== undefined}
+ aria-autocomplete="list"
+ aria-activedescendant={
+ filteredChoices?.length
+ ? `${id}-option-${activeIndex}`
+ : undefined
+ }
+ aria-describedby={
+ [
+ help ? `${id}-description` : undefined,
+ error ? `${id}-error` : undefined,
+ ]
+ .filter(Boolean)
+ .join(" ") || undefined
+ }
/>
<button
type="button"
@@ -124,6 +167,7 @@ export function InputSelectOne<Choices>(
inputRef.current?.focus();
}}
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
+ aria-label={i18n.str`Toggle choices`}
>
<svg
class="h-5 w-5 text-gray-400"
@@ -159,22 +203,27 @@ export function InputSelectOne<Choices>(
>
{filteredChoices.map((v, idx) => {
return (
- <li
- key={idx}
- class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
- id={`${id}-option-${idx}`}
- role="option"
- onMouseDown={(e) => {
- // Input element should not lose focus
- e.preventDefault();
- }}
- onClick={() => {
- setFilter(undefined);
- onChange(v.value as any);
- setDirty(true);
- }}
- >
- <span class="block truncate">{v.label}</span>
+ <li key={idx} role="none">
+ <button
+ type="button"
+ class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
+ id={`${id}-option-${idx}`}
+ role="option"
+ aria-selected={idx === activeIndex}
+ data-active={idx === activeIndex}
+ onMouseEnter={() => setActiveIndex(idx)}
+ onMouseDown={(e) => {
+ // Input element should not lose focus
+ e.preventDefault();
+ }}
+ onClick={() => {
+ setFilter(undefined);
+ onChange(v.value as any);
+ setDirty(true);
+ }}
+ >
+ <span class="block truncate">{v.label}</span>
+ </button>
</li>
);
})}
@@ -183,12 +232,12 @@ export function InputSelectOne<Choices>(
</div>
)}
{help && (
- <p class="mt-2 text-sm text-gray-500" id="email-description">
+ <p class="mt-2 text-sm text-gray-500" id={`${id}-description`}>
{help}
</p>
)}
{dirty !== undefined && error && (
- <p class="mt-2 text-sm text-red-600" id="email-error">
+ <p class="mt-2 text-sm text-red-600" id={`${id}-error`}>
{error}
</p>
)}
diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts
@@ -94,7 +94,7 @@ export function useNotificationHandler() {
if (description === undefined) return;
displayError(
title,
- args,
+ (args as unknown[])[0],
...(Array.isArray(description) ? description : [description]),
);
}) as T;