taler-typescript-core

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

commit 8803e1ca647bf6f05388c58df2906e5768bb58fb
parent c4b3d0f95285211fad7a0d72c2ef4992c1817f65
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Mon,  1 Jun 2026 11:14:57 -0300

several fixes and improvements

better error reporting (user can copy the content)
normalize safeHandler and MFA
simplify button
only one notificaton handler
prevent printing sensitive info

Diffstat:
Mpackages/challenger-ui/src/Routing.tsx | 4----
Mpackages/challenger-ui/src/app.tsx | 81+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx | 3++-
Mpackages/challenger-ui/src/pages/AnswerChallenge.tsx | 121++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/challenger-ui/src/pages/AskChallenge.tsx | 38++++++++++++++++++++------------------
Mpackages/challenger-ui/src/pages/Frame.tsx | 28++++++----------------------
Mpackages/challenger-ui/src/pages/Setup.tsx | 40+++++++++++++++++++---------------------
7 files changed, 151 insertions(+), 164 deletions(-)

diff --git a/packages/challenger-ui/src/Routing.tsx b/packages/challenger-ui/src/Routing.tsx @@ -23,7 +23,6 @@ import { import { Fragment, VNode, h } from "preact"; import { assertUnreachable } from "@gnu-taler/taler-util"; -import { useErrorBoundary } from "preact/hooks"; import { CheckChallengeIsUpToDate } from "./components/CheckChallengeIsUpToDate.js"; import { SessionId } from "./hooks/session.js"; import { AnswerChallenge } from "./pages/AnswerChallenge.js"; @@ -93,9 +92,6 @@ function PublicRounting(): VNode { const loc = useCurrentLocation(publicPages); const { i18n } = useTranslationContext(); const { navigateTo } = useNavigationContext(); - useErrorBoundary((e) => { - console.log("error", e); - }); const location: typeof loc = loc.name === undefined diff --git a/packages/challenger-ui/src/app.tsx b/packages/challenger-ui/src/app.tsx @@ -26,6 +26,7 @@ import { BrowserHashNavigationProvider, ChallengerApiProvider, Loading, + NotificationProvider, TalerWalletIntegrationBrowserProvider, TranslationProvider, } from "@gnu-taler/web-util/browser"; @@ -70,47 +71,49 @@ export function App(): VNode { return ( <SettingsProvider value={settings}> <TranslationProvider source={strings}> - <ChallengerApiProvider - baseUrl={new URL("/", baseUrl)} - frameOnError={Frame} - evictors={{ - challenger: evictBankSwrCache, - }} - > - <SWRConfig - value={{ - provider: WITH_LOCAL_STORAGE_CACHE - ? localStorageProvider - : undefined, - // normally, do not revalidate - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateIfStale: false, - revalidateOnMount: undefined, - focusThrottleInterval: undefined, - - // normally, do not refresh - refreshInterval: undefined, - dedupingInterval: 2000, - refreshWhenHidden: false, - refreshWhenOffline: false, - - // ignore errors - shouldRetryOnError: false, - errorRetryCount: 0, - errorRetryInterval: undefined, - - // do not go to loading again if already has data - keepPreviousData: true, + <NotificationProvider> + <ChallengerApiProvider + baseUrl={new URL("/", baseUrl)} + frameOnError={Frame} + evictors={{ + challenger: evictBankSwrCache, }} > - <TalerWalletIntegrationBrowserProvider> - <BrowserHashNavigationProvider> - <Routing /> - </BrowserHashNavigationProvider> - </TalerWalletIntegrationBrowserProvider> - </SWRConfig> - </ChallengerApiProvider> + <SWRConfig + value={{ + provider: WITH_LOCAL_STORAGE_CACHE + ? localStorageProvider + : undefined, + // normally, do not revalidate + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: undefined, + focusThrottleInterval: undefined, + + // normally, do not refresh + refreshInterval: undefined, + dedupingInterval: 2000, + refreshWhenHidden: false, + refreshWhenOffline: false, + + // ignore errors + shouldRetryOnError: false, + errorRetryCount: 0, + errorRetryInterval: undefined, + + // do not go to loading again if already has data + keepPreviousData: true, + }} + > + <TalerWalletIntegrationBrowserProvider> + <BrowserHashNavigationProvider> + <Routing /> + </BrowserHashNavigationProvider> + </TalerWalletIntegrationBrowserProvider> + </SWRConfig> + </ChallengerApiProvider> + </NotificationProvider> </TranslationProvider> </SettingsProvider> ); diff --git a/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx @@ -20,6 +20,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + ErrorLoading, Loading, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -50,7 +51,7 @@ export function CheckChallengeIsUpToDate({ return <Loading />; } if (result instanceof TalerError) { - return <pre>{JSON.stringify(result, undefined, 2)}</pre>; + return <ErrorLoading title={i18n.str`Failed to load the session.`} error={result} />; } if (result.type === "fail") { diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -15,32 +15,31 @@ */ import { AbsoluteTime, + ChallengerApi, EmptyObject, HttpStatusCode, TalerError, - ChallengerApi, + TalerFormAttributes, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, - LocalNotificationBanner, + Button, RouteDefinition, ShowInputErrorLabel, Time, useChallengerApiContext, - useLocalNotificationBetter, + useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; +import { useMemo } from "preact/compat"; import { useEffect, useState } from "preact/hooks"; import { revalidateChallengeSession, useChallengeSession, } from "../hooks/challenge.js"; import { SessionId, useSessionState } from "../hooks/session.js"; -import { TalerFormAttributes } from "@gnu-taler/taler-util"; -import { useMemo } from "preact/compat"; type Props = { focus?: boolean; @@ -96,7 +95,8 @@ export function AnswerChallenge({ const { config, lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); const { sent, failed, completed } = useSessionState(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); + const [pin, setPin] = useState<string | undefined>(); const errors = undefinedIfEmpty({ pin: !pin ? i18n.str`Can't be empty` : undefined, @@ -130,15 +130,15 @@ export function AnswerChallenge({ const unableToChangeAddr = !lastStatus || lastStatus.changes_left < 1; const contact = lastStatus?.last_address; - const sendAgain = safeFunctionHandler( - i18n.str`create challenge`, - lib.challenger.challenge.bind(lib.challenger), + // i18n.str`create challenge`, + const sendAgain = actionHandler( + (ct, n, b) => lib.challenger.challenge(n, b), contact === undefined || lastStatus === undefined || lastStatus.pin_transmissions_left === 0 || !AbsoluteTime.isExpired(deadline) ? undefined - : [session.nonce, contact], + : ([session.nonce, contact] as const), ); sendAgain.onSuccess = (success) => { if (success.type === "completed") { @@ -147,32 +147,35 @@ export function AnswerChallenge({ sent(success); } }; - sendAgain.onFail = (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The request was not accepted, try reloading the app.`; - case HttpStatusCode.NotFound: - return i18n.str`Challenge not found.`; - case HttpStatusCode.NotAcceptable: - return i18n.str`Server templates are missing due to misconfiguration.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`There have been too many attempts to request challenge transmissions.`; - case HttpStatusCode.InternalServerError: - return i18n.str`Server is unable to respond due to internal problems.`; - default: - assertUnreachable(fail); - } - }; + sendAgain.onFail = showError( + i18n.str`Failed to create a new challenge.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The request was not accepted, try reloading the app.`; + case HttpStatusCode.NotFound: + return i18n.str`Challenge not found.`; + case HttpStatusCode.NotAcceptable: + return i18n.str`Server templates are missing due to misconfiguration.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`There have been too many attempts to request challenge transmissions.`; + case HttpStatusCode.InternalServerError: + return i18n.str`Server is unable to respond due to internal problems.`; + default: + assertUnreachable(fail); + } + }, + ); - const check = safeFunctionHandler( - i18n.str`solve challenge`, - lib.challenger.solve.bind(lib.challenger), + // i18n.str`solve challenge`, + const check = actionHandler( + (ct, n, b) => lib.challenger.solve(n, b), errors !== undefined || lastStatus == undefined || lastStatus.auth_attempts_left === 0 || !pin ? undefined - : [session.nonce, { pin }], + : ([session.nonce, { pin }] as const), ); check.onSuccess = (success) => { if (success.type === "completed") { @@ -182,28 +185,31 @@ export function AnswerChallenge({ } onComplete(); }; - check.onFail = (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 pin.`; + check.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 pin.`; + } + 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(); + return i18n.str`There have been too many attempts to request challenge transmissions.`; + } + case HttpStatusCode.InternalServerError: + return i18n.str`Server is unable to respond due to internal problems.`; + default: + assertUnreachable(fail); } - 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(); - return i18n.str`There have been too many attempts to request challenge transmissions.`; - } - case HttpStatusCode.InternalServerError: - return i18n.str`Server is unable to respond due to internal problems.`; - default: - assertUnreachable(fail); - } - }; + }, + ); const cantTryAnymore = lastStatus?.auth_attempts_left === 0; function LastContactSent(): VNode { @@ -260,13 +266,13 @@ export function AnswerChallenge({ )} </div> <div> - <ButtonBetter + <Button 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={sendAgain} > <i18n.Translate>Send new code</i18n.Translate> - </ButtonBetter> + </Button> {lastStatus === undefined ? undefined : ( <p class="mt-2 text-sm leading-6 text-gray-400"> {lastStatus.pin_transmissions_left < 1 ? ( @@ -293,7 +299,6 @@ export function AnswerChallenge({ if (cantTryAnymore) { return ( <Fragment> - <LocalNotificationBanner notification={notification} /> <div class="isolate bg-white px-6 py-12"> <div class="mx-auto max-w-2xl text-center"> <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> @@ -311,8 +316,6 @@ export function AnswerChallenge({ return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="isolate bg-white px-6 py-12"> <div class="mx-auto max-w-2xl text-center"> <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> @@ -381,13 +384,13 @@ export function AnswerChallenge({ </div> <div class="mt-10"> - <ButtonBetter + <Button 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={check} > <i18n.Translate>Check</i18n.Translate> - </ButtonBetter> + </Button> </div> </form> diff --git a/packages/challenger-ui/src/pages/AskChallenge.tsx b/packages/challenger-ui/src/pages/AskChallenge.tsx @@ -25,17 +25,16 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ButtonBetter, + Button, countryNameList, ErrorLoading, FormDesign, FormUI, - LocalNotificationBanner, RouteDefinition, useChallengerApiContext, useForm, - useLocalNotificationBetter, - useTranslationContext, + useNotificationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -60,7 +59,7 @@ export function AskChallenge({ const { lib, config } = useChallengerApiContext(); const { i18n } = useTranslationContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { actionHandler, showError } = useNotificationContext(); // const [address, setEmail] = useState<string | undefined>(); const [addrIndex, setAddrIndex] = useState<number | undefined>(); @@ -74,7 +73,12 @@ export function AskChallenge({ ); } if (result instanceof TalerError) { - return <ErrorLoading error={result} />; + return ( + <ErrorLoading + title={i18n.str`Failed to load the session.`} + error={result} + /> + ); } if (result.type === "fail") { switch (result.case) { @@ -161,10 +165,10 @@ export function AskChallenge({ const info = lastStatus.fix_address ? lastStatus.last_address! : contact; - const send = safeFunctionHandler( - i18n.str`create challenge`, - lib.challenger.challenge.bind(lib.challenger), - form.status.errors || !info ? undefined : [session.nonce, info], + // i18n.str`create challenge`, + const send = actionHandler( + (ct, n, i) => lib.challenger.challenge(n, i), + form.status.errors || !info ? undefined : ([session.nonce, info] as const), ); send.onSuccess = (ok) => { if (ok.type === "completed") { @@ -174,7 +178,7 @@ export function AskChallenge({ } onSendSuccesful(); }; - send.onFail = (fail) => { + send.onFail = showError(i18n.str`Failed to create a challenge.`, (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: return i18n.str`The request was not accepted, try reloading the app.`; @@ -189,12 +193,10 @@ export function AskChallenge({ default: assertUnreachable(fail); } - }; + }); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="isolate bg-white px-6 py-12"> <div class="mx-auto max-w-2xl text-center"> <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> @@ -427,7 +429,7 @@ export function AskChallenge({ <div class="mx-auto mt-4 max-w-xl "> {!prevAddr ? ( <div class="mt-10"> - <ButtonBetter + <Button 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={send} @@ -443,11 +445,11 @@ export function AskChallenge({ return i18n.str`Send SMS`; } })()} - </ButtonBetter> + </Button> </div> ) : ( <div class="mt-10"> - <ButtonBetter + <Button 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={send} @@ -469,7 +471,7 @@ export function AskChallenge({ : i18n.str`Change phone`; } })()} - </ButtonBetter> + </Button> </div> )} </div> diff --git a/packages/challenger-ui/src/pages/Frame.tsx b/packages/challenger-ui/src/pages/Frame.tsx @@ -14,48 +14,32 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TranslatedString } from "@gnu-taler/taler-util"; import { Footer, Header, ToastBanner, - notifyError, - notifyException, + useRenderErrorReport, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useErrorBoundary } from "preact/hooks"; import { getAllBooleanPreferences, getLabelForPreferences, usePreferences, } from "../context/preferences.js"; -import { useSettingsContext } from "../context/settings.js"; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; export function Frame({ children }: { children: ComponentChildren }): VNode { - const settings = useSettingsContext(); const [preferences, updatePreferences] = usePreferences(); - const [error, resetError] = useErrorBoundary(); const { i18n } = useTranslationContext(); - useEffect(() => { - if (error) { - if (error instanceof Error) { - console.log("Internal error, please report", error); - notifyException(i18n.str`Internal error, please report.`, error); - } else { - console.log("Internal error, please report", error); - notifyError( - i18n.str`Internal error, please report.`, - String(error) as TranslatedString, - ); - } - resetError(); - } - }, [error]); + + useRenderErrorReport({ + hash: __GIT_HASH__, + version: __VERSION__, + }); return ( <div diff --git a/packages/challenger-ui/src/pages/Setup.tsx b/packages/challenger-ui/src/pages/Setup.tsx @@ -22,12 +22,11 @@ import { randomBytes, } from "@gnu-taler/taler-util"; import { - ButtonBetter, - LocalNotificationBanner, + Button, ShowInputErrorLabel, useChallengerApiContext, - useLocalNotificationBetter, - useTranslationContext + useNotificationContext, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -42,6 +41,7 @@ type Props = { onCreated: () => void; focus?: boolean; }; + export function Setup({ clientId, secret, @@ -50,8 +50,8 @@ export function Setup({ onCreated, }: Props): VNode { const { i18n } = useTranslationContext(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const { lib } = useChallengerApiContext(); + const { actionHandler, showError } = useNotificationContext(); const { start } = useSessionState(); const [password, setPassword] = useState<string | undefined>(secret); const [url, setUrl] = useState<string | undefined>(redirectURL?.href); @@ -65,13 +65,13 @@ export function Setup({ : undefined, }); - const doStart = safeFunctionHandler( - i18n.str`setup challenge`, - (token: AccessToken, url) => lib.challenger.setup(clientId, token), + const doStart = actionHandler( + (ct, token: AccessToken, url) => lib.challenger.setup(clientId, token), !!errors || password === undefined || url === undefined ? undefined : [createRFC8959AccessTokenEncoded(password), url], ); + doStart.onSuccess = (ok, token, redirect_uri) => { start(); const redirect = new URL(window.location.href); @@ -84,19 +84,17 @@ export function Setup({ onCreated(); }; - doStart.onFail = (fail) => { + doStart.onFail = showError(i18n.str`Failed to setup a new challenge.`, (fail) => { switch (fail.case) { case HttpStatusCode.NotFound: return i18n.str`The server doesn't know about this client. Either the URL or the secret is wrong.`; default: assertUnreachable(fail.case); } - }; + }); return ( <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="isolate bg-white px-6 py-12"> <div class="mx-auto max-w-2xl text-center"> <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> @@ -169,16 +167,16 @@ export function Setup({ /> </div> </div> + <div class="mt-10"> + <Button + 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={doStart} + > + <i18n.Translate>Start</i18n.Translate> + </Button> + </div> </form> - <div class="mt-10"> - <ButtonBetter - 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={doStart} - > - <i18n.Translate>Start</i18n.Translate> - </ButtonBetter> - </div> </div> </Fragment> );