taler-typescript-core

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

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:
Mpackages/challenger-webui/src/Routing.tsx | 119+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/challenger-webui/src/app.tsx | 10+++++-----
Apackages/challenger-webui/src/challenger-flow.test.ts | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/challenger-webui/src/components/CheckChallengeIsUpToDate.tsx | 84++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/challenger-webui/src/context/preferences.ts | 8++------
Mpackages/challenger-webui/src/hooks/challenge.ts | 40++++++++++++++++++++++++----------------
Mpackages/challenger-webui/src/hooks/session.ts | 23+++++++++++++----------
Mpackages/challenger-webui/src/pages/AnswerChallenge.tsx | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/challenger-webui/src/pages/AskChallenge.tsx | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mpackages/challenger-webui/src/pages/CallengeCompleted.tsx | 44+++++++++++++++++++++++++++++++++++++-------
Mpackages/challenger-webui/src/pages/Frame.tsx | 47++++++++++++++++++++++++++++++++++++++++-------
Mpackages/challenger-webui/src/pages/NonceNotFound.tsx | 4----
Mpackages/challenger-webui/src/pages/Setup.tsx | 33+++++++++++++++------------------
Mpackages/challenger-webui/tsconfig.json | 1+
Apackages/web-util/src/components/Accessibility.test.tsx | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/components/Attention.tsx | 22++++++++++++++++------
Mpackages/web-util/src/components/ErrorLoading.tsx | 65++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/web-util/src/components/Header.tsx | 58+++++++++++++++++++++++++++++++++++++---------------------
Mpackages/web-util/src/components/LangSelector.tsx | 63+++++++++++++++++++++++++++++++++++----------------------------
Mpackages/web-util/src/components/Loading.tsx | 34++++++++++++++++++++++++++++------
Mpackages/web-util/src/components/NotificationBanner.test.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/components/NotificationBanner.tsx | 34+++++++++++++++++++++-------------
Mpackages/web-util/src/context/challenger-api.ts | 61++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/web-util/src/context/common-preferences.ts | 14+++-----------
Mpackages/web-util/src/context/translation.ts | 22+++++++++++++++-------
Mpackages/web-util/src/forms/fields/InputSelectOne.tsx | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------
Mpackages/web-util/src/hooks/useNotifications.ts | 2+-
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 &quot;{lastAddr} - &quot; is no longer valid. + You may request a new TAN code for &quot;{lastAddr}&quot;. </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 &quot;{invalidRestriction}&quot; 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"> &quot; - {state?.completedURL} + {completedURL} &quot; </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;