taler-typescript-core

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

commit e5f6e43e1a23c75a0a44484b7393ac055e26a048
parent b843f0f73e0fa1d2dfa429077f77c28c01536e76
Author: Florian Dold <dold@taler.net>
Date:   Sun, 23 Aug 2026 12:56:10 +0200

web-util: decouple async actions from notifications

Diffstat:
Mpackages/challenger-webui/src/pages/AnswerChallenge.tsx | 128+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/challenger-webui/src/pages/AskChallenge.tsx | 80+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/challenger-webui/src/pages/Setup.tsx | 53++++++++++++++++++++++++++---------------------------
Mpackages/libeufin-bank-webui/src/Routing.tsx | 80+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/libeufin-bank-webui/src/context/challenge.ts | 14++++----------
Mpackages/libeufin-bank-webui/src/hooks/account.ts | 1-
Mpackages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx | 241+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/libeufin-bank-webui/src/pages/LoginForm.tsx | 116++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpackages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx | 64+++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/libeufin-bank-webui/src/pages/OperationState/views.tsx | 175++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx | 126+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/libeufin-bank-webui/src/pages/QrCodeSection.tsx | 54++++++++++++++++++++++++++++++------------------------
Mpackages/libeufin-bank-webui/src/pages/RegistrationPage.tsx | 117++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/libeufin-bank-webui/src/pages/SolveMFA.tsx | 192++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx | 81+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx | 157+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx | 149+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx | 115++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx | 41++++++++++++++++++++---------------------
Mpackages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx | 29++++++++++++++++-------------
Mpackages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx | 120+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx | 126++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx | 183+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/taler-exchange-aml-webui/src/components/CreateSession.tsx | 25++++++++++++++-----------
Mpackages/taler-exchange-aml-webui/src/components/UnlockSession.tsx | 50++++++++++++++++++++++++++++++--------------------
Mpackages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx | 86++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mpackages/taler-exchange-aml-webui/src/pages/AccountList.tsx | 177++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx | 70++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/taler-exchange-kyc-webui/src/pages/FillForm.tsx | 59++++++++++++++++++++++++++++++++---------------------------
Mpackages/taler-exchange-kyc-webui/src/pages/Start.tsx | 60+++++++++++++++++++++++++++++++-----------------------------
Mpackages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx | 203+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/web-util/src/components/Button.tsx | 192+++++++++++++++++++++++++++++--------------------------------------------------
Mpackages/web-util/src/components/NotificationBanner.tsx | 293++++++++++++++++++-------------------------------------------------------------
Mpackages/web-util/src/context/notification.ts | 41++---------------------------------------
Mpackages/web-util/src/hooks/index.stories.ts | 1-
Mpackages/web-util/src/hooks/index.ts | 2++
Apackages/web-util/src/hooks/useAsyncAction.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/web-util/src/hooks/useNotifications.stories.tsx | 524-------------------------------------------------------------------------------
Mpackages/web-util/src/hooks/useNotifications.ts | 678++++++++-----------------------------------------------------------------------
Apackages/web-util/src/hooks/useNotifiedOperation.ts | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
40 files changed, 2022 insertions(+), 3052 deletions(-)

diff --git a/packages/challenger-webui/src/pages/AnswerChallenge.tsx b/packages/challenger-webui/src/pages/AnswerChallenge.tsx @@ -24,12 +24,13 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, RouteDefinition, ShowInputErrorLabel, Time, useChallengerApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -95,7 +96,7 @@ export function AnswerChallenge({ const { config, lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); const { sent, failed, completed } = useSessionState(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const [pin, setPin] = useState<string | undefined>(); const errors = undefinedIfEmpty({ @@ -131,25 +132,25 @@ export function AnswerChallenge({ const contact = lastStatus?.last_address; // i18n.str`create challenge`, - const sendAgain = actionHandler( - (ct, n, b) => lib.challenger.challenge(n, b), + const sendAgainArgs = contact === undefined || - lastStatus === undefined || - lastStatus.pin_transmissions_left === 0 || - !AbsoluteTime.isExpired(deadline) + lastStatus === undefined || + lastStatus.pin_transmissions_left === 0 || + !AbsoluteTime.isExpired(deadline) ? undefined - : ([session.nonce, contact] as const), - ); - sendAgain.onSuccess = (success) => { - if (success.type === "completed") { - completed(success); - } else { - sent(success); - } - }; - sendAgain.onFail = showError( - i18n.str`Failed to create a new challenge.`, - (fail) => { + : ([session.nonce, contact] as const); + const sendAgain = useNotifiedOperation< + Awaited<ReturnType<typeof lib.challenger.challenge>>, + [string, Record<string, string>] + >((ct, n, b) => lib.challenger.challenge(n, b), { + onSuccess(success) { + if (success.type === "completed") { + completed(success); + } else { + sent(success); + } + }, + 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.`; @@ -164,48 +165,51 @@ export function AnswerChallenge({ default: assertUnreachable(fail); } - }, - ); + }), + }); // i18n.str`solve challenge`, - const check = actionHandler( - (ct, n, b) => lib.challenger.solve(n, b), + const checkArgs = errors !== undefined || - lastStatus == undefined || - lastStatus.auth_attempts_left === 0 || - !pin + lastStatus == undefined || + lastStatus.auth_attempts_left === 0 || + !pin ? undefined - : ([session.nonce, { pin }] as const), - ); - check.onSuccess = (success) => { - if (success.type === "completed") { - completed(success); - } else { - failed(success); - } - onComplete(); - }; - 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 TAN code.`; + : ([session.nonce, { pin }] as const); + const check = useNotifiedOperation< + Awaited<ReturnType<typeof lib.challenger.solve>>, + [string, Record<string, string>] + >((ct, n, b) => lib.challenger.solve(n, b), { + onSuccess(success) { + if (success.type === "completed") { + completed(success); + } else { + failed(success); } - 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 send the TAN code.`; + 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.`; + } + 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 send the TAN code.`; + } + case HttpStatusCode.InternalServerError: + return i18n.str`Server is unable to respond due to internal problems.`; + default: + assertUnreachable(fail); } - 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; @@ -263,13 +267,15 @@ export function AnswerChallenge({ )} </div> <div> - <Button + <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={sendAgain} + onClick={ + sendAgainArgs ? () => sendAgain.run(...sendAgainArgs) : undefined + } > <i18n.Translate>Send new code</i18n.Translate> - </Button> + </AsyncButton> {lastStatus === undefined ? undefined : ( <p class="mt-2 text-sm leading-6 text-gray-400"> {lastStatus.pin_transmissions_left < 1 ? ( @@ -383,13 +389,13 @@ export function AnswerChallenge({ </div> <div class="mt-10"> - <Button + <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={check} + onClick={checkArgs ? () => check.run(...checkArgs) : undefined} > <i18n.Translate>Check</i18n.Translate> - </Button> + </AsyncButton> </div> </form> diff --git a/packages/challenger-webui/src/pages/AskChallenge.tsx b/packages/challenger-webui/src/pages/AskChallenge.tsx @@ -25,7 +25,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, countryNameList, ErrorLoading, FormDesign, @@ -33,6 +33,7 @@ import { RouteDefinition, useChallengerApiContext, useForm, + useNotifiedOperation, useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -135,7 +136,7 @@ function AskChallengeInternal({ const { lib, config } = useChallengerApiContext(); const { i18n } = useTranslationContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const initial = lastStatus.last_address ?? {}; if (config.address_type === "postal-ch") { @@ -166,33 +167,36 @@ function AskChallengeInternal({ const info = lastStatus.fix_address ? lastStatus.last_address! : contact; // 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") { - completed(ok); - } else { - sent(ok); - } - onSendSuccesful(); - }; - 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.`; - case HttpStatusCode.NotFound: - return i18n.str`Challenge not found.`; - case HttpStatusCode.NotAcceptable: - return i18n.str`Server templates are missing due to misconfiguration.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`There have been too many attempts to send the TAN code.`; - case HttpStatusCode.InternalServerError: - return i18n.str`Server is unable to respond due to internal problems.`; - default: - assertUnreachable(fail); - } + const sendArgs = + form.status.errors || !info ? undefined : ([session.nonce, info] as const); + const send = useNotifiedOperation< + Awaited<ReturnType<typeof lib.challenger.challenge>>, + [string, Record<string, string>] + >((ct, n, i) => lib.challenger.challenge(n, i), { + onSuccess(ok) { + if (ok.type === "completed") { + completed(ok); + } else { + sent(ok); + } + onSendSuccesful(); + }, + onFail: showError(i18n.str`Failed to create a challenge.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The request was not accepted. Try reloading the app.`; + case HttpStatusCode.NotFound: + return i18n.str`Challenge not found.`; + case HttpStatusCode.NotAcceptable: + return i18n.str`Server templates are missing due to misconfiguration.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`There have been too many attempts to send the TAN code.`; + case HttpStatusCode.InternalServerError: + return i18n.str`Server is unable to respond due to internal problems.`; + default: + assertUnreachable(fail); + } + }), }); return ( @@ -243,7 +247,11 @@ function AskChallengeInternal({ )} <div class="mx-auto mt-4 max-w-xl "> - <FormUI design={design} model={form.model} onSubmit={send.call} /> + <FormUI + design={design} + model={form.model} + onSubmit={sendArgs ? () => send.run(...sendArgs) : undefined} + /> </div> {lastStatus === undefined ? undefined : ( @@ -268,10 +276,10 @@ function AskChallengeInternal({ <div class="mx-auto mt-4 max-w-xl "> {!prevAddr ? ( <div class="mt-10"> - <Button + <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={send} + onClick={sendArgs ? () => send.run(...sendArgs) : undefined} > {(function (): TranslatedString { switch (config.address_type) { @@ -284,14 +292,14 @@ function AskChallengeInternal({ return i18n.str`Send SMS`; } })()} - </Button> + </AsyncButton> </div> ) : ( <div class="mt-10"> - <Button + <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={send} + onClick={sendArgs ? () => send.run(...sendArgs) : undefined} > {(function (): TranslatedString { switch (config.address_type) { @@ -310,7 +318,7 @@ function AskChallengeInternal({ : i18n.str`Change phone`; } })()} - </Button> + </AsyncButton> </div> )} </div> diff --git a/packages/challenger-webui/src/pages/Setup.tsx b/packages/challenger-webui/src/pages/Setup.tsx @@ -22,9 +22,10 @@ import { randomBytes, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, ShowInputErrorLabel, useChallengerApiContext, + useNotifiedOperation, useNotificationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -51,7 +52,7 @@ export function Setup({ }: Props): VNode { const { i18n } = useTranslationContext(); const { lib } = useChallengerApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { start } = useSessionState(); const [password, setPassword] = useState<string | undefined>(secret); const [url, setUrl] = useState<string | undefined>(redirectURL?.href); @@ -65,36 +66,34 @@ export function Setup({ : undefined, }); - const doStart = actionHandler( - (ct, token: AccessToken, url) => lib.challenger.setup(clientId, token), + const startArgs = !!errors || password === undefined || url === undefined ? undefined - : [createRFC8959AccessTokenEncoded(password), url], - ); - - doStart.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 = ""; - window.location.href = redirect.href; - onCreated(); - }; - - doStart.onFail = showError( - i18n.str`Failed to setup a new challenge.`, - (fail) => { + : ([createRFC8959AccessTokenEncoded(password), url] as const); + const doStart = useNotifiedOperation< + Awaited<ReturnType<typeof lib.challenger.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 = ""; + window.location.href = redirect.href; + onCreated(); + }, + 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> @@ -171,13 +170,13 @@ export function Setup({ </div> </div> <div class="mt-10"> - <Button + <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={doStart} + onClick={startArgs ? () => doStart.run(...startArgs) : undefined} > <i18n.Translate>Start</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/Routing.tsx b/packages/libeufin-bank-webui/src/Routing.tsx @@ -20,6 +20,7 @@ import { useCurrentLocation, useNavigationContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -114,7 +115,7 @@ function PublicRounting({ const { navigateTo } = useNavigationContext(); const { config, lib } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const mfa = useBankChallengeHandlerContext(); @@ -131,7 +132,10 @@ function PublicRounting({ } as TokenRequest; // i18n.str`login`, - const login = actionHandler( + const login = useNotifiedOperation< + Awaited<ReturnType<typeof lib.bank.createAccessToken>>, + [string, string, string[]] + >( (ct, username: string, password: string, challengeIds: string[]) => lib.bank.createAccessToken( username, @@ -139,39 +143,47 @@ function PublicRounting({ tokenRequest, { challengeIds }, ), - ); - - login.onSuccess = (success, username) => - onLoggedUser( - username, - createRFC8959AccessTokenEncoded(success.access_token), - AbsoluteTime.fromProtocolTimestamp(success.expiration), - ); - - login.onFail = showError(i18n.str`Failed to login.`, (fail, username) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Identity verification.`, + { + onSuccess: (success, username) => { + mfa.cancel(); + onLoggedUser( username, - fail.body, - login.lambda((prev, next) => - !prev ? undefined : [prev[0], prev[1], next[0]], - ), + createRFC8959AccessTokenEncoded(success.access_token), + AbsoluteTime.fromProtocolTimestamp(success.expiration), ); - return undefined; - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials for "${username}"`; - case TalerErrorCode.GENERIC_FORBIDDEN: - return i18n.str`You do not have permission to access this account.`; - case TalerErrorCode.BANK_ACCOUNT_LOCKED: - return i18n.str`This account is locked. If you have an active session you can change the password or contact the administrator.`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - default: - assertUnreachable(fail); - } - }); + }, + onFail: showError( + i18n.str`Failed to login.`, + (fail, username, password) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Identity verification.`, + username, + fail.body, + { + running: login.running, + cancel: login.cancel, + run: (challengeIds) => + login.run(username, password, challengeIds), + }, + ); + return undefined; + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case TalerErrorCode.GENERIC_FORBIDDEN: + return i18n.str`You do not have permission to access this account.`; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + return i18n.str`This account is locked. If you have an active session you can change the password or contact the administrator.`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + default: + assertUnreachable(fail); + } + }, + ), + }, + ); switch (location.name) { case undefined: @@ -203,7 +215,7 @@ function PublicRounting({ <Fragment> <RegistrationPage onRegistrationSuccesful={(usr, pwd) => { - login.withArgs(usr, pwd, []).call(); + login.run(usr, pwd, []); }} routeCancel={publicPages.login} /> diff --git a/packages/libeufin-bank-webui/src/context/challenge.ts b/packages/libeufin-bank-webui/src/context/challenge.ts @@ -21,7 +21,7 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { - SafeHandler, + AsyncAction, useBankCoreApiContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, h, VNode } from "preact"; @@ -39,7 +39,7 @@ export type ContextType = { operation: TranslatedString, username: string, challenge: ChallengeResponse, - handler: SafeHandler<[string[]], any>, + handler: AsyncAction<[string[]]>, ): void; }; @@ -61,7 +61,7 @@ type MfaState = { loadingFirstChallenge: boolean; username: string; title: TranslatedString; - retry: SafeHandler<[string[]], any>; + retry: AsyncAction<[string[]]>; initial?: { request: Challenge; response?: ChallengeRequestResponse }; }; @@ -88,17 +88,11 @@ export const BankChallengeHandlerProvider = ({ operation: TranslatedString, username: string, requirement: ChallengeResponse, - handler: SafeHandler<[string[]], any>, + handler: AsyncAction<[string[]]>, ) { const loadingFirstChallenge = requirement.combi_and === true || requirement.challenges.length === 1; - handler.addListener((ev) => { - if (ev === "success") { - setState(undefined); - } - }); - // Set the sate now, if "LFC" is true "initial" is undefined it means "loading" setState({ username, diff --git a/packages/libeufin-bank-webui/src/hooks/account.ts b/packages/libeufin-bank-webui/src/hooks/account.ts @@ -31,7 +31,6 @@ import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { LONG_POLL_DELAY, PaginatedResult, - useAsync, useBankCoreApiContext, useLongPolling, } from "@gnu-taler/web-util/browser"; diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -1,6 +1,7 @@ import { Amounts, assertUnreachable, + CancellationToken, HttpStatusCode, InternationalizationAPI, RoundingMode, @@ -10,7 +11,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, ErrorLoading, InputText, InputToggle, @@ -20,6 +21,7 @@ import { ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -143,7 +145,7 @@ function Form({ const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const { lib, config } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const [section, setSection] = useState< "detail" | "cashout" | "cashin" | "users" | "test" | "delete" >("detail"); @@ -173,29 +175,31 @@ function Form({ ); // i18n.str`delete conversion rate class`, - const deleteClass = actionHandler( + const deleteClass = useNotifiedOperation< + Awaited<ReturnType<typeof lib.bank.deleteConversionRateClass>>, + [AccessToken] + >( (ct, token: AccessToken) => lib.bank.deleteConversionRateClass(token, classId), - !creds || section !== "delete" || detailsResult.num_users > 0 - ? undefined - : [creds.token], - ); - deleteClass.onSuccess = onClassDeleted; - deleteClass.onFail = showError( - i18n.str`Failed to delete conversion rate class`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Unauthorized`; - case HttpStatusCode.Forbidden: - return i18n.str`Forbidden`; - case HttpStatusCode.NotFound: - return i18n.str`NotFound`; - case HttpStatusCode.NotImplemented: - return i18n.str`NotImplemented`; - default: - assertUnreachable(fail); - } + { + onSuccess: onClassDeleted, + onFail: showError( + i18n.str`Failed to delete conversion rate class`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized`; + case HttpStatusCode.Forbidden: + return i18n.str`Forbidden`; + case HttpStatusCode.NotFound: + return i18n.str`NotFound`; + case HttpStatusCode.NotImplemented: + return i18n.str`NotImplemented`; + default: + assertUnreachable(fail); + } + }, + ), }, ); @@ -218,35 +222,39 @@ function Form({ }; // i18n.str`update conversion rate class`, - const updateClass = actionHandler( + const updateClass = useNotifiedOperation< + Awaited<ReturnType<typeof lib.bank.updateConversionRateClass>>, + [AccessToken, number, TalerCorebankApi.ConversionRateClassInput] + >( ( ct, t: AccessToken, id: number, i: TalerCorebankApi.ConversionRateClassInput, ) => lib.bank.updateConversionRateClass(t, id, i), - // !creds || !input ? undefined : ([creds.token, classId, input] as const), - ); - updateClass.onSuccess = () => { - setSection("detail"); - }; - updateClass.onFail = showError( - i18n.str`Failed to update conversion rate class.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Unauthorized`; - case HttpStatusCode.Forbidden: - return i18n.str`Forbidden`; - case HttpStatusCode.NotFound: - return i18n.str`Not Found`; - case HttpStatusCode.NotImplemented: - return i18n.str`Not implemented`; - case TalerErrorCode.BANK_NAME_REUSE: - return i18n.str`The name of the conversion is already used.`; - default: - assertUnreachable(fail); - } + { + onSuccess: () => { + setSection("detail"); + }, + onFail: showError( + i18n.str`Failed to update conversion rate class.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized`; + case HttpStatusCode.Forbidden: + return i18n.str`Forbidden`; + case HttpStatusCode.NotFound: + return i18n.str`Not Found`; + case HttpStatusCode.NotImplemented: + return i18n.str`Not implemented`; + case TalerErrorCode.BANK_NAME_REUSE: + return i18n.str`The name of the conversion is already used.`; + default: + assertUnreachable(fail); + } + }, + ), }, ); @@ -268,7 +276,7 @@ function Form({ cashout_rounding_mode: status.result.conv.cashout_rounding_mode, }; - const updateDetails = + const updateDetailsDisabled = !!( !creds || !updateRequest || section !== "detail" || @@ -276,10 +284,9 @@ function Form({ status.errors?.description || (status.result.name === initalState.name && status.result.description === initalState.description) - ? updateClass - : updateClass.withArgs(creds.token, classId, updateRequest); + ); - const updateCashin = + const updateCashinDisabled = !!( !creds || !updateRequest || section !== "cashin" || @@ -287,10 +294,9 @@ function Form({ status.errors?.conv?.cashin_min_amount || status.errors?.conv?.cashin_ratio || status.errors?.conv?.cashin_rounding_mode - ? updateClass - : updateClass.withArgs(creds.token, classId, updateRequest); + ); - const updateCashout = + const updateCashoutDisabled = !!( !creds || !updateRequest || section !== "cashout" || @@ -306,8 +312,7 @@ function Form({ status.result?.conv?.cashout_ratio === initalState.conv.cashout_ratio && status.result?.conv?.cashout_rounding_mode === initalState.conv.cashout_rounding_mode) - ? updateClass - : updateClass.withArgs(creds.token, classId, updateRequest); + ); const default_rate = conversionInfo.conversion_rate; @@ -672,50 +677,64 @@ function Form({ </a> {section == "cashin" ? ( <Fragment> - <Button + <AsyncButton submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={updateCashin} + disabled={updateCashinDisabled} + onClick={() => + updateClass.run(creds!.token, classId, updateRequest!) + } > <i18n.Translate>Update</i18n.Translate> - </Button> + </AsyncButton> </Fragment> ) : undefined} {section == "cashout" ? ( <Fragment> - <Button + <AsyncButton submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={updateCashout} + disabled={updateCashoutDisabled} + onClick={() => + updateClass.run(creds!.token, classId, updateRequest!) + } > <i18n.Translate>Update</i18n.Translate> - </Button> + </AsyncButton> </Fragment> ) : undefined} {section == "detail" ? ( <Fragment> - <Button + <AsyncButton submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={updateDetails} + disabled={updateDetailsDisabled} + onClick={() => + updateClass.run(creds!.token, classId, updateRequest!) + } > <i18n.Translate>Update</i18n.Translate> - </Button> + </AsyncButton> </Fragment> ) : undefined} {section == "delete" ? ( <Fragment> - <Button + <AsyncButton submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" - onClick={deleteClass} + disabled={ + !creds || + section !== "delete" || + detailsResult.num_users > 0 + } + onClick={() => deleteClass.run(creds!.token)} > <i18n.Translate>Delete</i18n.Translate> - </Button> + </AsyncButton> </Fragment> ) : undefined} </div> @@ -849,7 +868,7 @@ function TestConversionClass({ info: TalerBankConversionApi.TalerConversionInfoConfig; }): VNode { const { i18n } = useTranslationContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { estimateByDebit: calculateCashoutFromDebit } = useCashoutEstimatorForClass(classId); @@ -871,50 +890,54 @@ function TestConversionClass({ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); - // i18n.str`calculate cashout fee`, - const calculate = actionHandler( - async (ct, amount: AmountJson) => { - const respCashin = await calculateCashinFromDebit(amount, in_fee); - if (respCashin.type === "fail") { - return respCashin; - } - const cashin = respCashin.body; - const respCashout = await calculateCashoutFromDebit( - cashin.credit, - out_fee, - ); - if (respCashout.type === "fail") { - return respCashout; - } - const cashout = respCashout.body; - return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); - }, - !in_amount || !!error ? undefined : [in_amount], - ); + async function calculateOperation( + _ct: CancellationToken, + amount: AmountJson, + ) { + const respCashin = await calculateCashinFromDebit(amount, in_fee); + if (respCashin.type === "fail") { + return respCashin; + } + const cashin = respCashin.body; + const respCashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + if (respCashout.type === "fail") { + return respCashout; + } + const cashout = respCashout.body; + return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); + } - calculate.onSuccess = (resp) => setCalc(resp); - calculate.onFail = showError( - i18n.str`Failed to calculate the cashout fee.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not implemented.`; - case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; - case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malformed`; - case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: - return i18n.str`The currency is not supported`; - default: - assertUnreachable(fail); - } - }, - ); + // i18n.str`calculate cashout fee`, + const calculate = useNotifiedOperation< + Awaited<ReturnType<typeof calculateOperation>>, + [AmountJson] + >(calculateOperation, { + onSuccess: (resp) => setCalc(resp), + onFail: showError( + i18n.str`Failed to calculate the cashout fee.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malformed`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; + default: + assertUnreachable(fail); + } + }, + ), + }); useEffect(() => { - calculate.call(); + if (in_amount && !error) { + calculate.run(in_amount); + } }, [amount]); const cashinCalc = calculationResult?.cashin; diff --git a/packages/libeufin-bank-webui/src/pages/LoginForm.tsx b/packages/libeufin-bank-webui/src/pages/LoginForm.tsx @@ -27,11 +27,12 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, - Button, + AsyncButton, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -77,7 +78,7 @@ export function LoginForm({ config, lib: { bank: api }, } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const mfa = useBankChallengeHandlerContext(); @@ -91,11 +92,13 @@ export function LoginForm({ }); // i18n.str`logout`, - const logout = actionHandler(async () => { - session.logOut(); - return opEmptySuccess(dummyHttpResponse); - }, []); - logout.onSuccess = session.logOut; + const logout = useNotifiedOperation( + async () => { + session.logOut(); + return opEmptySuccess(dummyHttpResponse); + }, + { onSuccess: session.logOut }, + ); const tokenRequest = { scope: "readwrite", @@ -104,7 +107,10 @@ export function LoginForm({ } as TokenRequest; // i18n.str`login`, - const login = actionHandler( + const login = useNotifiedOperation< + Awaited<ReturnType<typeof api.createAccessToken>>, + [string, string, challengeIds?: string[]] + >( (ct, username: string, password: string, challengeIds?: string[]) => api.createAccessToken( username, @@ -112,41 +118,47 @@ export function LoginForm({ tokenRequest, { challengeIds }, ), - !!errors ? undefined : [username!, password!], - ); - - login.onSuccess = (result, username) => { - session.logIn({ - username, - token: createRFC8959AccessTokenEncoded(result.access_token), - expiration: AbsoluteTime.fromProtocolTimestamp(result.expiration), - }); - }; - - login.onFail = showError(i18n.str`Failed to login.`, (fail, username) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Identity verification.`, + { + onSuccess: (result, username) => { + mfa.cancel(); + session.logIn({ username, - fail.body, - login.lambda((prev, next) => - !prev ? undefined : [prev[0], prev[1], next[0]], - ), - ); - return undefined; - case TalerErrorCode.GENERIC_FORBIDDEN: - return i18n.str`The account has no rights to login.`; - case TalerErrorCode.BANK_ACCOUNT_LOCKED: - return i18n.str`The account is locked and cannot login. Contact administrator.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials for "${username}"`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - default: - assertUnreachable(fail); - } - }); + token: createRFC8959AccessTokenEncoded(result.access_token), + expiration: AbsoluteTime.fromProtocolTimestamp(result.expiration), + }); + }, + onFail: showError( + i18n.str`Failed to login.`, + (fail, username, password) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Identity verification.`, + username, + fail.body, + { + running: login.running, + cancel: login.cancel, + run: (challengeIds) => + login.run(username, password, challengeIds), + }, + ); + return undefined; + case TalerErrorCode.GENERIC_FORBIDDEN: + return i18n.str`The account has no rights to login.`; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + return i18n.str`The account is locked and cannot login. Contact administrator.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + default: + assertUnreachable(fail); + } + }, + ), + }, + ); const onlyThisUser = fixedUser || session.state.status !== "loggedOut"; return ( @@ -232,37 +244,39 @@ export function LoginForm({ {session.state.status !== "loggedOut" ? ( <div class="flex justify-between"> - <Button + <AsyncButton name="cancel" class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600" - onClick={logout} + onClick={() => logout.run()} > {session.state.status === "loggedIn" ? ( <i18n.Translate>Log out</i18n.Translate> ) : ( <i18n.Translate>Clear</i18n.Translate> )} - </Button> + </AsyncButton> - <Button + <AsyncButton submit name="check" class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 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={login} + disabled={!!errors} + onClick={() => login.run(username!, password!)} > <i18n.Translate>Verify</i18n.Translate> - </Button> + </AsyncButton> </div> ) : ( <div> - <Button + <AsyncButton submit name="login" class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 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 data-[failed=true]:hover:bg-error data-[failed=true]:bg-error" - onClick={login} + disabled={!!errors} + onClick={() => login.run(username!, password!)} > <i18n.Translate>Log in</i18n.Translate> - </Button> + </AsyncButton> </div> )} </form> diff --git a/packages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx b/packages/libeufin-bank-webui/src/pages/NewConversionRateClass.tsx @@ -6,10 +6,11 @@ import { TalerErrorCode, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, RouteDefinition, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; @@ -34,39 +35,43 @@ export function NewConversionRateClass({ lib: { bank: api }, } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const [submitData, setSubmitData] = useState< TalerCorebankApi.ConversionRateClassInput | undefined >(); // i18n.str`create conversion rate class`, - const create = actionHandler( + const create = useNotifiedOperation< + Awaited<ReturnType<typeof api.createConversionRateClass>>, + [AccessToken, TalerCorebankApi.ConversionRateClassInput] + >( (ct, token: AccessToken, data: TalerCorebankApi.ConversionRateClassInput) => api.createConversionRateClass(token, data), - !submitData || !token ? undefined : [token, submitData], - ); - create.onSuccess = (success) => { - // notifyInfo(i18n.str`Conversion rate class created.`); - onCreated(success.conversion_rate_class_id); - }; - create.onFail = showError( - i18n.str`Failed to create conversion class.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`The rights to change the account are not sufficient`; - case HttpStatusCode.Forbidden: - return i18n.str`Wrong credentials`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - case HttpStatusCode.NotImplemented: - return i18n.str`Not implemented`; - case TalerErrorCode.BANK_NAME_REUSE: - return i18n.str`The name of the conversion is already used.`; - default: - assertUnreachable(fail); - } + { + onSuccess: (success) => { + // notifyInfo(i18n.str`Conversion rate class created.`); + onCreated(success.conversion_rate_class_id); + }, + onFail: showError( + i18n.str`Failed to create conversion class.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`The rights to change the account are not sufficient`; + case HttpStatusCode.Forbidden: + return i18n.str`Wrong credentials`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case HttpStatusCode.NotImplemented: + return i18n.str`Not implemented`; + case TalerErrorCode.BANK_NAME_REUSE: + return i18n.str`The name of the conversion is already used.`; + default: + assertUnreachable(fail); + } + }, + ), }, ); @@ -87,14 +92,15 @@ export function NewConversionRateClass({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="create" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={create} + disabled={!submitData || !token} + onClick={() => create.run(token!, submitData!)} > <i18n.Translate>Create</i18n.Translate> - </Button> + </AsyncButton> </div> </ConversionRateClassForm> </div> diff --git a/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx b/packages/libeufin-bank-webui/src/pages/OperationState/views.tsx @@ -25,10 +25,11 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, RenderAmount, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTalerWalletIntegrationAPI, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -67,7 +68,7 @@ export function NeedConfirmationView({ }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const mfa = useBankChallengeHandlerContext(); const { state: credentials } = useSessionState(); @@ -82,14 +83,12 @@ export function NeedConfirmationView({ : Amounts.parseOrThrow(config.wire_transfer_fees); // i18n.str`abort withdrawal`, - const abort = actionHandler( - (ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), - !creds ? undefined : ([creds] as const), - ); - abort.onSuccess = onAbort; - abort.onFail = showError( - i18n.str`Failed to abort the withdrawal.`, - (fail) => { + const abort = useNotifiedOperation< + Awaited<ReturnType<typeof bank.abortWithdrawalById>>, + [LoggedIn] + >((ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), { + onSuccess: onAbort, + onFail: showError(i18n.str`Failed to abort the withdrawal.`, (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: return i18n.str`The server did not understand the request.`; @@ -100,52 +99,59 @@ export function NeedConfirmationView({ default: assertUnreachable(fail); } - }, - ); + }), + }); // i18n.str`confirm withdrawal`, - const confirm = actionHandler( + const confirm = useNotifiedOperation< + Awaited<ReturnType<typeof bank.confirmWithdrawalById>>, + [LoggedIn, challengeIds?: string[]] + >( (ct, creds: LoggedIn, challengeIds?: string[]) => bank.confirmWithdrawalById(creds, {}, operationId, { challengeIds }), - !creds ? undefined : ([creds, undefined as string[] | undefined] as const), - ); - confirm.onSuccess = () => { - if (!settings.showWithdrawalSuccess) { - // notifyInfo(i18n.str`Wire transfer completed!`); - } - onAbort(); - }; - confirm.onFail = showError( - i18n.str`Failed to confirm the withdrawal.`, - (fail, creds) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Withdrawal confirmation`, - creds.username, - fail.body, - confirm.lambda((prev, next) => - !prev ? undefined : [prev[0], next[0]], - ), - ); - return undefined; - case HttpStatusCode.BadRequest: - return i18n.str`The server did not understand the request.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`; - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return i18n.str`The withdrawal has been aborted and can not be confirmed.`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`The withdrawal has no exchange and reserve public selected.`; - case TalerErrorCode.BANK_AMOUNT_DIFFERS: - return i18n.str`The starting withdrawal amount and the confirmation amount differ.`; - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`The bank requires a bank account which has not been specified yet.`; - default: - assertUnreachable(fail); - } + { + onSuccess: () => { + mfa.cancel(); + if (!settings.showWithdrawalSuccess) { + // notifyInfo(i18n.str`Wire transfer completed!`); + } + onAbort(); + }, + onFail: showError( + i18n.str`Failed to confirm the withdrawal.`, + (fail, creds) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Withdrawal confirmation`, + creds.username, + fail.body, + { + running: confirm.running, + cancel: confirm.cancel, + run: (challengeIds) => confirm.run(creds, challengeIds), + }, + ); + return undefined; + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`; + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + return i18n.str`The withdrawal has been aborted and can not be confirmed.`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`The withdrawal has no exchange and reserve public selected.`; + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return i18n.str`The starting withdrawal amount and the confirmation amount differ.`; + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return i18n.str`The bank requires a bank account which has not been specified yet.`; + default: + assertUnreachable(fail); + } + }, + ), }, ); @@ -344,7 +350,7 @@ export function NeedConfirmationView({ ); } case PaytoType.Void: - return <p>void account not suported</p> + return <p>void account not suported</p>; default: { assertUnreachable(details.account); } @@ -388,21 +394,23 @@ export function NeedConfirmationView({ </div> </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <Button + <AsyncButton name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={abort} + disabled={!creds} + onClick={() => abort.run(creds!)} > <i18n.Translate>Cancel</i18n.Translate> - </Button> - <Button + </AsyncButton> + <AsyncButton submit name="transfer" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={confirm} + disabled={!creds} + onClick={() => confirm.run(creds!)} > <i18n.Translate>Transfer</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </ShouldBeSameUser> @@ -551,7 +559,7 @@ export function ReadyView({ }: State.Ready): VNode { const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; @@ -570,22 +578,23 @@ export function ReadyView({ }, []); // i18n.str`abort withdrawal`, - const abort = actionHandler( - (ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), - !creds ? undefined : ([creds] as const), - ); - abort.onSuccess = onAbort; - abort.onFail = showError(i18n.str`Failed to abort the withdrawal`, (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The server did not understand the request.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case HttpStatusCode.Conflict: - return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; - default: - assertUnreachable(fail); - } + const abort = useNotifiedOperation< + Awaited<ReturnType<typeof bank.abortWithdrawalById>>, + [LoggedIn] + >((ct, creds: LoggedIn) => bank.abortWithdrawalById(creds, operationId), { + onSuccess: onAbort, + onFail: showError(i18n.str`Failed to abort the withdrawal`, (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + default: + assertUnreachable(fail); + } + }), }); return ( @@ -615,14 +624,15 @@ export function ReadyView({ </p> </div> <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> - <Button + <AsyncButton name="cancel" class="text-sm font-semibold leading-6 text-gray-900" // class="inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-black shadow-sm " - onClick={abort} + disabled={!creds} + onClick={() => abort.run(creds!)} > <i18n.Translate>Cancel</i18n.Translate> - </Button> + </AsyncButton> <a href={talerWithdrawUri} @@ -652,12 +662,13 @@ export function ReadyView({ </div> </div> <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <Button + <AsyncButton class="text-sm font-semibold leading-6 text-gray-900" - onClick={abort} + disabled={!creds} + onClick={() => abort.run(creds!)} > <i18n.Translate>Cancel</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </Fragment> diff --git a/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx b/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx @@ -29,13 +29,14 @@ import { assertUnreachable, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, InternationalizationAPI, RenderAmount, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; @@ -74,6 +75,7 @@ export function PaytoWireTransferForm({ const isRawPayto = inputType !== "form"; const { state: credentials } = useSessionState(); + const creds = credentials.status === "loggedIn" ? credentials : undefined; const { lib: { bank: api }, config, @@ -102,7 +104,7 @@ export function PaytoWireTransferForm({ const parsedAmount = Amounts.parse( `${limitWithFee.currency}:${trimmedAmountStr}`, ); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const mfa = useBankChallengeHandlerContext(); const paytoType = @@ -173,9 +175,17 @@ export function PaytoWireTransferForm({ `${limitWithFee.currency}:${trimmedAmountStr}` as AmountString; } const sAmount = sendingAmount; + const sendDisabled = + (isRawPayto ? !!errorsPayto : !!errorsWire) || + !sAmount || + !parsedURI || + !creds; // i18n.str`send transaction`, - const send = actionHandler( + const send = useNotifiedOperation< + Awaited<ReturnType<typeof api.createTransaction>>, + [LoggedIn, AmountString, Paytos.URI, challengeIds?: string[]] + >( ( ct, creds: LoggedIn, @@ -188,61 +198,54 @@ export function PaytoWireTransferForm({ { payto_uri: Paytos.toFullString(uri), amount }, { challengeIds }, ), - (isRawPayto ? !!errorsPayto : !!errorsWire) || - !sAmount || - !parsedURI || - credentials.status !== "loggedIn" - ? undefined - : ([ - credentials, - sAmount, - parsedURI, - undefined as string[] | undefined, - ] as const), - ); - - send.onSuccess = (success) => { - // notifyInfo(i18n.str`The wire transfer was successfully completed!`); - if (onSuccess) onSuccess(); - setAmount(undefined); - setAccount(undefined); - setSubject(undefined); - rawPaytoInputSetter(undefined); - }; - - send.onFail = showError( - i18n.str`Failed to create the transactions.`, - (fail, creds, amount, uri) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Not enough permission to complete the operation.`; - case TalerErrorCode.BANK_ADMIN_CREDITOR: - return i18n.str`The bank administrator cannot be the transfer creditor.`; - case TalerErrorCode.BANK_UNKNOWN_CREDITOR: - return i18n.str`The destination account "${uri.displayName}" was not found.`; - case TalerErrorCode.BANK_SAME_ACCOUNT: - return i18n.str`The origin and the destination of the transfer can't be the same.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Your balance is not sufficient for the operation.`; - case HttpStatusCode.NotFound: - return i18n.str`The origin account "${uri.displayName}" was not found.`; - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return i18n.str`The attempt to create the transaction has failed. Please try again.`; - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Confirm transaction.`, - creds.username, - fail.body, - send.lambda((prev, next) => - !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], - ), - ); - return undefined; - default: - assertUnreachable(fail); - } + { + onSuccess: () => { + mfa.cancel(); + // notifyInfo(i18n.str`The wire transfer was successfully completed!`); + if (onSuccess) onSuccess(); + setAmount(undefined); + setAccount(undefined); + setSubject(undefined); + rawPaytoInputSetter(undefined); + }, + onFail: showError( + i18n.str`Failed to create the transactions.`, + (fail, creds, amount, uri) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Not enough permission to complete the operation.`; + case TalerErrorCode.BANK_ADMIN_CREDITOR: + return i18n.str`The bank administrator cannot be the transfer creditor.`; + case TalerErrorCode.BANK_UNKNOWN_CREDITOR: + return i18n.str`The destination account "${uri.displayName}" was not found.`; + case TalerErrorCode.BANK_SAME_ACCOUNT: + return i18n.str`The origin and the destination of the transfer can't be the same.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Your balance is not sufficient for the operation.`; + case HttpStatusCode.NotFound: + return i18n.str`The origin account "${uri.displayName}" was not found.`; + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return i18n.str`The attempt to create the transaction has failed. Please try again.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Confirm transaction.`, + creds.username, + fail.body, + { + running: send.running, + cancel: send.cancel, + run: (challengeIds) => + send.run(creds, amount, uri, challengeIds), + }, + ); + return undefined; + default: + assertUnreachable(fail); + } + }, + ), }, ); @@ -648,14 +651,15 @@ export function PaytoWireTransferForm({ ) : ( <div /> )} - <Button + <AsyncButton submit name="send" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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} + disabled={sendDisabled} + onClick={() => send.run(creds!, sAmount!, parsedURI!)} > <i18n.Translate>Send</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx b/packages/libeufin-bank-webui/src/pages/QrCodeSection.tsx @@ -22,9 +22,10 @@ import { TalerWithdrawUri, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTalerWalletIntegrationAPI, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -52,33 +53,36 @@ export function QrCodeSection({ walletInegrationApi.publishTalerAction(withdrawUri); }, []); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { lib: { bank: api }, } = useBankCoreApiContext(); // i18n.str`abort withdrawal`, - const abort = actionHandler( + const abort = useNotifiedOperation< + Awaited<ReturnType<typeof api.abortWithdrawalById>>, + [UserAndToken] + >( (ct, creds: UserAndToken) => api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId), - !creds ? undefined : [creds], + { + onSuccess: onAborted, + onFail: showError(i18n.str`Failed to abort withdrawal.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The operation ID is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; + default: + assertUnreachable(fail); + } + }), + }, ); - abort.onSuccess = onAborted; - abort.onFail = showError(i18n.str`Failed to abort withdrawal.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case HttpStatusCode.Conflict: - return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; - default: - assertUnreachable(fail); - } - }); - return ( <Fragment> <div class="bg-white shadow-xl sm:rounded-lg"> @@ -106,13 +110,14 @@ export function QrCodeSection({ </p> </div> <div class="flex items-center justify-between gap-x-6 pt-2 mt-2 "> - <Button + <AsyncButton name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={abort} + disabled={!creds} + onClick={() => abort.run(creds!)} > <i18n.Translate>Cancel</i18n.Translate> - </Button> + </AsyncButton> <a href={talerWithdrawUri} name="withdraw" @@ -141,12 +146,13 @@ export function QrCodeSection({ </div> </div> <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <Button + <AsyncButton class="text-sm font-semibold leading-6 text-gray-900" - onClick={abort} + disabled={!creds} + onClick={() => abort.run(creds!)} > <i18n.Translate>Cancel</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </Fragment> diff --git a/packages/libeufin-bank-webui/src/pages/RegistrationPage.tsx b/packages/libeufin-bank-webui/src/pages/RegistrationPage.tsx @@ -20,11 +20,12 @@ import { TalerErrorCode, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -81,7 +82,7 @@ function RegistrationForm({ // const [phone, setPhone] = useState<string | undefined>(); // const [email, setEmail] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const settings = useSettingsContext(); const [pref] = usePreferences(); @@ -120,59 +121,58 @@ function RegistrationForm({ }; // i18n.str`register new account`, - const register = actionHandler( + const register = useNotifiedOperation< + Awaited<ReturnType<typeof api.createAccount>>, + [TalerCorebankApi.RegisterAccountRequest] + >( (ct, account: TalerCorebankApi.RegisterAccountRequest) => api.createAccount(undefined, account), - !!errors || !reg ? undefined : [reg], - ); - - register.onSuccess = (success, acc) => { - setUsername(undefined); - setPassword(undefined); - setRepeatPassword(undefined); - setName(undefined); - onRegistrationSuccesful(acc.username, acc.password); - }; - - register.onFail = showError( - i18n.str`Failed to create a new account.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`Server replied with invalid phone or email.`; - case HttpStatusCode.Unauthorized: - return i18n.str`You are not authorized to create this account.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return i18n.str`That username can't be used because it is reserved.`; - case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: - return i18n.str`That username is already taken.`; - case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: - return i18n.str`That account ID is already taken.`; - case TalerErrorCode.BANK_MISSING_TAN_INFO: - return i18n.str`No information for the selected authentication channel.`; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: - return i18n.str`Authentication channel is not supported.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: - return i18n.str`Only an administrator is allowed to set the debt limit.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: - return i18n.str`Only the administrator can change the conversion rate.`; - case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: - return i18n.str`The conversion rate class doesn't exist.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: - return i18n.str`Only admin can create accounts with second factor authentication.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } + { + onSuccess: (success, acc) => { + setUsername(undefined); + setPassword(undefined); + setRepeatPassword(undefined); + setName(undefined); + onRegistrationSuccesful(acc.username, acc.password); + }, + onFail: showError(i18n.str`Failed to create a new account.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`Server replied with invalid phone or email.`; + case HttpStatusCode.Unauthorized: + return i18n.str`You are not authorized to create this account.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`That username can't be used because it is reserved.`; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return i18n.str`That username is already taken.`; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return i18n.str`That account ID is already taken.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`Only an administrator is allowed to set the debt limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return i18n.str`Only admin can create accounts with second factor authentication.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); + } + }), }, ); - const registerRandom = register.lambda(() => { + const registerRandom = () => { const user = getRandomUsername(); const password = "12345678"; @@ -180,8 +180,8 @@ function RegistrationForm({ const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter( user.second, )}`; - return [{ name, username, password }]; - }, []); + return register.run({ name, username, password }); + }; return ( <Fragment> @@ -340,27 +340,28 @@ function RegistrationForm({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="register" class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 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={register} + disabled={!!errors || !reg} + onClick={() => register.run(reg!)} > <i18n.Translate>Register</i18n.Translate> - </Button> + </AsyncButton> </div> </form> {settings.allowRandomAccountCreation && ( <p class="mt-10 text-center text-sm text-gray-500 border-t"> - <Button + <AsyncButton submit name="create random" class="flex mt-4 w-full disabled:bg-gray-300 justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" - onClick={registerRandom} + onClick={() => registerRandom()} > <i18n.Translate>Create a random temporary user</i18n.Translate> - </Button> + </AsyncButton> </p> )} </div> diff --git a/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx b/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx @@ -11,13 +11,14 @@ import { } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { - Button, - SafeHandler, + AsyncAction, + AsyncButton, ShowInputErrorLabel, Time, undefinedIfEmpty, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; @@ -27,7 +28,7 @@ import { doAutoFocus } from "./PaytoWireTransferForm.js"; const TALER_SCREEN_ID = 9; export interface Props { - onCompleted: SafeHandler<[challenges: string[]], any>; + onCompleted: AsyncAction<[challenges: string[]]>; username: string; onCancel(): void; description: TranslatedString; @@ -53,7 +54,7 @@ function SolveChallenge({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const [showExpired, setExpired] = useState( expiration !== undefined && AbsoluteTime.isExpired(expiration), @@ -76,31 +77,32 @@ function SolveChallenge({ }, []); // i18n.str`confirm MFA challenge`, - const doVerification = actionHandler( + const doVerification = useNotifiedOperation< + Awaited<ReturnType<typeof api.confirmChallenge>>, + [string] + >( (ct, tan: string) => api.confirmChallenge(username, challenge.challenge_id, { tan }), - !errors ? [tanCode!] : undefined, - ); - doVerification.onFail = showError( - i18n.str`Faild to verify the code.`, - (fail) => { - switch (fail.case) { - case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: - return i18n.str`Unknown challenge.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Failed to validate the verification code.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`; - case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: - return i18n.str`Wrong authentication number.`; - case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: - return i18n.str`Expired challenge.`; - default: - assertUnreachable(fail); - } + { + onSuccess: onSolved, + onFail: showError(i18n.str`Faild to verify the code.`, (fail) => { + switch (fail.case) { + case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: + return i18n.str`Unknown challenge.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Failed to validate the verification code.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`; + case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: + return i18n.str`Wrong authentication number.`; + case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + return i18n.str`Expired challenge.`; + default: + assertUnreachable(fail); + } + }), }, ); - doVerification.onSuccess = onSolved; return ( <Fragment> @@ -207,14 +209,15 @@ function SolveChallenge({ <i18n.Translate>Back</i18n.Translate> </button> - <Button + <AsyncButton submit name="send again" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={doVerification} + disabled={!!errors} + onClick={() => doVerification.run(tanCode!)} > <i18n.Translate>Verify</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </div> @@ -278,7 +281,7 @@ function SolveMFAChallenges({ ch: Challenge; expiration: AbsoluteTime; }>(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { lib: { bank: api }, @@ -289,6 +292,54 @@ function SolveMFAChallenges({ Record<string, AbsoluteTime | undefined> >({}); + // i18n.str`send MFA challenge`, + const sendMessage = useNotifiedOperation< + Awaited<ReturnType<typeof api.sendChallenge>>, + [Challenge] + >((ct, ch: Challenge) => api.sendChallenge(username, ch.challenge_id), { + onSuccess: (success, ch) => { + if (success.earliest_retransmission) { + setRetransmission({ + ...retransmission, + [ch.challenge_id]: AbsoluteTime.fromProtocolTimestamp( + success.earliest_retransmission, + ), + }); + } + setSelected({ + ch, + expiration: !success.solve_expiration + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(success.solve_expiration), + }); + }, + onFail: showError(i18n.str`Failed to start the challenge.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unable to send the verification code.`; + case HttpStatusCode.Forbidden: + return i18n.str`The request was valid, but the server is refusing action.`; + case HttpStatusCode.NotFound: + return i18n.str`The backend is not aware of the specified MFA challenge.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`It is too early to request another transmission of the challenge.`; + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return i18n.str`Code transmission failed.`; + default: + assertUnreachable(fail); + } + }), + }); + + // i18n.str`select challenge`, + const selectChallenge = useNotifiedOperation(async (ct, ch: Challenge) => { + setSelected({ + ch, + expiration: AbsoluteTime.never(), + }); + return opEmptySuccess(dummyHttpResponse); + }); + if (selected) { return ( <SolveChallenge @@ -305,7 +356,7 @@ function SolveMFAChallenges({ if (enough) { setSolved(total); - await onCompleted.withArgs(total).call(); + await onCompleted.run(total); } else { setSolved(total); } @@ -321,58 +372,6 @@ function SolveMFAChallenges({ ? currentSolved.length === currentChallenge.challenges.length : currentSolved.length > 0; - // i18n.str`send MFA challenge`, - const sendMessage = actionHandler((ct, ch: Challenge) => - api.sendChallenge(username, ch.challenge_id), - ); - sendMessage.onSuccess = (success, ch) => { - if (success.earliest_retransmission) { - setRetransmission({ - ...retransmission, - [ch.challenge_id]: AbsoluteTime.fromProtocolTimestamp( - success.earliest_retransmission, - ), - }); - } - setSelected({ - ch, - expiration: !success.solve_expiration - ? AbsoluteTime.never() - : AbsoluteTime.fromProtocolTimestamp(success.solve_expiration), - }); - }; - - sendMessage.onFail = showError( - i18n.str`Failed to start the challenge.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Unable to send the verification code.`; - case HttpStatusCode.Forbidden: - return i18n.str`The request was valid, but the server is refusing action.`; - case HttpStatusCode.NotFound: - return i18n.str`The backend is not aware of the specified MFA challenge.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`It is too early to request another transmission of the challenge.`; - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return i18n.str`Code transmission failed.`; - default: - assertUnreachable(fail); - } - }, - ); - - const complete = onCompleted.withArgs(solved); - - // i18n.str`select challenge`, - const selectChallenge = actionHandler(async (ct, ch: Challenge) => { - setSelected({ - ch, - expiration: AbsoluteTime.never(), - }); - return opEmptySuccess(dummyHttpResponse); - }); - return ( <Fragment> <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> @@ -436,15 +435,6 @@ function SolveMFAChallenges({ hasSolvedEnough || solved.indexOf(challenge.challenge_id) !== -1; - const doSelect = noNeedToComplete - ? selectChallenge - : selectChallenge.withArgs(challenge); - - const doSend = - alreadySent || noNeedToComplete - ? sendMessage - : sendMessage.withArgs(challenge); - return ( <div key={idx} class="rounded-xl border px-2 my-2"> <dl class="divide-y divide-gray-100"> @@ -470,22 +460,24 @@ function SolveMFAChallenges({ </dt> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:mt-0"> <div class="flex justify-between"> - <Button + <AsyncButton name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={doSelect} + disabled={noNeedToComplete} + onClick={() => selectChallenge.run(challenge)} > <i18n.Translate>I have a code</i18n.Translate> - </Button> + </AsyncButton> - <Button + <AsyncButton submit name="send again" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={doSend} + disabled={alreadySent || noNeedToComplete} + onClick={() => sendMessage.run(challenge)} > <i18n.Translate>Send me a message</i18n.Translate> - </Button> + </AsyncButton> </div> </dd> {alreadySent && time.t_ms !== "never" ? ( @@ -513,14 +505,14 @@ function SolveMFAChallenges({ <i18n.Translate>Cancel</i18n.Translate> </button> - <Button + <AsyncButton submit name="send again" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={complete} + onClick={() => onCompleted.run(solved)} > <i18n.Translate>Complete</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </div> diff --git a/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx b/packages/libeufin-bank-webui/src/pages/WalletWithdrawForm.tsx @@ -26,12 +26,13 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, RenderAmount, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -79,7 +80,7 @@ function OldWithdrawalForm({ const [amountStr, setAmountStr] = useState<string | undefined>( `${settings.defaultSuggestedAmount ?? 1}`, ); - const { actionHandler, showError, displayError } = useNotificationContext(); + const { showError, displayError } = useNotificationContext(); const trimmedAmountStr = amountStr?.trim(); @@ -99,7 +100,10 @@ function OldWithdrawalForm({ }); // i18n.str`create withdrawal`, - const start = actionHandler( + const start = useNotifiedOperation< + Awaited<ReturnType<typeof api.createWithdrawal>>, + [UserAndToken, AmountString] + >( (ct, creds: UserAndToken, amount: AmountString) => api.createWithdrawal( creds, @@ -107,41 +111,35 @@ function OldWithdrawalForm({ ? { suggested_amount: amount } : { amount: amount }, ), - !parsedAmount || !creds || !!errors - ? undefined - : [creds, Amounts.stringify(parsedAmount)], - ); - - start.onSuccess = (success) => { - const uri = TalerUris.parse(success.taler_withdraw_uri); - if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) { - displayError( - i18n.str`The server replied with an invalid taler://withdraw URI`, - i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`, - ); - return; - } else { - updateBankState( - "currentWithdrawalOperationId", - uri.value.withdrawalOperationId, - ); - onOperationCreated(uri.value.withdrawalOperationId); - } - }; - - start.onFail = showError( - i18n.str`Failed to create the withdrawal.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The operation was rejected due to insufficient funds`; - case HttpStatusCode.Unauthorized: - return i18n.str`The operation was rejected due to insufficient funds`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - default: - assertUnreachable(fail); - } + { + onSuccess: (success) => { + const uri = TalerUris.parse(success.taler_withdraw_uri); + if (uri.tag === "error" || uri.value.type !== TalerUriAction.Withdraw) { + displayError( + i18n.str`The server replied with an invalid taler://withdraw URI`, + i18n.str`Withdraw URI: ${success.taler_withdraw_uri}`, + ); + return; + } else { + updateBankState( + "currentWithdrawalOperationId", + uri.value.withdrawalOperationId, + ); + onOperationCreated(uri.value.withdrawalOperationId); + } + }, + onFail: showError(i18n.str`Failed to create the withdrawal.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.Conflict: + return i18n.str`The operation was rejected due to insufficient funds`; + case HttpStatusCode.Unauthorized: + return i18n.str`The operation was rejected due to insufficient funds`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + default: + assertUnreachable(fail); + } + }), }, ); @@ -252,15 +250,16 @@ function OldWithdrawalForm({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="continue" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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" // disabled={isRawPayto ? !!errorsPayto : !!errorsWire} - onClick={start} + disabled={!parsedAmount || !creds || !!errors} + onClick={() => start.run(creds!, Amounts.stringify(parsedAmount!))} > <i18n.Translate>Continue</i18n.Translate> - </Button> + </AsyncButton> </div> </form> ); diff --git a/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/libeufin-bank-webui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -26,10 +26,11 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, RenderAmount, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; @@ -53,7 +54,9 @@ interface Props { function useComponentState(opid: string) { const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { actionHandler } = useNotificationContext(); + const { i18n } = useTranslationContext(); + const { showError } = useNotificationContext(); + const mfa = useBankChallengeHandlerContext(); const { config, @@ -66,33 +69,85 @@ function useComponentState(opid: string) { : Amounts.parseOrThrow(config.wire_transfer_fees); // i18n.str`confirm withdrawal`, - const confirm = actionHandler( + const confirm = useNotifiedOperation< + Awaited<ReturnType<typeof api.confirmWithdrawalById>>, + [LoggedIn, challengeIds?: string[]] + >( (ct, creds: LoggedIn, challengeIds?: string[]) => api.confirmWithdrawalById(creds, {}, opid, { challengeIds, }), - !creds ? undefined : ([creds, undefined as string[] | undefined] as const), + { + onSuccess: () => { + mfa.cancel(); + mutate(() => true); // clean any info that we have + }, + onFail: showError( + i18n.str`Failed to confirm the withdrawal.`, + (fail, creds) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Withdrawal confirmation`, + creds.username, + fail.body, + { + running: confirm.running, + cancel: confirm.cancel, + run: (challengeIds) => confirm.run(creds, challengeIds), + }, + ); + return undefined; + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`; + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + return i18n.str`The withdrawal has been aborted and can not be confirmed.`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`The withdrawal has no exchange and reserve public selected.`; + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return i18n.str`The starting withdrawal amount and the confirmation amount differ.`; + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return i18n.str`The bank requires a bank account which has not been specified yet.`; + default: + assertUnreachable(fail); + } + }, + ), + }, ); - confirm.onSuccess = () => { - mutate(() => true); // clean any info that we have - }; - // i18n.str`abort withdrawal`, - const abort = actionHandler( - (ct, s, id) => api.abortWithdrawalById(s, id), - !creds ? undefined : ([creds, opid] as const), - ); - - abort.onSuccess = () => { - mutate(() => true); // clean any info that we have - }; + const abort = useNotifiedOperation< + Awaited<ReturnType<typeof api.abortWithdrawalById>>, + [LoggedIn, string] + >((ct, s, id) => api.abortWithdrawalById(s, id), { + onSuccess: () => { + mutate(() => true); // clean any info that we have + }, + onFail: showError(i18n.str`Failed to abort the withdrawal.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The server did not understand the request.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case HttpStatusCode.Conflict: + return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; + default: + assertUnreachable(fail); + } + }), + }); const spec = config.currency_specification; return { wireFee, spec, + creds, abort, confirm, }; @@ -107,62 +162,10 @@ export function WithdrawalConfirmationQuestion({ withdrawUri, }: Props): VNode { const { i18n } = useTranslationContext(); - const { wireFee, spec, abort, confirm } = useComponentState( + const { wireFee, spec, creds, abort, confirm } = useComponentState( withdrawUri.withdrawalOperationId, ); - const mfa = useBankChallengeHandlerContext(); - const { showError } = useNotificationContext(); - - confirm.onFail = showError( - i18n.str`Failed to confirm the withdrawal.`, - (fail, creds) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Withdrawal confirmation`, - creds.username, - fail.body, - confirm.lambda((prev, next) => - !prev ? undefined : [prev[0], next[0]], - ), - ); - return undefined; - case HttpStatusCode.BadRequest: - return i18n.str`The server did not understand the request.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`The account does not have sufficient funds or the amount is outside the limits.`; - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return i18n.str`The withdrawal has been aborted and can not be confirmed.`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`The withdrawal has no exchange and reserve public selected.`; - case TalerErrorCode.BANK_AMOUNT_DIFFERS: - return i18n.str`The starting withdrawal amount and the confirmation amount differ.`; - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`The bank requires a bank account which has not been specified yet.`; - default: - assertUnreachable(fail); - } - }, - ); - abort.onFail = showError( - i18n.str`Failed to abort the withdrawal.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.BadRequest: - return i18n.str`The server did not understand the request.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case HttpStatusCode.Conflict: - return i18n.str`The withdrawal operation has been confirmed previously and can not be aborted.`; - default: - assertUnreachable(fail); - } - }, - ); - return ( <Fragment> <div class="bg-white shadow sm:rounded-lg"> @@ -420,21 +423,25 @@ export function WithdrawalConfirmationQuestion({ </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <Button + <AsyncButton name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={abort} + disabled={!creds} + onClick={() => + abort.run(creds!, withdrawUri.withdrawalOperationId) + } > <i18n.Translate>Cancel</i18n.Translate> - </Button> - <Button + </AsyncButton> + <AsyncButton submit name="transfer" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={confirm} + disabled={!creds} + onClick={() => confirm.run(creds!)} > <i18n.Translate>Transfer</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx b/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx @@ -23,13 +23,14 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, CopyButton, ErrorLoading, Loading, RouteDefinition, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -83,9 +84,80 @@ export function ShowAccountDetails({ const [submitAccount, setSubmitAccount] = useState< TalerCorebankApi.AccountReconfiguration | undefined >(); - const { actionHandler, showError, displayInfo } = useNotificationContext(); + const { showError, displayInfo } = useNotificationContext(); const result = useAccountDetails(account); + + // i18n.str`update account`, + const update = useNotifiedOperation< + Awaited<ReturnType<typeof bank.updateAccount>>, + [ + string, + AccessToken, + TalerCorebankApi.AccountReconfiguration, + challengeIds?: string[], + ] + >( + ( + ct, + username: string, + token: AccessToken, + account: TalerCorebankApi.AccountReconfiguration, + challengeIds?: string[], + ) => bank.updateAccount({ username, token }, account, { challengeIds }), + { + onSuccess: () => { + mfa.cancel(); + displayInfo(i18n.str`Account updated`); + // onUpdateSuccess(); + }, + onFail: showError( + i18n.str`Failed to update the account.`, + (fail, username, token, account) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`The rights to change the account are not sufficient`; + case HttpStatusCode.NotFound: + return i18n.str`The username was not found`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: + return i18n.str`You can't change the legal name, please contact your account administrator.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`You can't change the debt limit, please contact your account administrator.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: + return i18n.str`You can't change the cashout address, please contact your account administrator.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Account update`, + username, + fail.body, + { + running: update.running, + cancel: update.cancel, + run: (challengeIds) => + update.run(username, token, account, challengeIds), + }, + ); + return undefined; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); + } + }, + ), + }, + ); + if (!result) { return <Loading />; } @@ -110,72 +182,6 @@ export function ShowAccountDetails({ } } - // i18n.str`update account`, - const update = actionHandler( - ( - ct, - username: string, - token: AccessToken, - account: TalerCorebankApi.AccountReconfiguration, - challengeIds?: string[], - ) => bank.updateAccount({ username, token }, account, { challengeIds }), - !sessionToken || !submitAccount - ? undefined - : ([ - account, - sessionToken, - submitAccount, - undefined as string[] | undefined, - ] as const), - ); - - update.onSuccess = (success) => { - displayInfo(i18n.str`Account updated`); - // onUpdateSuccess(); - }; - - update.onFail = showError( - i18n.str`Failed to update the account.`, - (fail, username) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`The rights to change the account are not sufficient`; - case HttpStatusCode.NotFound: - return i18n.str`The username was not found`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: - return i18n.str`You can't change the legal name, please contact your account administrator.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: - return i18n.str`You can't change the debt limit, please contact your account administrator.`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: - return i18n.str`You can't change the cashout address, please contact your account administrator.`; - case TalerErrorCode.BANK_MISSING_TAN_INFO: - return i18n.str`No information for the selected authentication channel.`; - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Account update`, - username, - fail.body, - update.lambda((prev, next) => - !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], - ), - ); - return undefined; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: - return i18n.str`Authentication channel is not supported.`; - case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: - return i18n.str`Only the administrator can change the conversion rate.`; - case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: - return i18n.str`The conversion rate class doesn't exist.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } - }, - ); - const url = bank.getRevenueAPI(account); const baseURL = url.href; const revenueURL = new URL(baseURL); @@ -238,14 +244,15 @@ export function ShowAccountDetails({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="update" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={update} + disabled={!sessionToken || !submitAccount} + onClick={() => update.run(account, sessionToken!, submitAccount!)} > <i18n.Translate>Update</i18n.Translate> - </Button> + </AsyncButton> </div> </AccountForm> </div> diff --git a/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx b/packages/libeufin-bank-webui/src/pages/account/UpdateAccountPassword.tsx @@ -21,11 +21,12 @@ import { TalerErrorCode, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -92,10 +93,19 @@ export function UpdateAccountPassword({ ? i18n.str`Repeated password doesn't match` : undefined, }); - const { actionHandler, showError } = useNotificationContext(); + const passwordChange = !password + ? undefined + : { + old_password: current, + new_password: password, + }; + const { showError } = useNotificationContext(); // i18n.str`update password`, - const update = actionHandler( + const update = useNotifiedOperation< + Awaited<ReturnType<typeof api.updatePassword>>, + [string, AccessToken, TalerCorebankApi.AccountPasswordChange, string[]] + >( ( ct, username: string, @@ -106,54 +116,48 @@ export function UpdateAccountPassword({ api.updatePassword({ username, token }, request, { challengeIds, }), - !password || !token || !!errors - ? undefined - : ([ - accountName, - token, - { - old_password: current, - new_password: password, - }, - [], - ] as const), - ); - - update.onSuccess = (success) => { - // notifyInfo(i18n.str`Password changed`); - onUpdateSuccess(); - }; - update.onFail = showError( - i18n.str`Failed to update the password.`, - (fail, username) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Not authorized to change the password, maybe the session is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: - return i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`; - case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: - return i18n.str`Your current password doesn't match, can't change to a new password.`; - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Password update`, - username, - fail.body, - update.lambda((prev, next) => - !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], - ), - ); - return undefined; - case HttpStatusCode.Forbidden: - return i18n.str`You don't have the rights to change the password.`; - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: - return i18n.str`The password is too short. Can't have less than 8 characters.`; - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: - return i18n.str`The password is too long. Can't have more than 64 characters.`; - default: - assertUnreachable(fail); - } + { + onSuccess: () => { + mfa.cancel(); + // notifyInfo(i18n.str`Password changed`); + onUpdateSuccess(); + }, + onFail: showError( + i18n.str`Failed to update the password.`, + (fail, username, token, request) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Not authorized to change the password, maybe the session is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: + return i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`; + case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: + return i18n.str`Your current password doesn't match, can't change to a new password.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Password update`, + username, + fail.body, + { + running: update.running, + cancel: update.cancel, + run: (challengeIds) => + update.run(username, token, request, challengeIds), + }, + ); + return undefined; + case HttpStatusCode.Forbidden: + return i18n.str`You don't have the rights to change the password.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + default: + assertUnreachable(fail); + } + }, + ), }, ); return ( @@ -294,14 +298,17 @@ export function UpdateAccountPassword({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="change" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={update} + disabled={!passwordChange || !token || !!errors} + onClick={() => + update.run(accountName, token!, passwordChange!, []) + } > <i18n.Translate>Change</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx @@ -21,10 +21,11 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, RouteDefinition, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -53,23 +54,18 @@ export function CreateNewAccount({ TalerCorebankApi.RegisterAccountRequest | undefined >(); - const { actionHandler, showError, showSuccess } = useNotificationContext(); + const { showError, showSuccess } = useNotificationContext(); // i18n.str`create account`, - const create = actionHandler( - (ct, t, ac) => api.createAccount(t, ac), - !submitAccount || !token - ? undefined - : ([{ type: "bearer", token }, submitAccount] as const), - ); - create.onSuccess = showSuccess((success, token, account) => { - onCreateSuccess(); - return i18n.str`Account created with password "${account.password}".`; - }); - - create.onFail = showError( - i18n.str`Failed to create a new account.`, - (fail) => { + const create = useNotifiedOperation< + Awaited<ReturnType<typeof api.createAccount>>, + Parameters<typeof api.createAccount> + >((ct, t, ac) => api.createAccount(t, ac), { + onSuccess: showSuccess((success, token, account) => { + onCreateSuccess(); + return i18n.str`Account created with password "${account.password}".`; + }), + onFail: showError(i18n.str`Failed to create a new account.`, (fail) => { switch (fail.case) { case HttpStatusCode.BadRequest: return i18n.str`Server replied that phone or email is invalid`; @@ -102,8 +98,8 @@ export function CreateNewAccount({ default: assertUnreachable(fail); } - }, - ); + }), + }); if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) { return ( @@ -148,14 +144,17 @@ export function CreateNewAccount({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="create" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={create} + disabled={!submitAccount || !token} + onClick={() => + create.run({ type: "bearer", token: token! }, submitAccount!) + } > <i18n.Translate>Create</i18n.Translate> - </Button> + </AsyncButton> </div> </AccountForm> </div> diff --git a/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx b/packages/libeufin-bank-webui/src/pages/admin/DownloadStats.tsx @@ -25,10 +25,10 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, - Button, + AsyncButton, RouteDefinition, useBankCoreApiContext, - useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -79,10 +79,11 @@ export function DownloadStats({ routeCancel }: Props): VNode { const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); const [downloaded, setDownloaded] = useState<string>(); const referenceDates = [new Date()]; - const { actionHandler, showError } = useNotificationContext(); - // i18n.str`download statistics`, - const download = actionHandler( + const download = useNotifiedOperation< + Awaited<ReturnType<typeof fetchAllStatus>>, + [Parameters<typeof fetchAllStatus>[1]] + >( async (ct, token) => { setDownloaded(undefined); return fetchAllStatus( @@ -95,12 +96,13 @@ export function DownloadStats({ routeCancel }: Props): VNode { }, ); }, - lastStep !== undefined || !creds ? undefined : ([creds.token] as const), + { + onSuccess: (success) => { + setDownloaded(success); + setLastStep(undefined); + }, + }, ); - download.onSuccess = (success) => { - setDownloaded(success); - setLastStep(undefined); - }; if (!creds) { return <i18n.Translate>only admin can download stats</i18n.Translate>; @@ -373,14 +375,15 @@ export function DownloadStats({ routeCancel }: Props): VNode { > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="download" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={download} + disabled={lastStep !== undefined || !creds} + onClick={() => download.run(creds!.token)} > <i18n.Translate>Download</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx @@ -22,13 +22,14 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, ErrorLoading, Loading, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -66,9 +67,62 @@ export function RemoveAccount({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const mfa = useBankChallengeHandlerContext(); + const errors = undefinedIfEmpty({ + accountName: !accountName + ? i18n.str`Required` + : account !== accountName + ? i18n.str`Name doesn't match` + : undefined, + }); + + // i18n.str`delete account`, + const deleteAccount = useNotifiedOperation< + Awaited<ReturnType<typeof api.deleteAccount>>, + [UserAndToken, challengeIds?: string[]] + >( + (ct, auth: UserAndToken, challengeIds?: string[]) => + api.deleteAccount(auth, { challengeIds }), + { + onSuccess: () => { + mfa.cancel(); + // notifyInfo(i18n.str`Account removed`); + onUpdateSuccess(); + }, + onFail: showError( + i18n.str`Faild to delete the account.`, + (fail, creds) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Not enough permission to delete the account.`; + case HttpStatusCode.NotFound: + return i18n.str`The username was not found.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`Can't delete a reserved username.`; + case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: + return i18n.str`Can't delete an account with balance different than zero.`; + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Account deletion`, + creds.username, + fail.body, + { + running: deleteAccount.running, + cancel: deleteAccount.cancel, + run: (challengeIds) => deleteAccount.run(creds, challengeIds), + }, + ); + return undefined; + default: + assertUnreachable(fail); + } + }, + ), + }, + ); + if (!result) { return <Loading />; } @@ -123,59 +177,6 @@ export function RemoveAccount({ ); } - const errors = undefinedIfEmpty({ - accountName: !accountName - ? i18n.str`Required` - : account !== accountName - ? i18n.str`Name doesn't match` - : undefined, - }); - - // i18n.str`delete account`, - const deleteAccount = actionHandler( - (ct, auth: UserAndToken, challengeIds?: string[]) => - api.deleteAccount(auth, { challengeIds }), - !!errors || !token - ? undefined - : ([ - { username: account, token }, - undefined as string[] | undefined, - ] as const), - ); - - deleteAccount.onSuccess = (success) => { - // notifyInfo(i18n.str`Account removed`); - onUpdateSuccess(); - }; - - deleteAccount.onFail = showError( - i18n.str`Faild to delete the account.`, - (fail, creds) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Not enough permission to delete the account.`; - case HttpStatusCode.NotFound: - return i18n.str`The username was not found.`; - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return i18n.str`Can't delete a reserved username.`; - case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: - return i18n.str`Can't delete an account with balance different than zero.`; - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Account deletion`, - creds.username, - fail.body, - deleteAccount.lambda((prev, next) => - !prev ? undefined : [prev[0], next[0]], - ), - ); - return undefined; - default: - assertUnreachable(fail); - } - }, - ); - return ( <div> <Attention @@ -246,14 +247,17 @@ export function RemoveAccount({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="delete" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" - onClick={deleteAccount} + disabled={!!errors || !token} + onClick={() => + deleteAccount.run({ username: account, token: token! }) + } > <i18n.Translate>Delete</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx b/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx @@ -17,6 +17,7 @@ import { AmountJson, Amounts, + CancellationToken, HttpStatusCode, TalerBankConversionApi, TalerError, @@ -24,7 +25,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, ErrorLoading, InternationalizationAPI, Loading, @@ -33,6 +34,7 @@ import { ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, utils, } from "@gnu-taler/web-util/browser"; @@ -129,12 +131,12 @@ function useComponentState({ } const info = resp.body; - return function afterComponentLoads() { + return function AfterComponentLoads() { const { lib: { conversion }, } = useBankCoreApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const initalState: FormValues<FormType> = { amount: "100", @@ -177,32 +179,33 @@ function useComponentState({ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); + async function calculateOperation( + _ct: CancellationToken, + amount: AmountJson, + ) { + const respCashin = await calculateCashinFromDebit(amount, in_fee); + if (respCashin.type === "fail") { + return respCashin; + } + const cashin = respCashin.body; + const respCashout = await calculateCashoutFromDebit( + cashin.credit, + out_fee, + ); + if (respCashout.type === "fail") { + return respCashout; + } + const cashout = respCashout.body; + return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); + } + // i18n.str`calculate cashout fee`, - const calculate = actionHandler( - async (ct, amount: AmountJson) => { - const respCashin = await calculateCashinFromDebit(amount, in_fee); - if (respCashin.type === "fail") { - return respCashin; - } - const cashin = respCashin.body; - const respCashout = await calculateCashoutFromDebit( - cashin.credit, - out_fee, - ); - if (respCashout.type === "fail") { - return respCashout; - } - const cashout = respCashout.body; - return opFixedSuccess(dummyHttpResponse, { cashin, cashout }); - }, - !in_amount || status.status === "fail" - ? undefined - : ([in_amount] as const), - ); - calculate.onSuccess = (resp) => setCalc(resp); - calculate.onFail = showError( - i18n.str`Failed to calculate cashout fee.`, - (fail) => { + const calculate = useNotifiedOperation< + Awaited<ReturnType<typeof calculateOperation>>, + [AmountJson] + >(calculateOperation, { + onSuccess: (resp) => setCalc(resp), + onFail: showError(i18n.str`Failed to calculate cashout fee.`, (fail) => { switch (fail.case) { case HttpStatusCode.Conflict: return i18n.str`The amount is too small`; @@ -217,11 +220,13 @@ function useComponentState({ default: assertUnreachable(fail); } - }, - ); + }), + }); useEffect(() => { - calculate.call(); + if (in_amount && status.status !== "fail") { + calculate.run(in_amount); + } }, [ form.amount?.value, form.conv?.cashin_fee?.value, @@ -235,32 +240,27 @@ function useComponentState({ const cashoutCalc = calculationResult?.cashout; // i18n.str`update conversion rate`, - const update = actionHandler( - (ct, s, c) => conversion.updateConversionRate(s, c), - !creds || status.status === "fail" - ? undefined - : ([ - { type: "bearer", token: creds.token }, - status.result.conv, - ] as const), - ); - - update.onSuccess = () => { - setSection("detail"); - }; - update.onFail = showError( - i18n.str`Failed to update the conversion rate.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is disabled`; - default: - assertUnreachable(fail); - } + const update = useNotifiedOperation< + Awaited<ReturnType<typeof conversion.updateConversionRate>>, + Parameters<typeof conversion.updateConversionRate> + >((ct, s, c) => conversion.updateConversionRate(s, c), { + onSuccess: () => { + setSection("detail"); }, - ); + onFail: showError( + i18n.str`Failed to update the conversion rate.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is disabled`; + default: + assertUnreachable(fail); + } + }, + ), + }); const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio); const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio); @@ -599,14 +599,22 @@ function useComponentState({ <i18n.Translate>Cancel</i18n.Translate> </a> {section == "cashin" || section == "cashout" ? ( - <Button + <AsyncButton submit name="update conversion" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={update} + disabled={!creds || status.status === "fail"} + onClick={() => + update.run( + { type: "bearer", token: creds.token }, + status.status === "fail" + ? undefined! + : status.result.conv, + ) + } > <i18n.Translate>Update</i18n.Translate> - </Button> + </AsyncButton> ) : ( <div /> )} diff --git a/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx b/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx @@ -16,6 +16,7 @@ import { AmountJson, Amounts, + CancellationToken, ConversionRate, HttpStatusCode, TalerConversionInfoConfig, @@ -27,7 +28,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, ErrorLoading, Loading, RenderAmount, @@ -35,6 +36,7 @@ import { ShowInputErrorLabel, useBankCoreApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -235,7 +237,7 @@ function CreateCashoutInternal({ } = useCashoutEstimatorByUser(accountName); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); const [requestUid] = useState(() => encodeCrock(getRandomBytes(32))); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const mfa = useBankChallengeHandlerContext(); const { i18n } = useTranslationContext(); @@ -281,42 +283,50 @@ function CreateCashoutInternal({ : true; const notZero = Amounts.isNonZero(inputAmount); + async function calculateOperation( + _ct: CancellationToken, + isDebit: boolean, + input: AmountJson, + fee: AmountJson, + ) { + if (notZero && higerThanMin) { + return isDebit + ? calculateFromDebit(input, fee) + : calculateFromCredit(input, fee); + } else { + return opFixedSuccess(dummyHttpResponse, zeroCalc); + } + } + // i18n.str`calculate conversion fee`, - const conversionCalculator = actionHandler( - async (ct, isDebit: boolean, input: AmountJson, fee: AmountJson) => { - if (notZero && higerThanMin) { - return isDebit - ? calculateFromDebit(input, fee) - : calculateFromCredit(input, fee); - } else { - return opFixedSuccess(dummyHttpResponse, zeroCalc); - } - }, - [form.isDebit ?? false, inputAmount, sellFee], - ); - conversionCalculator.onSuccess = (success) => setCalculation(success); - conversionCalculator.onFail = showError( - i18n.str`Failed to calculate the conversion fee.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not implemented.`; - case TalerErrorCode.GENERIC_PARAMETER_MISSING: - return i18n.str`At least debit or credit needs to be provided`; - case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: - return i18n.str`The amount is malformed`; - case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: - return i18n.str`The currency is not supported`; - default: - assertUnreachable(fail); - } - }, - ); + const conversionCalculator = useNotifiedOperation< + Awaited<ReturnType<typeof calculateOperation>>, + [boolean, AmountJson, AmountJson] + >(calculateOperation, { + onSuccess: (success) => setCalculation(success), + onFail: showError( + i18n.str`Failed to calculate the conversion fee.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not implemented.`; + case TalerErrorCode.GENERIC_PARAMETER_MISSING: + return i18n.str`At least debit or credit needs to be provided`; + case TalerErrorCode.GENERIC_PARAMETER_MALFORMED: + return i18n.str`The amount is malformed`; + case TalerErrorCode.GENERIC_CURRENCY_MISMATCH: + return i18n.str`The currency is not supported`; + default: + assertUnreachable(fail); + } + }, + ), + }); useEffect(() => { - conversionCalculator.call(); + conversionCalculator.run(form.isDebit ?? false, inputAmount, sellFee); }, [form.amount, form.isDebit, notZero, higerThanMin, rate.cashout_fee]); const calc = !calculationResult ? zeroCalc : calculationResult; @@ -359,7 +369,10 @@ function CreateCashoutInternal({ const subject = form.subject; // i18n.str`create cashout`, - const cashout = actionHandler( + const cashout = useNotifiedOperation< + Awaited<ReturnType<typeof api.createCashout>>, + [LoggedIn, TransCalc, string, challengeIds?: string[]] + >( ( ct, session: LoggedIn, @@ -377,50 +390,53 @@ function CreateCashoutInternal({ }, { challengeIds }, ), - !!errors || !subject - ? undefined - : ([session, calc, subject, undefined as string[] | undefined] as const), - ); - cashout.onSuccess = (success) => { - // notifyInfo(i18n.str`Cashout created`); - onCashout(); - }; - cashout.onFail = showError( - i18n.str`Failed to create the cashout.`, - (fail, session) => { - switch (fail.case) { - case HttpStatusCode.Accepted: - mfa.onNewChallenge( - i18n.str`Cashout`, - session.username, - fail.body, - cashout.lambda((prev, next) => - !prev ? undefined : [prev[0], prev[1], prev[2], next[0]], - ), - ); - return undefined; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return i18n.str`Duplicate request detected, check if the operation succeeded or try again.`; - case TalerErrorCode.BANK_BAD_CONVERSION: - return i18n.str`The conversion rate was applied incorrectly`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`The account does not have sufficient funds`; - case HttpStatusCode.NotImplemented: - return i18n.str`Cashout is disabled`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`Missing cashout URI in the profile`; - case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: - return i18n.str`The amount is below the minimum amount permitted.`; - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`; - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { - return i18n.str`The server doesn't support the current TAN channel.`; - } - default: - assertUnreachable(fail); - } + { + onSuccess: () => { + mfa.cancel(); + // notifyInfo(i18n.str`Cashout created`); + onCashout(); + }, + onFail: showError( + i18n.str`Failed to create the cashout.`, + (fail, session, calc, subject) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onNewChallenge( + i18n.str`Cashout`, + session.username, + fail.body, + { + running: cashout.running, + cancel: cashout.cancel, + run: (challengeIds) => + cashout.run(session, calc, subject, challengeIds), + }, + ); + return undefined; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return i18n.str`Duplicate request detected, check if the operation succeeded or try again.`; + case TalerErrorCode.BANK_BAD_CONVERSION: + return i18n.str`The conversion rate was applied incorrectly`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`The account does not have sufficient funds`; + case HttpStatusCode.NotImplemented: + return i18n.str`Cashout is disabled`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`Missing cashout URI in the profile`; + case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: + return i18n.str`The amount is below the minimum amount permitted.`; + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { + return i18n.str`The server doesn't support the current TAN channel.`; + } + default: + assertUnreachable(fail); + } + }, + ), }, ); @@ -758,14 +774,15 @@ function CreateCashoutInternal({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <Button + <AsyncButton submit name="cashout" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={cashout} + disabled={!!errors || !subject} + onClick={() => cashout.run(session, calc, subject!)} > <i18n.Translate>Cashout</i18n.Translate> - </Button> + </AsyncButton> </div> </form> </div> diff --git a/packages/taler-exchange-aml-webui/src/components/CreateSession.tsx b/packages/taler-exchange-aml-webui/src/components/CreateSession.tsx @@ -14,18 +14,18 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - Button, + AsyncButton, FormDesign, FormUI, InternationalizationAPI, useForm, - useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { OfficerNotFound } from "../hooks/officer.js"; import { usePreferences } from "../hooks/preferences.js"; -import { asPassword } from "@gnu-taler/taler-util"; +import { asPassword, Password } from "@gnu-taler/taler-util"; type FormType = { password: string; @@ -84,7 +84,6 @@ export function CreateSession({ }): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const { actionHandler, showError } = useNotificationContext(); const design = createAccountForm(i18n, settings.allowInsecurePassword); @@ -93,10 +92,10 @@ export function CreateSession({ repeat: undefined, }); - const create = actionHandler( - (ct, pw) => officer.create(pw), - status.status === "fail" ? undefined : [asPassword(status.result.password)], - ); + const create = useNotifiedOperation< + Awaited<ReturnType<OfficerNotFound["create"]>>, + [Password] + >((_ct, password: Password) => officer.create(password)); return ( <div class="flex min-h-full flex-col "> @@ -109,13 +108,17 @@ export function CreateSession({ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> <FormUI design={design} model={handler} focus /> <div class="mt-8"> - <Button + <AsyncButton submit class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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={create} + onClick={ + status.status === "fail" + ? undefined + : () => create.run(asPassword(status.result.password)) + } > <i18n.Translate>Create</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </div> diff --git a/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx b/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx @@ -17,14 +17,16 @@ import { asPassword, assertUnreachable, HttpStatusCode, + Password, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, FormDesign, InputLine, InternationalizationAPI, useForm, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; @@ -51,7 +53,7 @@ const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { const { i18n } = useTranslationContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const design = unlockAccountForm(i18n); @@ -59,19 +61,23 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { password: undefined, }); - const unlock = actionHandler( - (ct, pw) => officer.tryUnlock(pw), - status.status === "fail" ? undefined : [asPassword(status.result.password)], - ); - unlock.onFail = showError(i18n.str`Failed to unlock the session.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.Forbidden: - return i18n.str`Authorization denied for this session. Contact the administrator.`; - default: - assertUnreachable(fail.case); - } + const unlock = useNotifiedOperation< + Awaited<ReturnType<OfficerLocked["tryUnlock"]>>, + [Password] + >((_ct, password: Password) => officer.tryUnlock(password), { + onFail: showError(i18n.str`Failed to unlock the session.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.Forbidden: + return i18n.str`Authorization denied for this session. Contact the administrator.`; + default: + assertUnreachable(fail.case); + } + }), }); - const forget = actionHandler(async () => officer.forget(), []); + const forget = useNotifiedOperation< + Awaited<ReturnType<OfficerLocked["forget"]>>, + [] + >(async () => officer.forget()); return ( <div class="flex min-h-full flex-col "> @@ -100,21 +106,25 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { </div> <div class="mt-8"> - <Button + <AsyncButton submit - onClick={unlock} + onClick={ + status.status === "fail" + ? undefined + : () => unlock.run(asPassword(status.result.password)) + } class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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" > <i18n.Translate>Unlock</i18n.Translate> - </Button> + </AsyncButton> </div> </form> - <Button - onClick={forget} + <AsyncButton + onClick={() => forget.run()} class="disabled:opacity-50 disabled:cursor-default m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " > <i18n.Translate>Forget session</i18n.Translate> - </Button> + </AsyncButton> </div> </div> ); diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx @@ -18,19 +18,21 @@ import { AccountProperties, assertUnreachable, HttpStatusCode, + OfficerSession, TalerError, TalerExchangeApi, TalerFormAttributes, } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, CopyButton, ErrorLoading, Loading, RouteDefinition, useExchangeApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; @@ -94,7 +96,43 @@ export function AccountDetails({ const session = officer.state === "ready" ? officer.session : undefined; const { lib } = useExchangeApiContext(); const [exported, setExported] = useState<{ content: string; file: string }>(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); + + const downloadPdf = useNotifiedOperation< + Awaited<ReturnType<typeof lib.exchange.getAmlAttributesForAccountAsPdf>>, + [OfficerSession, string] + >( + (_ct, officerSession: OfficerSession, accountId: string) => + lib.exchange.getAmlAttributesForAccountAsPdf(officerSession, accountId), + { + onSuccess: (result) => { + const time = format(new Date(), "yyyyMMdd_HHmmss"); + setExported({ + content: new Uint8Array(result).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + file: `account_${time}_${account}.pdf`, + }); + }, + onFail: showError(i18n.str`Failed to download report.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.NoContent: + return i18n.str`The account has no KYC info.`; + case HttpStatusCode.Forbidden: + return i18n.str`Authorization denied for this session. Contact the administrator.`; + case HttpStatusCode.NotFound: + return i18n.str`Session not found. Contact the administrator.`; + case HttpStatusCode.Conflict: + return i18n.str`The session is disabled. Contact the administrator.`; + case HttpStatusCode.NotImplemented: + return i18n.str`The server doesn't support PDF download. Contact the administrator.`; + default: + assertUnreachable(fail.case); + } + }), + }, + ); const measures = useServerMeasures(); @@ -217,42 +255,6 @@ export function AccountDetails({ : BANK_RULES.includes(r.operation_type); }); - const time = format(new Date(), "yyyyMMdd_HHmmss"); - - const downloadPdf = actionHandler( - (ct, s, ac) => lib.exchange.getAmlAttributesForAccountAsPdf(s, ac), - session ? ([session, account] as const) : undefined, - ); - - downloadPdf.onSuccess = (result) => { - setExported({ - content: new Uint8Array(result).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - file: `account_${time}_${account}.pdf`, - }); - }; - downloadPdf.onFail = showError( - i18n.str`Failed to download report.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.NoContent: - return i18n.str`The account has no KYC info.`; - case HttpStatusCode.Forbidden: - return i18n.str`Authorization denied for this session. Contact the administrator.`; - case HttpStatusCode.NotFound: - return i18n.str`Session not found. Contact the administrator.`; - case HttpStatusCode.Conflict: - return i18n.str`The session is disabled. Contact the administrator.`; - case HttpStatusCode.NotImplemented: - return i18n.str`The server doesn't support PDF download. Contact the administrator.`; - default: - assertUnreachable(fail.case); - } - }, - ); - return ( <div class="min-w-60"> <header class="flex flex-col justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8 gap-2"> @@ -280,9 +282,13 @@ export function AccountDetails({ <div class="flex space-x-2 mb-4"> <i18n.Translate>Export as PDF</i18n.Translate> - <Button onClick={downloadPdf}> + <AsyncButton + onClick={ + !session ? undefined : () => downloadPdf.run(session, account) + } + > <img class="size-6 w-6" src={pdfIcon} /> - </Button> + </AsyncButton> </div> {!exported ? ( <div /> diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx @@ -24,7 +24,7 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, ErrorLoading, FailLoading, InputToggle, @@ -33,6 +33,7 @@ import { RouteDefinition, useExchangeApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -46,6 +47,7 @@ import { useOfficer } from "../hooks/officer.js"; import { Profile } from "./Profile.js"; const utfDecoder = new TextDecoder("utf-8"); +type Mime = "text/csv" | "application/vnd.ms-excel"; export function AccountList({ routeToAccountById: caseByIdRoute, @@ -57,13 +59,78 @@ export function AccountList({ const [opened, setOpened] = useState<boolean>(); const [highRisk, setHighRisk] = useState<boolean>(); const list = useAmlAccounts({ investigated, open: opened, highRisk }); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const officer = useOfficer(); const session = officer.state === "ready" ? officer.session : undefined; const { lib } = useExchangeApiContext(); const [exported, setExported] = useState<{ content: string; file: string }>(); + const fileDescription = + investigated === undefined + ? opened + ? highRisk + ? `risky_opened` + : `opened` + : highRisk + ? `risky` + : `` + : investigated + ? opened + ? highRisk + ? `investigated_risky_opened` + : `investigated_opened` + : highRisk + ? `investigated_risky` + : `investigated` + : opened + ? highRisk + ? `not-investigated_risky_opened` + : `not-investigated_opened` + : highRisk + ? `not-investigated_risky` + : `not-investigated`; + + const download = useNotifiedOperation< + Awaited<ReturnType<typeof lib.exchange.getAmlAccountsAsOtherFormat>>, + [OfficerSession, Mime] + >( + (_ct, officerSession: OfficerSession, mime: Mime) => + lib.exchange.getAmlAccountsAsOtherFormat(officerSession, mime), + { + onFail: showError(i18n.str`Failed to download`, (fail) => { + switch (fail.case) { + case HttpStatusCode.NoContent: + return i18n.str`There are no accounts in the resultset.`; + case HttpStatusCode.NotAcceptable: + return i18n.str`The format requested is not acceptable from the service provider.`; + case HttpStatusCode.Forbidden: + return i18n.str`Authorization denied for this session.`; + case HttpStatusCode.NotFound: + return i18n.str`Session not found. Contact the administrator.`; + case HttpStatusCode.Conflict: + return i18n.str`The session is disabled. Contact the administrator`; + } + }), + onSuccess: (result, _officerSession, mime) => { + const time = format(new Date(), "yyyyMMdd_HHmmss"); + switch (mime) { + case "text/csv": + return setExported({ + content: utfDecoder.decode(result), + file: `accounts_${time}_${fileDescription}.csv`, + }); + case "application/vnd.ms-excel": + return setExported({ + content: utfDecoder.decode(result), + file: `accounts_${time}_${fileDescription}.xls`, + }); + default: + assertUnreachable(mime); + } + }, + }, + ); if (!list) { return <Loading />; @@ -140,98 +207,6 @@ export function AccountList({ ? i18n.str`High risk accounts without investigation.` : i18n.str`Accounts without investigation.`; - const fileDescription = - investigated === undefined - ? opened - ? highRisk - ? `risky_opened` - : `opened` - : highRisk - ? `risky` - : `` - : investigated - ? opened - ? highRisk - ? `investigated_risky_opened` - : `investigated_opened` - : highRisk - ? `investigated_risky` - : `investigated` - : opened - ? highRisk - ? `not-investigated_risky_opened` - : `not-investigated_opened` - : highRisk - ? `not-investigated_risky` - : `not-investigated`; - - const time = format(new Date(), "yyyyMMdd_HHmmss"); - - type Mime = "text/csv" | "application/vnd.ms-excel"; - const download = actionHandler((ct, s: OfficerSession, f: Mime) => - lib.exchange.getAmlAccountsAsOtherFormat(s, f), - ); - download.onFail = showError(i18n.str`Failed to download`, (fail) => { - switch (fail.case) { - case HttpStatusCode.NoContent: - return i18n.str`There are no accounts in the resultset.`; - case HttpStatusCode.NotAcceptable: - return i18n.str`The format requested is not acceptable from the service provider.`; - case HttpStatusCode.Forbidden: - return i18n.str`Authorization denied for this session.`; - case HttpStatusCode.NotFound: - return i18n.str`Session not found. Contact the administrator.`; - case HttpStatusCode.Conflict: - return i18n.str`The session is disabled. Contact the administrator`; - } - }); - download.onSuccess = (result, s, f) => { - switch (f) { - case "text/csv": - return setExported({ - content: utfDecoder.decode(result), - file: `accounts_${time}_${fileDescription}.csv`, - }); - case "application/vnd.ms-excel": - return setExported({ - content: utfDecoder.decode(result), - file: `accounts_${time}_${fileDescription}.xls`, - }); - default: - assertUnreachable(f); - } - }; - - // const downloadXls = actionHandler( - // (ct, s, f) => lib.exchange.getAmlAccountsAsOtherFormat(s, f), - // session ? ([session, "application/vnd.ms-excel"] as const) : undefined, - // ); - - // downloadXls.onFail = showError(i18n.str`Failed to download XLS.`, (fail) => { - // switch (fail.case) { - // case HttpStatusCode.NoContent: - // return i18n.str`There are no accounts in the resultset.`; - // case HttpStatusCode.Forbidden: - // return i18n.str`Invalid session.`; - // case HttpStatusCode.NotFound: - // return i18n.str`Session not found. Contact the administrator.`; - // case HttpStatusCode.Conflict: - // return i18n.str`The session is disabled. Contact the administrator`; - // } - // }); - - // downloadCsv.onSuccess = (result) => { - // setExported({ - // content: utfDecoder.decode(result), - // file: `accounts_${time}_${fileDescription}.csv`, - // }); - // }; - // downloadXls.onSuccess = (result) => { - // setExported({ - // content: utfDecoder.decode(result), - // file: `accounts_${time}_${fileDescription}.xls`, - // }); - // }; return ( <div> <div class="sm:flex sm:items-center"> @@ -245,22 +220,24 @@ export function AccountList({ {!records.length ? undefined : ( <div class="flex space-x-2 mt-4"> <i18n.Translate>Export as file</i18n.Translate> - <Button + <AsyncButton + disabled={download.running} onClick={ - !session ? undefined : download.withArgs(session, "text/csv") + !session ? undefined : () => download.run(session, "text/csv") } > <img class="size-6 w-6" src={csvIcon} /> - </Button> - <Button + </AsyncButton> + <AsyncButton + disabled={download.running} onClick={ !session ? undefined - : download.withArgs(session, "application/vnd.ms-excel") + : () => download.run(session, "application/vnd.ms-excel") } > <img class="size-6 w-6" src={xlsIcon} /> - </Button> + </AsyncButton> </div> )} diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx @@ -18,7 +18,8 @@ import { AmlDecisionRequest, assertUnreachable, HttpStatusCode, - opEmptySuccess, + OperationFail, + OperationOk, opFixedSuccess, Paytos, TalerError, @@ -27,9 +28,10 @@ import { import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { Attention, - Button, + AsyncButton, useExchangeApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -75,7 +77,7 @@ export function Summary({ const [decision, , cleanUpDecision] = useCurrentDecisionRequest(); const measures = useServerMeasures(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const session = officer.session; const allMeasures = computeMeasureInformation( @@ -168,8 +170,16 @@ export function Summary({ const [submitConfirmation, setSubmitConfirmation] = useState<boolean>(false); const requiresConfirmation = MROS_REPORT_COMPLETED; - const submit = actionHandler( - async (ct, req) => { + const submit = useNotifiedOperation< + | OperationOk<boolean> + | OperationFail< + | HttpStatusCode.Forbidden + | HttpStatusCode.NotFound + | HttpStatusCode.Conflict + >, + [Omit<AmlDecisionRequest, "officer_sig">] + >( + async (_ct, req: Omit<AmlDecisionRequest, "officer_sig">) => { if (requiresConfirmation && !submitConfirmation) { setSubmitConfirmation(true); // FIXME: This is not the right type to use here. @@ -179,26 +189,26 @@ export function Summary({ if (r.type === "fail") return r; return opFixedSuccess(dummyHttpResponse, true); }, - !request ? undefined : [request], + { + onSuccess: (completed) => { + if (completed) { + clearUp(); + } + }, + onFail: showError(i18n.str`Failed to make the decision.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.Forbidden: + return i18n.str`Invalid credentials.`; + case HttpStatusCode.NotFound: + return i18n.str`Session not found. Contact the administrator.`; + case HttpStatusCode.Conflict: + return i18n.str`The session is disabled or a more recent decision was already submitted.`; + default: + assertUnreachable(fail.case); + } + }), + }, ); - submit.onSuccess = (completed) => { - if (completed) { - clearUp(); - } - }; - - submit.onFail = showError(i18n.str`Failed to make the decision.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.Forbidden: - return i18n.str`Invalid credentials.`; - case HttpStatusCode.NotFound: - return i18n.str`Session not found. Contact the administrator.`; - case HttpStatusCode.Conflict: - return i18n.str`The session is disabled or a more recent decision was already submitted.`; - default: - assertUnreachable(fail.case); - } - }); if (submitConfirmation) { return ( @@ -221,13 +231,13 @@ export function Summary({ > <i18n.Translate>I want to check first!</i18n.Translate> </button> - <Button + <AsyncButton submit - onClick={submit} + onClick={!request ? undefined : () => submit.run(request)} class="mt-4 disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Confirm decision</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </div> @@ -360,13 +370,13 @@ export function Summary({ > <i18n.Translate>Clear</i18n.Translate> </button> - <Button + <AsyncButton submit - onClick={submit} + onClick={!request ? undefined : () => submit.run(request)} class="mt-4 disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Send decision</i18n.Translate> - </Button> + </AsyncButton> </div> </Fragment> ); diff --git a/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx b/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx @@ -24,7 +24,7 @@ import { import { AcceptTermOfServiceContext, Attention, - Button, + AsyncButton, ErrorsSummary, FormMetadata, FormUI, @@ -34,6 +34,7 @@ import { useAsyncAsHook, useExchangeApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, useFormMeta, } from "@gnu-taler/web-util/browser"; @@ -114,7 +115,7 @@ function ShowForm({ onComplete: () => void; }): VNode { const { lib } = useExchangeApiContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const [preferences] = usePreferences(); const { i18n } = useTranslationContext(); @@ -130,28 +131,32 @@ function ShowForm({ validatedForm[TalerFormAttributes.FORM_CONTEXT] = formContext; } - const submit = actionHandler( - (ct, id, f) => lib.exchange.uploadKycForm(id, f), - !validatedForm ? undefined : ([reqId, validatedForm] as const), - ); - submit.onSuccess = onComplete; - submit.onFail = showError( - i18n.str`Failed to upload the KYC information.`, - (fail) => { - switch (fail.case) { - case HttpStatusCode.PayloadTooLarge: - return i18n.str`The submission is too large. Please upload smaller files and try again.`; - case HttpStatusCode.InternalServerError: - return i18n.str`There was a problem processing your request. Please try again later.`; - case HttpStatusCode.NotFound: - return i18n.str`The account was not found`; - case HttpStatusCode.Conflict: - return i18n.str`Officer disabled or more recent decision was already submitted.`; - default: - assertUnreachable(fail); - } - }, - ); + const submitArgs = !validatedForm + ? undefined + : ([reqId, validatedForm] as const); + const submit = useNotifiedOperation< + Awaited<ReturnType<typeof lib.exchange.uploadKycForm>>, + [KycRequirementInformationId, FormType] + >((ct, id, f) => lib.exchange.uploadKycForm(id, f), { + onSuccess: onComplete, + onFail: showError( + i18n.str`Failed to upload the KYC information.`, + (fail) => { + switch (fail.case) { + case HttpStatusCode.PayloadTooLarge: + return i18n.str`The submission is too large. Please upload smaller files and try again.`; + case HttpStatusCode.InternalServerError: + return i18n.str`There was a problem processing your request. Please try again later.`; + case HttpStatusCode.NotFound: + return i18n.str`The account was not found`; + case HttpStatusCode.Conflict: + return i18n.str`Officer disabled or more recent decision was already submitted.`; + default: + assertUnreachable(fail); + } + }, + ), + }); return ( <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> @@ -181,13 +186,13 @@ function ShowForm({ > <i18n.Translate>Cancel</i18n.Translate> </button> - <Button + <AsyncButton submit class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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={submit} + onClick={submitArgs ? () => submit.run(...submitArgs) : undefined} > <i18n.Translate>Submit</i18n.Translate> - </Button> + </AsyncButton> </div> {!status.errors ? undefined : <ErrorsSummary errors={status.errors} />} </div> diff --git a/packages/taler-exchange-kyc-webui/src/pages/Start.tsx b/packages/taler-exchange-kyc-webui/src/pages/Start.tsx @@ -17,16 +17,18 @@ import { AccessToken, HttpStatusCode, KycRequirementInformation, + KycRequirementInformationId, TalerError, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, - Button, + AsyncButton, ErrorLoading, Loading, useExchangeApiContext, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -180,39 +182,39 @@ function LinkGenerator({ req }: { req: KycRequirementInformation }): VNode { state: LinkGenerationState.WAIT, }); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { lib } = useExchangeApiContext(); - const start = actionHandler( + const start = useNotifiedOperation< + Awaited<ReturnType<typeof lib.exchange.startExternalKycProcess>>, + [KycRequirementInformationId] + >( async (ct, id: string) => { return lib.exchange.startExternalKycProcess(id); }, - [req.id!], - ); - start.onFail = showError( - i18n.str`Failed to start the KYC process.`, - (fail) => { - setLoading({ state: LinkGenerationState.ERROR }); - switch (fail.case) { - case HttpStatusCode.NotFound: - return i18n.str`not found`; - case HttpStatusCode.Conflict: - return i18n.str`conflict`; - case HttpStatusCode.PayloadTooLarge: - return i18n.str`payload is too large`; - default: - assertUnreachable(fail.case); - } + { + onSuccess(success) { + setLoading({ + state: LinkGenerationState.DONE, + url: success.redirect_url, + }); + }, + onFail: showError(i18n.str`Failed to start the KYC process.`, (fail) => { + setLoading({ state: LinkGenerationState.ERROR }); + switch (fail.case) { + case HttpStatusCode.NotFound: + return i18n.str`not found`; + case HttpStatusCode.Conflict: + return i18n.str`conflict`; + case HttpStatusCode.PayloadTooLarge: + return i18n.str`payload is too large`; + default: + assertUnreachable(fail.case); + } + }), }, ); - start.onSuccess = (success) => { - setLoading({ - state: LinkGenerationState.DONE, - url: success.redirect_url, - }); - return undefined; - }; useEffect(() => { - start.call(); + void start.run(req.id!); setLoading({ state: LinkGenerationState.RUNNING }); }, []); const redirectUrl = loading.url; @@ -261,12 +263,12 @@ function LinkGenerator({ req }: { req: KycRequirementInformation }): VNode { </p> ) : ( <p class="text-sm font-semibold leading-6 text-gray-900"> - <Button onClick={start}> + <AsyncButton onClick={() => start.run(req.id!)}> <span class="absolute inset-x-0 -top-px bottom-0"></span> <i18n.Translate context="KYC_REQUIREMENT_LINK_DESCRIPTION"> {req.description} </i18n.Translate> - </Button> + </AsyncButton> </p> )} </div> diff --git a/packages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx b/packages/taler-exchange-kyc-webui/src/pages/TriggerKyc.tsx @@ -18,6 +18,7 @@ import { AmountJson, Amounts, AmountString, + CancellationToken, assertUnreachable, createNewWalletKycAccount, eddsaGetPublic, @@ -30,13 +31,14 @@ import { WalletKycRequest, } from "@gnu-taler/taler-util"; import { - Button, + AsyncButton, FormMetadata, FormUI, UIHandlerId, useExchangeApiContext, useFormMeta, useNotificationContext, + useNotifiedOperation, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -53,7 +55,7 @@ type Props = { export function TriggerKyc({ onKycStarted }: Props): VNode { const { i18n } = useTranslationContext(); - const { actionHandler, showError } = useNotificationContext(); + const { showError } = useNotificationContext(); const { config, lib } = useExchangeApiContext(); const theForm: FormMetadata = { @@ -72,7 +74,6 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { currency: config.config.currency, label: i18n.str`Amount`, required: true, - converterId: "Taler.Amount", }, ], }, @@ -99,60 +100,63 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { }, [1]); // i18n.str`trigger kyc process`, - const send = actionHandler( - async (ct, balance: AmountString) => { - const account = await accountPromise; - const limit: WalletKycRequest = { - balance, - reserve_pub: account.id, - reserve_sig: encodeCrock( - signWalletAccountSetup(account.__signingKey, balance), - ), - }; - const resp = await lib.exchange.notifyKycBalanceLimit(limit); - if (resp.type === "ok") { - return opKnownFailure(dummyHttpResponse, HttpStatusCode.NoContent); - } - if (resp.case === HttpStatusCode.UnavailableForLegalReasons) { - const paytoHash = resp.body.h_payto; - const { __signingKey } = await accountPromise; - const merchantPub = eddsaGetPublic(__signingKey); - const accountOwnerSig = encodeCrock(signKycAuth(__signingKey)); - const statusRes = await lib.exchange.checkKycStatus({ - accountPub: encodeCrock(merchantPub), - accountSig: accountOwnerSig, - paytoHash, - }); - switch (statusRes.case) { - case HttpStatusCode.Accepted: - return opFixedSuccess(dummyHttpResponse, statusRes.body); - } - return statusRes; + const triggerKyc = async (_ct: CancellationToken, balance: AmountString) => { + const account = await accountPromise; + const limit: WalletKycRequest = { + balance, + reserve_pub: account.id, + reserve_sig: encodeCrock( + signWalletAccountSetup(account.__signingKey, balance), + ), + }; + const resp = await lib.exchange.notifyKycBalanceLimit(limit); + if (resp.type === "ok") { + return opKnownFailure(dummyHttpResponse, HttpStatusCode.NoContent); + } + if (resp.case === HttpStatusCode.UnavailableForLegalReasons) { + const paytoHash = resp.body.h_payto; + const { __signingKey } = await accountPromise; + const merchantPub = eddsaGetPublic(__signingKey); + const accountOwnerSig = encodeCrock(signKycAuth(__signingKey)); + const statusRes = await lib.exchange.checkKycStatus({ + accountPub: encodeCrock(merchantPub), + accountSig: accountOwnerSig, + paytoHash, + }); + switch (statusRes.case) { + case HttpStatusCode.Accepted: + return opFixedSuccess(dummyHttpResponse, statusRes.body); } - return resp; + return statusRes; + } + return resp; + }; + const send = useNotifiedOperation< + Awaited<ReturnType<typeof triggerKyc>>, + [AmountString] + >(triggerKyc, { + onSuccess(success) { + onKycStarted(success.access_token); }, + onFail: showError(i18n.str`Failed to trigger a KYC event.`, (fail) => { + switch (fail.case) { + case HttpStatusCode.NoContent: + return i18n.str`No kyc configured.`; + case HttpStatusCode.Forbidden: + return i18n.str`Forbidden.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + case HttpStatusCode.Conflict: + return i18n.str`Conflict.`; + default: + assertUnreachable(fail); + } + }), + }); + const sendArgs = theForm === undefined || status.status === "fail" ? undefined - : [Amounts.stringify(status.result.amount)], - ); - - send.onSuccess = (success) => { - onKycStarted(success.access_token); - }; - send.onFail = showError(i18n.str`Failed to trigger a KYC event.`, (fail) => { - switch (fail.case) { - case HttpStatusCode.NoContent: - return i18n.str`No kyc configured.`; - case HttpStatusCode.Forbidden: - return i18n.str`Forbidden.`; - case HttpStatusCode.NotFound: - return i18n.str`Not found.`; - case HttpStatusCode.Conflict: - return i18n.str`Conflict.`; - default: - assertUnreachable(fail); - } - }); + : ([Amounts.stringify(status.result.amount)] as const); return ( <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> @@ -168,12 +172,12 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { > <i18n.Translate>Cancel</i18n.Translate> </button> - <Button - onClick={send} + <AsyncButton + onClick={sendArgs ? () => send.run(...sendArgs) : undefined} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Submit</i18n.Translate> - </Button> + </AsyncButton> </div> <div class="grid grid-cols-1 gap-x-8 gap-y-4 "> @@ -185,130 +189,121 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { </i18n.Translate> </p> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000000`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000000`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger TOPS Terms of service</i18n.Translate> - </Button> - </div> - <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000010`)} - // disabled={!submitHandler} - class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" - > - <i18n.Translate>Trigger GLS onboarding</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000020`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000020`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.1</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000030`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000030`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.4</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000040`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000040`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.5</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000050`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000050`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.9</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000060`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000060`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.11</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000070`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000070`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.12</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000080`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000080`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.13</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000090`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000090`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.14</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000100`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000100`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.15</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000110`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000110`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Challenger test</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000120`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000120`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.9 customer</i18n.Translate> - </Button> + </AsyncButton> </div> <div> - <Button - onClick={send.withArgs(`${config.config.currency}:1000130`)} + <AsyncButton + onClick={() => send.run(`${config.config.currency}:1000130`)} // disabled={!submitHandler} class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 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" > <i18n.Translate>Trigger VQF 902.9 officer</i18n.Translate> - </Button> + </AsyncButton> </div> </div> </div> diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx @@ -1,155 +1,101 @@ /* This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. + (C) 2022-2026 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. +*/ - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { assertUnreachable } from "@gnu-taler/taler-util"; -import { Fragment, VNode, h } from "preact"; +import { h, JSX, VNode } from "preact"; import { CSSProperties, HTMLAttributes, useEffect, + useRef, useState, } from "preact/compat"; -import { useNotificationContext } from "../context/notification.js"; -import { SafeHandler } from "../hooks/useNotifications.js"; +import { useTranslationContext } from "../context/translation.js"; import { doAutoFocus } from "./utils.js"; -type Props = Omit< - Omit<HTMLAttributes<HTMLButtonElement>, "type">, - "onClick" -> & { +export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "type"> & { submit?: boolean; - onClick: SafeHandler<any, any> | undefined; focus?: boolean; + type?: "button" | "submit" | "reset"; }; -/** - * we should have a button-type and a submit-type - * submit tpye should not have focus sin the focus in the form - * submit should only be used on forms and there should be only one - */ +/** A plain button. Notifications and asynchronous state belong to callers. */ +export function Button({ focus, submit, type, ...rest }: ButtonProps): VNode { + return ( + <button + {...rest} + ref={focus ? doAutoFocus : undefined} + type={type ?? (submit ? "submit" : "button")} + /> + ); +} + +export type AsyncButtonProps = Omit<ButtonProps, "onClick"> & { + onClick?: ( + event: JSX.TargetedMouseEvent<HTMLButtonElement>, + ) => void | Promise<void>; +}; -/** - * This button accept an async function and report a notification - * on error or success. - * - * When the async function is running the inner text will change into - * a "loading" animation. - * - * @param param0 - * @returns - */ -export function Button({ +/** A button with local progress UI for an asynchronous click handler. */ +export function AsyncButton({ children, - focus, - onClick, disabled, - submit, + onClick, ...rest -}: Props): VNode { +}: AsyncButtonProps): VNode { const [running, setRunning] = useState(false); - const [failed, setFailed] = useState(false); - const { notification: ns } = useNotificationContext(); - const notification = ns.length > 0 ? ns[0] : undefined; - // if the button is in failed state and the user - // change the state of the form that affect this action handler - // the remove the failed state for faster submit - useEffect(() => { - if (failed) { - setFailed(false); - } - }, onClick?.args ?? []); + const mounted = useRef(true); + + useEffect( + () => () => { + mounted.current = false; + }, + [], + ); - onClick?.addListener((e) => { - switch (e) { - case "start": - return setRunning(true); - case "fail": - return setFailed(true); - case "success": - return setFailed(false); - case "finish": - return setRunning(false); - default: { - assertUnreachable(e); - } + async function handleClick( + event: JSX.TargetedMouseEvent<HTMLButtonElement>, + ): Promise<void> { + if (!onClick || running) return; + setRunning(true); + try { + await onClick(event); + } catch (error) { + // AsyncButton deliberately has no notification dependency. Callers + // that need user-facing errors should use useNotifiedOperation. + console.error("Unhandled asynchronous button action", error); + } finally { + if (mounted.current) setRunning(false); } - }); + } return ( - <button + <Button {...rest} - disabled={running || !onClick || !onClick.args || disabled} - ref={focus ? doAutoFocus : undefined} - type={submit ? "submit" : "button"} - data-failed={failed ? "true" : undefined} - onClick={(e) => { - e.preventDefault(); - if (failed) { - setFailed(false); - notification?.acknowledge(); - } else { - onClick?.call(); - } - }} + aria-busy={running} + disabled={disabled || !onClick || running} + onClick={handleClick} > - <div style={{ position: "relative" }}> - <span style={{ visibility: running || failed ? "hidden" : undefined }}> + <span style={{ position: "relative" }}> + <span style={{ visibility: running ? "hidden" : undefined }}> {children} </span> - {running ? <Wait /> : failed ? <Failed /> : undefined} - </div> - </button> - ); -} - -function Failed(): VNode { - return ( - <div role="status" style={{ ...centerInTheMiddle, ...tailwind_size24 }}> - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="1.5" - stroke="currentColor" - > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" - /> - </svg> - <span style={tailwind_srOnly}>Failed</span> - </div> + {running ? <Wait /> : undefined} + </span> + </Button> ); } -////// -// tailwind helper in domCssProps since this needs to work -// with tailwind and bulma -////// - -// const tailwind_textNeutralTertiary: CSSProperties = {}; -const tailwind_animateSpin: CSSProperties = { +const animateSpin: CSSProperties = { animation: "spin 1s cubic-bezier(0.5, 0, 0.5, 1) infinite", }; -const tailwind_size24: CSSProperties = { - width: 24, - height: 24, -}; -const tailwind_srOnly: CSSProperties = { +const size24: CSSProperties = { width: 24, height: 24 }; +const screenReaderOnly: CSSProperties = { position: "absolute", width: 1, height: 1, @@ -160,30 +106,30 @@ const tailwind_srOnly: CSSProperties = { whiteSpace: "nowrap", border: 0, }; -const centerInTheMiddle: CSSProperties = { +const centered: CSSProperties = { position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)", }; -const singleLineHeight: CSSProperties = { - height: "1lh", -}; function Wait(): VNode { + const { i18n } = useTranslationContext(); return ( - <div role="status" style={{ ...centerInTheMiddle, ...tailwind_size24 }}> + <span role="status" style={{ ...centered, ...size24 }}> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" - style={{ ...singleLineHeight, ...tailwind_animateSpin }} + style={{ height: "1lh", ...animateSpin }} > <path d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" /> </svg> - <span style={tailwind_srOnly}>Loading</span> - </div> + <span style={screenReaderOnly}> + <i18n.Translate>Loading</i18n.Translate> + </span> + </span> ); } diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx @@ -1,242 +1,77 @@ -import { Fragment, h, VNode } from "preact"; -import { useRef, useState } from "preact/compat"; +/* + This file is part of GNU Taler + (C) 2022-2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; import { useCommonPreferences } from "../context/common-preferences.js"; import { useNotificationContext } from "../context/notification.js"; import { useTranslationContext } from "../context/translation.js"; -import { CopyButton } from "./CopyButton.js"; import { Attention } from "./Attention.js"; -import { composeRef, saveRef } from "./utils.js"; -import { Duration } from "@gnu-taler/taler-util"; -/** - * Toasts should be considered when displaying these types of information to the user: - * - * Low attention messages that do not require user action - * Singular status updates - * Confirmations - * Information that does not need to be followed up - * - * Do not use toasts if the information contains the following: - * - * High attention and critical information - * Time-sensitive information - * Requires user action or input - * Batch updates - * - * @returns - */ + export function ToastBanner(): VNode { const { i18n } = useTranslationContext(); - const { notification: ns } = useNotificationContext(); + const { notification: notifications } = useNotificationContext(); const [{ showDebugInfo }] = useCommonPreferences(); const [moreInfo, setMoreInfo] = useState(false); - if (!ns || !ns.length) return <Fragment />; - const notification = ns[0]; - switch (notification.message.type) { - case "error": { - const desc = notification.message.description; - return ( - <Attention - type="danger" - title={notification.message.title} - copy - onClose={() => { - notification.acknowledge(); - setMoreInfo(false); - }} - > - {desc && - desc.length && - (moreInfo ? ( - desc.map((d, idx) => { - return ( - <div key={idx} class="mt-2 text-sm text-red-700"> - {d} - </div> - ); - }) - ) : ( - <div class="mt-2 text-sm text-red-700">{desc[0]}</div> - ))} - <div class="flex justify-between"> - <div class="text-[grey]"> - {moreInfo || (desc && desc.length < 2) ? undefined : ( - <button onClick={() => setMoreInfo(true)} class="text-grey"> - <i18n.Translate>Show more info</i18n.Translate> - </button> - )} - </div> - </div> + if (!notifications.length) return <Fragment />; - <pre - class="whitespace-break-spaces text-black" - style={{ display: showDebugInfo ? "block" : "none" }} - > - {JSON.stringify( - notification.message.debug, - function excludePrivate(key, value) { - if (key.startsWith("__")) return "..."; - return value; - }, - 2, - )} - </pre> - </Attention> - ); - } - case "info": - return ( - <Attention - type="success" - title={notification.message.title} - onClose={() => { - notification.acknowledge(); - setMoreInfo(false); - }} - timeout={GLOBAL_TOAST_TIMEOUT} - /> - ); + const notification = notifications[0]; + if (notification.message.type === "info") { + return ( + <Attention + type="success" + title={notification.message.title} + onClose={notification.acknowledge} + /> + ); } -} -const GLOBAL_TOAST_TIMEOUT = Duration.fromSpec({ - seconds: 5, -}); - -export function ToastBannerBulma(): VNode { - const { i18n } = useTranslationContext(); - const { notification: ns } = useNotificationContext(); - const [{ showDebugInfo }] = useCommonPreferences(); - const [moreInfo, setMoreInfo] = useState(false); - const divHtml = useRef<HTMLDivElement>(); - if (!ns || !ns.length) return <Fragment />; - const notification = ns[0]; - const msg = notification.message; - switch (msg.type) { - case "error": - return ( - <div style={{ position: "relative" }}> - <div - style={{ - position: "fixed", - zIndex: 99, - top: 0, - left: 0, - right: 0, - width: "100%", - }} - > - <div class="notification"> - <div class="columns is-vcentered"> - <div ref={composeRef(saveRef(divHtml))} class="column is-12"> - <article class="message is-danger"> - <div class="message-header"> - <p>{msg.title}</p> - <div> - <CopyButton - class="button" - style={{ padding: 8 }} - getContent={() => fromNodeToText(divHtml.current)} - /> - <button - class="delete " - aria-label="close" - style={{ margin: 8 }} - onClick={() => notification.acknowledge()} - /> - </div> - </div> - {msg.description && msg.description.length && ( - <div class="message-body"> - {moreInfo ? ( - msg.description.map((d, idx) => { - return <div key={idx}>{d}</div>; - }) - ) : ( - <div>{msg.description[0]}</div> - )} - {moreInfo || - msg.description.length === 1 ? undefined : ( - <a - onClick={() => setMoreInfo(true)} - type="button" - style={{ justifySelf: "right", color: "gray" }} - > - <i18n.Translate>show more info</i18n.Translate> - </a> - )} - {msg.debug && ( - <pre - class="whitespace-break-spaces text-black" - style={{ - display: showDebugInfo ? "block" : "none", - }} - > - {JSON.stringify( - msg.debug, - function excludePrivate(key, value) { - if (key.startsWith("__")) return "..."; - return value; - }, - 2, - )} - </pre> - )} - </div> - )} - </article> - </div> - </div> - </div> - </div> - </div> - ); - case "info": - return ( - <div style={{ position: "relative" }}> - <div - style={{ - position: "fixed", - zIndex: 99, - top: 0, - left: 0, - right: 0, - width: "100%", - }} - > - <div class="notification"> - <div class="columns is-vcentered"> - <div class="column is-12"> - <article class="message is-info"> - <div class="message-header"> - <p>{msg.title}</p> - </div> - </article> - </div> - </div> - </div> + const descriptions = notification.message.description ?? []; + return ( + <Attention + type="danger" + title={notification.message.title} + copy + onClose={() => { + notification.acknowledge(); + setMoreInfo(false); + }} + > + {(moreInfo ? descriptions : descriptions.slice(0, 1)).map( + (description, index) => ( + <div key={index} class="mt-2 text-sm text-red-700"> + {description} </div> - </div> - ); - } -} - -function fromNodeToText(node: ChildNode | undefined) { - var i, result, text, child; - result = ""; - - if (node) - for (i = 0; i < node.childNodes.length; i++) { - child = node.childNodes[i]; - text = null; - if (child.nodeType === 1) { - text = fromNodeToText(child); - } else if (child.nodeType === 3) { - text = child.nodeValue; - } - if (text) { - result += "\n"; - result += text; - } - } - return result; + ), + )} + {!moreInfo && descriptions.length > 1 ? ( + <button onClick={() => setMoreInfo(true)} class="text-grey"> + <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> + </Attention> + ); } diff --git a/packages/web-util/src/context/notification.ts b/packages/web-util/src/context/notification.ts @@ -16,16 +16,10 @@ import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext } from "preact/hooks"; -import { - newSafeHandlerBuilder, - useNotificationHandler, -} from "../hooks/useNotifications.js"; -import { useTranslationContext } from "./translation.js"; +import { useNotificationHandler } from "../hooks/useNotifications.js"; type Notif = ReturnType<typeof useNotificationHandler>; -interface Type extends Notif { - actionHandler: ReturnType<typeof newSafeHandlerBuilder>; -} +type Type = Notif; function unhandled(): never { throw Error( "Missing NotificationProvider. The application is not properly configured.", @@ -34,7 +28,6 @@ function unhandled(): never { const initial: Type = { notification: [], - actionHandler: unhandled, showError: unhandled, displayInfo: unhandled, showSuccess: unhandled, @@ -49,39 +42,9 @@ interface Props { // Outmost UI wrapper. export const NotificationProvider = ({ children }: Props): VNode => { - const { i18n } = useTranslationContext(); const { notification, ...nf } = useNotificationHandler(); - const actionHandler = newSafeHandlerBuilder({ - onError: (error) => { - nf.displayError( - i18n.str`Unexpected error.`, - error, - i18n.str`The runtime thrown an Error which was not properly handled. To report click the copy button and create an issue in https://bugs.taler.net.`, - ); - }, - onFail: (f) => { - nf.displayError( - i18n.str`The operation failed`, - f, - i18n.str`This handler also need a better error reporting. To report click the copy button and create an issue in https://bugs.taler.net.`, - ); - }, - // no need to show a toast for every succeed operation - // onSuccess: (body) => { - // displayInfo(i18n.str`operation succeeded: ${JSON.stringify(body)}`); - // }, - listeners: [ - (e) => { - if (e === "start") { - nf.clear(); - } - }, - ], - }); - return h(Context.Provider, { value: { - actionHandler, ...nf, notification, }, diff --git a/packages/web-util/src/hooks/index.stories.ts b/packages/web-util/src/hooks/index.stories.ts @@ -1 +0,0 @@ -export * as a1 from "./useNotifications.stories.js"; diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts @@ -26,3 +26,5 @@ export { } from "./useLocalStorage.js"; export { useMemoryStorage } from "./useMemoryStorage.js"; export * from "./useNotifications.js"; +export * from "./useAsyncAction.js"; +export * from "./useNotifiedOperation.js"; diff --git a/packages/web-util/src/hooks/useAsyncAction.ts b/packages/web-util/src/hooks/useAsyncAction.ts @@ -0,0 +1,85 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import { CancellationToken } from "@gnu-taler/taler-util"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; + +export interface AsyncActionOptions<Result, Args extends unknown[]> { + onResult?: (result: Result, ...args: Args) => void | Promise<void>; + onError?: (error: unknown, ...args: Args) => void | Promise<void>; +} + +export interface AsyncAction<Args extends unknown[]> { + running: boolean; + run: (...args: Args) => Promise<void>; + cancel: () => void; +} + +/** + * Run one cancellable asynchronous operation at a time. + * + * The operation and callbacks always come from the latest render. Completion + * after unmount is ignored, and cancellation is not reported as a failure. + */ +export function useAsyncAction<Result, Args extends unknown[]>( + operation: (token: CancellationToken, ...args: Args) => Promise<Result>, + options: AsyncActionOptions<Result, Args> = {}, +): AsyncAction<Args> { + const [running, setRunning] = useState(false); + const runningRef = useRef(false); + const mounted = useRef(true); + const source = useRef<CancellationToken.Source>(); + const operationRef = useRef(operation); + const optionsRef = useRef(options); + operationRef.current = operation; + optionsRef.current = options; + + const cancel = useCallback(() => { + source.current?.cancel(); + }, []); + + useEffect( + () => () => { + mounted.current = false; + source.current?.cancel(); + source.current?.dispose(); + }, + [], + ); + + const run = useCallback(async (...args: Args): Promise<void> => { + if (runningRef.current) return; + runningRef.current = true; + setRunning(true); + const currentSource = CancellationToken.create(); + source.current = currentSource; + try { + const result = await currentSource.token.racePromise( + operationRef.current(currentSource.token, ...args), + ); + if (mounted.current) { + await optionsRef.current.onResult?.(result, ...args); + } + } catch (error: unknown) { + if ( + mounted.current && + !(error instanceof CancellationToken.CancellationError) + ) { + await optionsRef.current.onError?.(error, ...args); + } + } finally { + currentSource.dispose(); + if (source.current === currentSource) source.current = undefined; + runningRef.current = false; + if (mounted.current) setRunning(false); + } + }, []); + + return { running, run, cancel }; +} diff --git a/packages/web-util/src/hooks/useNotifications.stories.tsx b/packages/web-util/src/hooks/useNotifications.stories.tsx @@ -1,524 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { - AbsoluteTime, - opEmptySuccess, - opFixedSuccess, - opKnownFailure, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { Attention } from "../components/Attention.js"; -import { Button } from "../components/Button.js"; -import { - NotificationProvider, - useNotificationContext, -} from "../context/notification.js"; -import { delayMs } from "./useAsync.js"; -import * as tests from "../tests/hook.js"; -import { - newSafeHandlerBuilder, - Notification, - useNotificationHandler, -} from "./useNotifications.js"; -import { HttpResponse } from "../../../taler-util/src/http-common.js"; - -export default { - title: "Use Notifications NG", -}; - -export const autoTriggered = tests.createExample(() => { - const safe = newSafeHandlerBuilder({}); - const [count, setCount] = useState(0); - function inc() { - setCount((n) => n + 1); - } - const action = safe(async (ct) => { - await delayMs(1500, ct); - inc(); - return opEmptySuccess({} as any); - }, []); - useEffect(() => { - const id = setInterval(() => { - action.call(); - }, 3_000); - return () => { - clearInterval(id); - }; - }); - return ( - <div> - <div> - This button is going to be cliked programatically every 3 seconds, - unless is already working. While the action is active it can't be - trigered concurrently. Cancel should prevent the action to complete. - </div> - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action} - > - button - </Button> - <button - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - onClick={action.call} - > - call directly - </button> - <button - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - onClick={action.cancel} - > - cancel - </button> - </div> - </div> - ); -}, {}); - -export const withCancel = tests.createExample(() => { - const safe = newSafeHandlerBuilder({}); - const [count, setCount] = useState(0); - function inc() { - setCount((n) => n + 1); - } - const action = safe(async (ct) => { - await delayMs(5_000, ct); - inc(); - return opEmptySuccess({} as any); - }, []); - return ( - <div> - <div>This button will take 5 secs to complete but can be cancelled</div> - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - <button - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - onClick={action.cancel} - > - cancel - </button> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action} - > - button - </Button> - </div> - </div> - ); -}, {}); - -export const sharedButton = tests.createExample(() => { - const safe = newSafeHandlerBuilder({}); - const [count, setCount] = useState(0); - function inc(d: number) { - setCount((n) => n + d); - } - - const action = safe(async (ct, size: number) => { - await delayMs(1500, ct); - inc(size); - return opEmptySuccess({} as any); - }); - - return ( - <div> - <div>These buttons tiggers the same action but with different args. </div> - <p>It should block both buttons while working.</p> - <p>The "cancel" button it should cancel any of the button.</p> - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action.withArgs(1)} - > - button +1 - </Button> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action.withArgs(2)} - > - button +2 - </Button> - <button - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - onClick={action.cancel} - > - cancel - </button> - </div> - </div> - ); -}, {}); - -export const conditionally = tests.createExample(() => { - const { notification, showSuccess, clear } = useNotificationHandler(); - const safe = newSafeHandlerBuilder({}); - const [count, setCount] = useState(0); - const [name, setName] = useState(""); - function inc() { - setCount((n) => n + 1); - } - const action = safe( - async (ct, s: string) => { - await delayMs(500); - inc(); - return opFixedSuccess({} as any, s); - }, - name.length > 4 ? [name] : undefined, - ); - action.addListener((e) => (e === "start" ? clear() : undefined)); - action.onSuccess = showSuccess( - (d) => `the name ${d} is a good name` as TranslatedString, - ); - - return ( - <div> - <div> - This actions is disabled until the conditions on the forms are met. It - should show succeed on click - </div> - <input - class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " - onChange={(e) => { - setName(e.currentTarget.value); - }} - placeholder={"The name should be greater than 4 characters"} - /> - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action} - > - button - </Button> - </div> - <ShowMessage n={notification} /> - </div> - ); -}, {}); - -export const messages = tests.createExample(() => { - const { notification, showError, showSuccess, clear } = - useNotificationHandler(); - const safe = newSafeHandlerBuilder({}); - const [name, setName] = useState(""); - const [count, setCount] = useState(0); - function inc() { - setCount((n) => n + 1); - return count + 1; - } - const action = safe( - async function (ct, name: string) { - await delayMs(500); - if (!name.length) return opKnownFailure({} as any, "no-name"); - if (name.length <= 4) - return opKnownFailure({} as any, "short-name"); - const id = inc(); - - return opFixedSuccess({} as any, id); - }, - [name], - ); - action.addListener((e) => (e === "start" ? clear() : undefined)); - action.onFail = showError( - "the operation failed" as TranslatedString, - (fail) => { - switch (fail.case) { - case "no-name": - return "please enter a name" as TranslatedString; - case "short-name": - return "the name is not long enough" as TranslatedString; - } - }, - ); - action.onSuccess = showSuccess((s) => { - return `person updated, operation id: ${s}` as TranslatedString; - }); - return ( - <div> - <div>Fail if the input is invalid, shows success otherwise</div> - <input - class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " - onChange={(e) => { - setName(e.currentTarget.value); - }} - placeholder={"The name should be greater than 4 characters"} - /> - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action} - > - button - </Button> - </div> - <ShowMessage n={notification} /> - </div> - ); -}, {}); - -export const recreatedArgumentsKeepErrorVisible = tests.createExample(() => { - return ( - <NotificationProvider> - <RecreatedArgumentsKeepErrorVisible /> - </NotificationProvider> - ); -}, {}); - -function RecreatedArgumentsKeepErrorVisible(): VNode { - const { actionHandler, notification, showError } = useNotificationContext(); - const action = actionHandler( - async function (_ct, _input: { value: string }) { - return opKnownFailure({} as any, "failure"); - }, - // Deliberately recreate the argument object on every render. This mirrors - // the password wrapper used by the AML session unlock form. - [{ value: "same value" }], - ); - action.onFail = showError( - "the operation failed" as TranslatedString, - () => "the error should remain visible" as TranslatedString, - ); - - return ( - <div> - <p> - Click the button. The error must remain visible after the failed action - rerenders the component. - </p> - <Button onClick={action}>fail</Button> - {notification.length ? ( - <Attention - type="danger" - title={notification[0].message.title} - onClose={notification[0].acknowledge} - > - {notification[0].message.type === "error" - ? notification[0].message.description - : undefined} - </Attention> - ) : undefined} - </div> - ); -} - -export const confirm = tests.createExample(() => { - const safe = newSafeHandlerBuilder({}); - const [name, setName] = useState(""); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const { notification, showError, showSuccess, clear } = - useNotificationHandler(); - const [count, setCount] = useState(0); - function inc() { - setCount((n) => n + 1); - return count + 1; - } - const action = safe( - async function (ct, name: string, confirm?: boolean) { - await delayMs(500); - if (!name.length) return opKnownFailure({} as any, "no-name"); - if (name.length <= 4) - return opKnownFailure({} as any, "short-name"); - if (!confirm) { - return opKnownFailure({} as any, "confirm"); - } - const id = inc(); - - return opFixedSuccess({} as any, name + id); - }, - [name], - ); - - action.addListener((e) => (e === "start" ? clear() : undefined)); - action.onFail = showError( - "the operation failed" as TranslatedString, - (fail) => { - switch (fail.case) { - case "no-name": - return "please enter a name" as TranslatedString; - case "short-name": - return "the name is not long enough" as TranslatedString; - case "confirm": - setShowConfirmDialog(true); - return undefined; - } - }, - ); - - action.onSuccess = showSuccess((s) => { - setShowConfirmDialog(false); - return `person updated, operation id: ${s}` as TranslatedString; - }); - - const confirm = action.lambda( - (prev) => (!prev ? undefined : [prev[0], true]), - [], - ); - - return ( - <div> - <div>It will ask for a confirmaton if didn't fail and before succed</div> - <p>When the value is correct it will ask for confirmation</p> - <p>After confirmation the operation should succeed</p> - <p>On fail it should show error dialog</p> - - <input - class="block w-full rounded-md border-0 p-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 " - onChange={(e) => { - setName(e.currentTarget.value); - }} - placeholder={"The name should be greater than 4 characters"} - /> - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - {!showConfirmDialog ? ( - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={action} - > - agree - </Button> - ) : ( - <Fragment> - <button - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - onClick={() => setShowConfirmDialog(false)} - > - cancel - </button> - - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" - onClick={confirm} - > - confirm - </Button> - </Fragment> - )} - </div> - <ShowMessage n={notification} /> - </div> - ); -}, {}); - -export const unhandledError = tests.createExample(() => { - const { notification, displayError, displayInfo, clear } = - useNotificationHandler(); - const safeHandler = newSafeHandlerBuilder({ - onError: (error) => { - displayError(`unpexpected error` as TranslatedString, error); - }, - onFail: (error) => { - displayError(`the operation failed` as TranslatedString, error); - }, - onSuccess: (body) => { - displayInfo( - `operation succeeded: ${JSON.stringify(body)}` as TranslatedString, - ); - }, - }); - const [count, setCount] = useState(0); - function inc() { - setCount((n) => n + 1); - return count + 1; - } - const action = safeHandler(async function (ct, name: string) { - await delayMs(1500, ct); - if (!name.length) throw Error("missing name"); - if (name.length <= 4) - return opKnownFailure({} as any, "short-name"); - const id = inc(); - - return opFixedSuccess({} as any, name + id); - }); - - action.addListener((e) => (e === "start" ? clear() : undefined)); - const ok = action.withArgs("taler"); - const fail = action.withArgs("qwe"); - const error = action.withArgs(""); - - return ( - <div> - <div> - This buttons doesnt have set a particular handler on success and on - error but it should work anyway. - <p>"ok" button should show a info notification when clicked.</p> - <p>"cancel" should not show any error</p> - <p>"fail" should show a fail operation based on a validation</p> - <p>"error" should show a unexpected error</p> - </div> - - <div>click {count} times</div> - <div class="grid gap-2 w-40"> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={ok} - > - ok - </Button> - <button - class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" - onClick={ok.cancel} - > - cancel - </button> - - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={fail} - > - fail - </Button> - <Button - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 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={error} - > - error - </Button> - </div> - <ShowMessage n={notification} /> - </div> - ); -}, {}); - -function ShowMessage({ n: ns }: { n?: Notification[] }): VNode { - if (!ns || !ns.length) return <Fragment />; - const n = ns[0]; - return ( - <Attention - type={n.message.type} - title={n.message.title} - onClose={() => { - n.acknowledge(); - }} - > - {n.message.type === "error" ? n.message.description : undefined} - </Attention> - ); -} diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -1,134 +1,105 @@ +/* + This file is part of GNU Taler + (C) 2022-2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + import { AbsoluteTime, - assertUnreachable, - CancellationToken, - OperationAlternative, - OperationFail, - OperationOk, - OperationResult, TalerError, TalerErrorCode, TranslatedString, } from "@gnu-taler/taler-util"; import { useState } from "preact/hooks"; -import { - InternationalizationAPI, - useTranslationContext, -} from "../context/translation.js"; +import { InternationalizationAPI } from "../context/translation.js"; export type NotificationMessage = ErrorNotification | InfoNotification; export interface ErrorNotification { type: "error"; title: TranslatedString; - ack?: boolean; - timeout?: boolean; description?: TranslatedString[]; - debug?: any; - actions?: {}; + debug?: unknown; when: AbsoluteTime; } + export interface InfoNotification { type: "info"; title: TranslatedString; - ack?: boolean; - timeout?: boolean; when: AbsoluteTime; } -export type Notification = { +export interface Notification { message: NotificationMessage; acknowledge: () => void; -}; - -function hashCode(str: string): string { - if (str.length === 0) return "0"; - let hash = 0; - let chr; - for (let i = 0; i < str.length; i++) { - chr = str.charCodeAt(i); - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - return hash.toString(16); } -function hash(msg: NotificationMessage): string { - let str = (msg.type + ":" + msg.title) as string; - if (msg.type === "error") { - if (msg.description) { - str += ":" + msg.description; - } - if (msg.debug) { - str += ":" + msg.debug; - } - } - return hashCode(str); -} +export type ReplaceReturnType<T, NewReturn> = T extends (...args: any[]) => any + ? (...args: Parameters<T>) => NewReturn + : never; +type Params<T> = T extends (...args: infer P) => any ? P : never; +type FunctionThatReturnsVoid<T> = (...args: Params<T>) => void; export function useNotificationHandler() { const [notification, setNotification] = useState<Notification[]>([]); - function pushNotification(n: Notification) { - setNotification((ns) => [n, ...ns]); - } - function ackNotification(when: AbsoluteTime) { - return (): void => - setNotification((ns) => - ns.filter((n) => n.message.when.t_ms !== when.t_ms), - ); + function pushNotification(message: NotificationMessage): void { + setNotification((current) => { + const item: Notification = { + message, + acknowledge: () => { + setNotification((latest) => latest.filter((n) => n !== item)); + }, + }; + return [item, ...current]; + }); } + function displayError( title: TranslatedString, debug: Error | TalerError | unknown | undefined, ...description: TranslatedString[] - ) { - const when = AbsoluteTime.now(); + ): void { pushNotification({ - message: { - title, - type: "error", - when, - description, - debug, - }, - acknowledge: ackNotification(when), + title, + type: "error", + when: AbsoluteTime.now(), + description, + debug, }); } - function displayInfo(title: TranslatedString) { - const when = AbsoluteTime.now(); - pushNotification({ - message: { - title, - type: "info", - when, - }, - acknowledge: ackNotification(when), - }); + + function displayInfo(title: TranslatedString): void { + pushNotification({ title, type: "info", when: AbsoluteTime.now() }); } function showError<T extends FunctionThatReturnsVoid<T>>( title: TranslatedString, - h: ReplaceReturnType<T, TranslatedString | TranslatedString[] | undefined>, + handler: ReplaceReturnType< + T, + TranslatedString | TranslatedString[] | undefined + >, ): T { return ((...args: Parameters<T>): void => { - const n = h(...args); - if (n === undefined) return; - if (Array.isArray(n)) { - displayError(title, args, ...n); - } else { - displayError(title, args, n); - } + const description = handler(...args); + if (description === undefined) return; + displayError( + title, + args, + ...(Array.isArray(description) ? description : [description]), + ); }) as T; } function showSuccess<T extends FunctionThatReturnsVoid<T>>( - h: ReplaceReturnType<T, TranslatedString | undefined>, + handler: ReplaceReturnType<T, TranslatedString | undefined>, ): T { return ((...args: Parameters<T>): void => { - const n = h(...args); - if (n === undefined) return; - displayInfo(n); + const title = handler(...args); + if (title !== undefined) displayInfo(title); }) as T; } @@ -142,376 +113,6 @@ export function useNotificationHandler() { }; } -export type ReplaceReturnType<T, TNewReturn> = T extends (...a: any) => any - ? (...a: Parameters<T>) => TNewReturn - : never; -export type Params<T> = T extends (...a: infer P) => any ? P : never; -export type FunctionThatReturnsVoid<T> = (...a: Params<T>) => void; - -/** - * A function that may fail and return a message to be shown - * as a notification - */ -export type FunctionThatMayFail<T extends any[]> = ( - ...args: T -) => Promise<NotificationMessage | undefined>; - -/** - * Initialize a notification handler. - * @returns a tuple of notification and setter - * 1) notification that may be set by a function when it fails. - * 2) a error handling function that converts a function that returns a message - * into a function that will set the notification. - * - */ -// export function useLocalNotificationBetter(): [ -// Notification | undefined, -// <Args extends any[], R extends OperationResult<any, any>>( -// opName: TranslatedString, -// doAction: (...args: Args) => Promise<R>, -// args?: Args, -// ) => SafeHandler<Args, R>, -// ] { -// const [value, save] = useState<NotificationMessage>(); -// const notif = !value -// ? undefined -// : { -// message: value, -// acknowledge: () => { -// save(undefined); -// }, -// }; - -// // FIXME: we should move this outside of logic -// const { i18n } = useTranslationContext(); - -// function safeFunctionHandler< -// Args extends any[], -// R extends OperationResult<any, any>, -// >( -// opName: TranslatedString, -// doAction: (...args: Args) => Promise<R>, -// args?: Args, -// ): SafeHandler<Args, R> { -// function buildSafeHandler( -// a: Args | undefined, -// doAction: (...args: Args) => Promise<R>, -// ): SafeHandler<Args, R> { -// const s = CancellationToken.create(); -// let running = false; -// const thiz: SafeHandler<Args, R> = { -// args: a, -// cancel: () => { -// if (running) { -// s.cancel(); -// } -// }, -// withArgs: (...newArgs) => { -// const r = buildSafeHandler(newArgs, doAction); -// r.onSuccess = thiz.onSuccess; -// r.onFail = thiz.onFail; -// return r; -// }, -// lambda: (converter, init) => { -// type D = Parameters<typeof converter>; -// type SH = SafeHandler<D, R>; - -// const r = buildSafeHandler( -// init ? converter(...init) : undefined, -// doAction, -// ); -// // @ts-expect-error -// r.withArgs = (...args: D) => { -// const d = converter(...args); -// if (!d) return thiz; -// const e = thiz.withArgs(...d); -// return e; -// }; -// /** -// * FIXME: there is a problem with this -// * -// * adding onSuccess function after creating the lambda makes the withArgs -// * build handlers without onSuccess. consider this -// * -// * const h = safeHandler(handler).lambda((param) -> .. ) -// * h.onSuccess = () => i18n.str`ok` -// * const button = h.withArgs(p); -// * -// * button.call() -// * -// * the onSuccess function is undefined when button is clicked. -// * But not if the lambda is created after the onSuccess assignment -// */ -// r.onSuccess = thiz.onSuccess; -// r.onFail = thiz.onFail; -// return r as any as SH; -// }, -// call: async (): Promise<void> => { -// if (!thiz.args || running) return; -// running = true; -// try { -// thiz.onStart.forEach((listener) => { -// listener(); -// }); - -// const resp = await s.token.racePromise(doAction(...thiz.args)); -// switch (resp.type) { -// case "ok": { -// const msg = thiz.onSuccess(resp.body, ...thiz.args); -// if (msg) { -// save(successWithTitle(msg)); -// } -// return; -// } -// case "fail": { -// const error = thiz.onFail(resp as any, ...thiz.args); -// if (error) { -// save(failWithTitle(i18n, opName, resp, error, thiz.args)); -// } -// return; -// } -// default: { -// assertUnreachable(resp); -// } -// } -// } catch (error: unknown) { -// if (error instanceof CancellationToken.CancellationError) { -// return; -// } -// // This functions should not throw, this is a problem. -// logBugForDevelopers(error); -// onUnexpected( -// i18n, -// i18n.str`Unexpected error trying to ${opName}`, -// save, -// )(error, thiz.args); -// return; -// } finally { -// thiz.onComplete.forEach((listener) => { -// listener(); -// }); -// running = false; -// } -// }, -// onFail: (fail, ...rest) => -// i18n.str`Unhandled failure trying to ${opName}. Code ${fail.case}`, -// onSuccess: () => undefined, -// onStart: [], -// onComplete: [], -// addCompleteListener: (h) => { -// thiz.onComplete.push(h); -// return thiz; -// }, -// addStartListener: (h) => { -// thiz.onStart.push(h); -// return thiz; -// }, -// }; -// return thiz; -// } -// return buildSafeHandler(args, doAction); -// } - -// return [notif, safeFunctionHandler]; -// } - -export interface SafeHandler<Args extends any[], OpType> { - /** - * Be careful of not settings the args with always-changing values - * like `[]` or `{}` (which returns new instance every time) - * - * The list of arguments is subject to be used as an useEffect dependency array - * for re-render purpose - */ - readonly args: Args | undefined; - readonly listeners: ((e: "start" | "success" | "fail" | "finish") => void)[]; - - call(): Promise<void>; - cancel(): void; - - /** - * creates another handler with new arguments - */ - withArgs(...args: Args): SafeHandler<Args, Error>; - - /** - * Derive a new handler with different calling interface. - * - * The converter will be called with previoulsy set arguments plus the - * new arguments which should return the merged result. - * - */ - lambda<OtherArgs extends any[]>( - convert: ( - prevArgs: Args | undefined, - nextArgs: OtherArgs, - ) => Args | undefined, - init?: OtherArgs, - ): SafeHandler<OtherArgs, OpType>; - - onSuccess: OnOperationSuccesReturnType_NG<OpType, Args>; - onFail: OnOperationFailReturnType_NG<OpType, Args>; - - addListener: ( - h: (event: "start" | "success" | "fail" | "finish") => void, - ) => SafeHandler<Args, Error>; -} - -interface InnerSafeHandler<Args extends any[], R> extends SafeHandler<Args, R> { - cts: { - source: CancellationToken.Source; - running: boolean; - }; -} - -function noop(): undefined {} - -export function simpleSafeHandler(fn: () => void) { - return newSafeHandlerBuilder()(async () => { - fn(); - return { - type: "ok", - body: undefined, - case: "ok", - response: {} as any, - }; - }, []); -} - -export function newSafeHandlerBuilder< - A extends any[], - B extends OperationResult<any, any>, ->( - opts: { - onSuccess?: (result: unknown, ...args: any[]) => void; - onFail?: (fail: unknown, ...args: any[]) => void; - onError?: (e: unknown, ...args: any[]) => void; - listeners?: SafeHandler<unknown[], unknown>["listeners"]; - } = {}, -) { - return function newSafeHandler<Args extends A, R extends B>( - doAction: (t: CancellationToken, ...args: Args) => Promise<R>, - a?: Args, - ): SafeHandler<Args, R> { - const thiz: InnerSafeHandler<Args, R> = { - args: a, - cts: { - source: CancellationToken.create(), - running: false, - }, - cancel: () => { - thiz.cts.source.cancel(); - thiz.cts.source = CancellationToken.create(); - }, - withArgs: (...newArgs) => { - const r = newSafeHandler(doAction, newArgs); - // @ts-expect-error - r.listeners = thiz.listeners; - // @ts-expect-error - r.cts = thiz.cts; - r.cancel = thiz.cancel; - r.onSuccess = thiz.onSuccess; - r.onFail = thiz.onFail; - return r; - }, - lambda: (converter, init) => { - type D = Parameters<typeof converter>[1]; - type SH = SafeHandler<D, R>; - - const r = newSafeHandler( - doAction, - init !== undefined ? converter(thiz.args, init) : undefined, - ); - // @ts-expect-error - r.withArgs = (...args: D) => { - const d = converter(thiz.args, args); - if (d === undefined) return thiz; - const e = thiz.withArgs(...d); - // @ts-expect-error - e.listeners = r.listeners; - // @ts-expect-error - e.cts = r.cts; - e.cancel = r.cancel; - e.onSuccess = r.onSuccess; - e.onFail = r.onFail; - return e; - }; - // @ts-expect-error - r.listeners = thiz.listeners; - // @ts-expect-error - r.cts = thiz.cts; - r.cancel = thiz.cancel; - r.onSuccess = thiz.onSuccess; - r.onFail = thiz.onFail; - return r as any as SH; - }, - call: async (): Promise<void> => { - if (!thiz.args || thiz.cts.running) return; - thiz.cts.running = true; - try { - thiz.listeners.forEach((listener) => { - listener("start"); - }); - const resp = await thiz.cts.source.token.racePromise( - doAction(thiz.cts.source.token, ...thiz.args), - ); - switch (resp.type) { - case "ok": { - thiz.listeners.forEach((listener) => { - listener("success"); - }); - thiz.onSuccess(resp.body, ...thiz.args); - return; - } - case "fail": { - thiz.listeners.forEach((listener) => { - listener("fail"); - }); - thiz.onFail(resp as any, ...thiz.args); - return; - } - default: { - assertUnreachable(resp); - } - } - } catch (error: unknown) { - thiz.listeners.forEach((listener) => { - listener("fail"); - }); - if (error instanceof CancellationToken.CancellationError) { - return; - } - logBugForDevelopers(error); - (opts.onError ?? noop)(error, ...thiz.args); - return; - } finally { - thiz.listeners.forEach((listener) => { - listener("finish"); - }); - thiz.cts.running = false; - } - }, - onFail: opts.onFail ?? noop, - onSuccess: opts.onSuccess ?? noop, - // we save a copy because we don't want further - // references to affect the original handler - listeners: opts.listeners?.slice() ?? [], - addListener: (h) => { - thiz.listeners.push(h); - return thiz; - }, - }; - return thiz; - }; -} - -function logBugForDevelopers(error: unknown) { - console.error( - `Internal error, this is mostly a bug in the application. Please report: `, - error, - ); -} - function describeErrorResponse( i18n: InternationalizationAPI, errorResponse: { code?: number; hint?: string }, @@ -525,8 +126,8 @@ function describeErrorResponse( } } -function notUndefined<T>(t: T | undefined): t is T { - return !!t; +function notUndefined<T>(value: T | undefined): value is T { + return value !== undefined; } export function translateTalerError( @@ -539,16 +140,11 @@ export function translateTalerError( ) { return [ i18n.str`The request reached a timeout, check your connection.`, - // Not every timeout is about an HTTP request. cause.errorDetail.requestUrl - ? i18n.str`The ${cause.errorDetail.requestMethod} request to ${ - cause.errorDetail.requestUrl - } failed after ${(cause.errorDetail.timeoutMs ?? 0) / 1000} seconds.` + ? i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed after ${(cause.errorDetail.timeoutMs ?? 0) / 1000} seconds.` : undefined, cause.errorDetail.when - ? i18n.str`The last request time is ${AbsoluteTime.stringify( - cause.errorDetail.when, - )}` + ? i18n.str`The last request time is ${AbsoluteTime.stringify(cause.errorDetail.when)}` : undefined, ].filter(notUndefined); } @@ -557,9 +153,7 @@ export function translateTalerError( i18n.str`The request was cancelled.`, i18n.str`The ${cause.errorDetail.requestMethod} request ${cause.errorDetail.requestUrl} failed with code ${cause.errorDetail.httpStatusCode}.`, cause.errorDetail.when - ? i18n.str`The request was made at ${AbsoluteTime.stringify( - cause.errorDetail.when, - )}` + ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}` : undefined, ].filter(notUndefined); } @@ -568,9 +162,7 @@ export function translateTalerError( i18n.str`Too many requests were made to the server and this action was throttled.`, i18n.str`The request "${cause.errorDetail.requestMethod} ${cause.errorDetail.requestUrl}" failed with code ${cause.errorDetail.httpStatusCode}`, cause.errorDetail.when - ? i18n.str`The last request time is ${AbsoluteTime.stringify( - cause.errorDetail.when, - )}` + ? i18n.str`The last request time is ${AbsoluteTime.stringify(cause.errorDetail.when)}` : undefined, ].filter(notUndefined); } @@ -579,9 +171,7 @@ export function translateTalerError( i18n.str`The server's response was malformed.`, i18n.str`The response to "${cause.errorDetail.requestMethod} ${cause.errorDetail.requestUrl}" failed with code ${cause.errorDetail.httpStatusCode}`, cause.errorDetail.when - ? i18n.str`The request was made at ${AbsoluteTime.stringify( - cause.errorDetail.when, - )}` + ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}` : undefined, cause.errorDetail.contentType ? i18n.str`The content type is ${cause.errorDetail.contentType}` @@ -599,9 +189,7 @@ export function translateTalerError( i18n.str`Due to a network problem the request could not be finished.`, i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed.`, cause.errorDetail.when - ? i18n.str`The request was made at ${AbsoluteTime.stringify( - cause.errorDetail.when, - )}` + ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}` : undefined, ].filter(notUndefined); } @@ -614,9 +202,7 @@ export function translateTalerError( i18n.str`The server's response was unexpected. This means the client and the server are not in sync about the protocol.`, i18n.str`The ${cause.errorDetail.requestMethod} request to ${cause.errorDetail.requestUrl} failed with code ${cause.errorDetail.httpStatusCode}`, cause.errorDetail.when - ? i18n.str`The request was made at ${AbsoluteTime.stringify( - cause.errorDetail.when, - )}` + ? i18n.str`The request was made at ${AbsoluteTime.stringify(cause.errorDetail.when)}` : undefined, describeErrorResponse(i18n, cause.errorDetail.errorResponse), hint ? i18n.str`And the server says: "${hint}"` : undefined, @@ -624,145 +210,3 @@ export function translateTalerError( } return [i18n.str`Unexpected error`, cause.message as TranslatedString]; } - -function onUnexpected( - i18n: InternationalizationAPI, - title: TranslatedString, - save: (m: NotificationMessage) => void, -): (cause: unknown, args: any[]) => void { - return (error, args) => { - if (error instanceof TalerError) { - save({ - title, - type: "error", - description: translateTalerError(error, i18n), - debug: { - error, - stack: error instanceof Error ? error.stack : undefined, - args: sanitizeFunctionArguments(args), - when: AbsoluteTime.now(), - }, - when: AbsoluteTime.now(), - }); - } else { - const description = ( - error instanceof Error ? error.message : String(error) - ) as TranslatedString; - - save({ - title, - type: "error", - description: [ - i18n.str`Unexpected error, this is likely a bug. Please report it.`, - ], - debug: { - error: String(error), - stack: error instanceof Error ? error.stack : undefined, - args: sanitizeFunctionArguments(args), - when: AbsoluteTime.now(), - }, - when: AbsoluteTime.now(), - }); - } - }; -} - -function sanitizeFunctionArguments(args: any[]): string { - return args - .map((d) => - typeof d === "string" && d.startsWith("secret-token:") - ? "secret-token:...redacted..." - : typeof d === "object" - ? JSON.stringify(d, undefined, 2) - : d, - ) - .join(", "); -} - -/** - * A function converted into a safe handler. - * - * - */ -// export interface SafeHandler<Args extends any[], Errors> { -// readonly args: Args | undefined; -// readonly onStart: (() => void)[]; -// readonly onComplete: (() => void)[]; -// /** -// * call the action with the arguments -// */ -// call(): Promise<void>; -// cancel(): void; -// /** -// * creates another handler for the same actions but different arguments -// * @param e -// */ -// lambda<OtherArgs extends any[]>( -// e: (...d: OtherArgs) => Args | undefined, -// init?: OtherArgs, -// ): SafeHandler<OtherArgs, Errors>; -// /** -// * creates another handler with new arguments -// * @param args -// */ -// withArgs(...args: Args): SafeHandler<Args, Error>; - -// onSuccess: OnOperationSuccesReturnType<Errors, Args>; -// onFail: OnOperationFailReturnType<Errors, Args>; -// addStartListener: (h: () => void) => SafeHandler<Args, Error>; -// addCompleteListener: (h: () => void) => SafeHandler<Args, Error>; -// } - -function successWithTitle(title: TranslatedString): NotificationMessage { - return { - title, - type: "info", - when: AbsoluteTime.now(), - }; -} - -function failWithTitle( - i18n: InternationalizationAPI, - opName: TranslatedString, - fail: OperationFail<any>, - description: TranslatedString, - args: any[], -): NotificationMessage { - return { - title: i18n.str`Unable to ${opName}.`, - type: "error", - description: [description], - debug: { - detail: fail.detail, - case: fail.case, - when: AbsoluteTime.now(), - }, - when: AbsoluteTime.now(), - }; -} - -export type OnOperationSuccesReturnType<T, K extends any[]> = ( - result: T extends OperationOk<infer B> ? B : never, - ...args: K -) => TranslatedString | undefined | void; - -export type OnOperationFailReturnType<T, K extends any[]> = ( - d: - | (T extends OperationFail<any> ? T : never) - | (T extends OperationAlternative<any, any> ? T : never), - ...args: K -) => TranslatedString | undefined; - -export type OnOperationUnexpectedFailReturnType = (e: unknown) => void; - -export type OnOperationSuccesReturnType_NG<T, K extends any[]> = ( - result: T extends OperationOk<infer B> ? B : never, - ...args: K -) => void; - -export type OnOperationFailReturnType_NG<T, K extends any[]> = ( - d: - | (T extends OperationFail<any> ? T : never) - | (T extends OperationAlternative<any, any> ? T : never), - ...args: K -) => void; diff --git a/packages/web-util/src/hooks/useNotifiedOperation.ts b/packages/web-util/src/hooks/useNotifiedOperation.ts @@ -0,0 +1,86 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import { + assertUnreachable, + CancellationToken, + OperationAlternative, + OperationFail, + OperationOk, + OperationResult, +} from "@gnu-taler/taler-util"; +import { useNotificationContext } from "../context/notification.js"; +import { useTranslationContext } from "../context/translation.js"; +import { AsyncAction, useAsyncAction } from "./useAsyncAction.js"; + +export type OperationSuccess<Result> = + Result extends OperationOk<infer Body> ? Body : never; +export type OperationFailure<Result> = + | (Result extends OperationFail<unknown> ? Result : never) + | (Result extends OperationAlternative<unknown, unknown> ? Result : never); + +export interface NotifiedOperationOptions< + Result extends OperationResult<unknown, unknown>, + Args extends unknown[], +> { + onSuccess?: ( + result: OperationSuccess<Result>, + ...args: Args + ) => void | Promise<void>; + onFail?: ( + failure: OperationFailure<Result>, + ...args: Args + ) => void | Promise<void>; +} + +/** Connect an OperationResult-returning action to the notification system. */ +export function useNotifiedOperation< + Result extends OperationResult<unknown, unknown>, + Args extends unknown[], +>( + operation: (token: CancellationToken, ...args: Args) => Promise<Result>, + options: NotifiedOperationOptions<Result, Args> = {}, +): AsyncAction<Args> { + const { displayError } = useNotificationContext(); + const { i18n } = useTranslationContext(); + + return useAsyncAction(operation, { + onResult: async (result, ...args) => { + switch (result.type) { + case "ok": + await options.onSuccess?.( + result.body as OperationSuccess<Result>, + ...args, + ); + return; + case "fail": + if (options.onFail) { + await options.onFail(result as OperationFailure<Result>, ...args); + } else { + displayError( + i18n.str`The operation failed.`, + result, + i18n.str`The server rejected the operation without a specific error message.`, + ); + } + return; + default: + assertUnreachable(result); + } + }, + onError: (error, ..._args) => { + console.error("Unexpected error while running an operation", error); + displayError( + i18n.str`Unexpected error.`, + error, + i18n.str`The runtime threw an Error which was not properly handled. Please report this at https://bugs.taler.net.`, + ); + }, + }); +}