commit 990479c9818c9650a869f0e8123b220df40bc43d parent 483cdd567e59db441fa947514be06f7f8092f6e1 Author: Sebastian <sebasjm@gmail.com> Date: Thu, 25 Sep 2025 12:26:00 -0300 split reponsability: UI vs creating a safe function Diffstat:
25 files changed, 478 insertions(+), 367 deletions(-)
diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -16,14 +16,14 @@ import { LocalNotificationBanner, + makeSafeCall, urlPattern, useBankCoreApiContext, useChallengeHandler, useCurrentLocation, - useLocalNotification, useLocalNotificationBetter, useNavigationContext, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -33,22 +33,23 @@ import { HttpStatusCode, TalerErrorCode, TokenRequest, - TranslatedString, assertUnreachable, - createRFC8959AccessTokenEncoded, + createRFC8959AccessTokenEncoded } from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; -import { useBankState } from "./hooks/bank-state.js"; import { useRefreshSessionBeforeExpires, useSessionState, } from "./hooks/session.js"; import { AccountPage } from "./pages/AccountPage/index.js"; import { BankFrame } from "./pages/BankFrame.js"; +import { ConversionRateClassDetails } from "./pages/ConversionRateClassDetails.js"; import { LoginForm, SESSION_DURATION } from "./pages/LoginForm.js"; +import { NewConversionRateClass } from "./pages/NewConversionRateClass.js"; import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; import { RegistrationPage } from "./pages/RegistrationPage.js"; import { ShowNotifications } from "./pages/ShowNotifications.js"; +import { SolveMFAChallenges } from "./pages/SolveMFA.js"; import { WireTransfer } from "./pages/WireTransfer.js"; import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js"; import { CashoutListForAccount } from "./pages/account/CashoutListForAccount.js"; @@ -61,10 +62,6 @@ import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; import { ConversionConfig } from "./pages/regional/ConversionConfig.js"; import { CreateCashout } from "./pages/regional/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/regional/ShowCashoutDetails.js"; -import { NewConversionRateClass } from "./pages/NewConversionRateClass.js"; -import { ConversionRateClassDetails } from "./pages/ConversionRateClassDetails.js"; -import { SolveMFAChallenges } from "./pages/SolveMFA.js"; -import { TanChannel } from "./utils.js"; const TALER_SCREEN_ID = 100; @@ -121,7 +118,7 @@ function PublicRounting({ const { navigateTo } = useNavigationContext(); const { config, lib } = useBankCoreApiContext(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); useEffect(() => { @@ -136,9 +133,10 @@ function PublicRounting({ refreshable: true, } as TokenRequest; - const [doAutomaticLogin, repeatLogin] = mfa.withMfaHandler( + const [doAutomaticLogin, repeatLogin, lastCallingArgs] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, (username: string, password: string) => lib.bank.createAccessToken( username, @@ -178,6 +176,7 @@ function PublicRounting({ currentChallenge={mfa.pendingChallenge} description={i18n.str`New web session`} onCancel={mfa.doCancelChallenge} + username={lastCallingArgs[0]} onCompleted={repeatLogin} /> ); @@ -214,7 +213,7 @@ function PublicRounting({ <Fragment> <LocalNotificationBanner notification={notification} /> <RegistrationPage - onRegistrationSuccesful={doAutomaticLogin} + onRegistrationSuccesful={notifyOnError(doAutomaticLogin)} routeCancel={publicPages.login} /> </Fragment> diff --git a/packages/bank-ui/src/components/Cashouts/views.tsx b/packages/bank-ui/src/components/Cashouts/views.tsx @@ -165,30 +165,6 @@ export function ReadyView({ )} /> </div> - { - //FIXME: implement responsive view - } - {/* <dl class="font-normal sm:hidden"> - <dt class="sr-only sm:hidden"><i18n.Translate>Amount</i18n.Translate></dt> - <dd class="mt-1 truncate text-gray-700"> - {item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? ( - <span data-negative={item.negative ? "true" : "false"} class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600"> - <RenderAmount value={item.amount} /> - </span> - ) : ( - <span style={{ color: "grey" }}><{i18n.str`invalid value`}></span> - )}</dd> - - <dt class="sr-only sm:hidden"><i18n.Translate>Counterpart</i18n.Translate></dt> - <dd class="mt-1 truncate text-gray-500 sm:hidden"> - {item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart} - </dd> - <dd class="mt-1 text-gray-500 sm:hidden" > - <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> - {item.subject} - </pre> - </dd> - </dl> */} </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600 cursor-pointer"> <RenderAmount diff --git a/packages/bank-ui/src/components/QR.tsx b/packages/bank-ui/src/components/QR.tsx @@ -31,21 +31,8 @@ export function QR({ text }: { text: string }): VNode { }); return ( - <div - style={{ - display: "flex", - flexDirection: "column", - alignItems: "left", - }} - > - <div - style={{ - width: "100%", - marginRight: "auto", - marginLeft: "auto", - }} - ref={divRef} - /> + <div class="flex flex-col "> + <div class="mx-auto w-full" ref={divRef} /> </div> ); } diff --git a/packages/bank-ui/src/components/Transactions/views.tsx b/packages/bank-ui/src/components/Transactions/views.tsx @@ -149,7 +149,7 @@ export function ReadyView({ /> </span> ) : ( - <span style={{ color: "grey" }}> + <span class="text-[grey]"> <{i18n.str`Invalid value`}> </span> )} @@ -190,10 +190,11 @@ export function ReadyView({ value={item.amount} negative={item.negative} withColor + withSign spec={config.currency_specification} /> ) : ( - <span style={{ color: "grey" }}> + <span class="text-[grey]"> < {i18n.str`Invalid value`}> </span> diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx @@ -378,6 +378,7 @@ function AccountBalance({ account }: { account: string }): VNode { value={Amounts.parseOrThrow(result.body.balance.amount)} negative={result.body.balance.credit_debit_indicator === "debit"} spec={config.currency_specification} + withSign /> ); } diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx @@ -22,7 +22,9 @@ import { } from "@gnu-taler/taler-util"; import { Button, + ButtonBetter, LocalNotificationBanner, + makeSafeCall, RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, @@ -80,7 +82,7 @@ export function LoginForm({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); const { config } = useBankCoreApiContext(); @@ -108,7 +110,8 @@ export function LoginForm({ const [doLogin, repeatLogin] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, (username: string, password: string) => api.createAccessToken( username, @@ -144,14 +147,17 @@ export function LoginForm({ ), ); const loginHandler = - !username || !password ? undefined : () => doLogin(username, password); + !username || !password || !!errors + ? undefined + : () => notifyOnError(doLogin)(username, password); - if (mfa.pendingChallenge && repeatLogin) { + if (mfa.pendingChallenge && repeatLogin && username) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} description={i18n.str`Account login.`} onCancel={mfa.doCancelChallenge} + username={username} onCompleted={repeatLogin} /> ); @@ -248,27 +254,25 @@ export function LoginForm({ <i18n.Translate>Cancel</i18n.Translate> </button> - <Button + <ButtonBetter type="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" - disabled={!!errors} - handler={{ onClick: loginHandler }} + onClick={loginHandler} > <i18n.Translate>Check</i18n.Translate> - </Button> + </ButtonBetter> </div> ) : ( <div> - <Button + <ButtonBetter type="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" - disabled={!!errors} - handler={{ onClick: loginHandler }} + onClick={loginHandler} > <i18n.Translate>Log in</i18n.Translate> - </Button> + </ButtonBetter> </div> )} </form> diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -25,6 +25,7 @@ import { Attention, ButtonBetter, LocalNotificationBanner, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, @@ -67,7 +68,7 @@ export function NeedConfirmationView({ }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; const mfa = useChallengeHandler(); @@ -82,27 +83,31 @@ export function NeedConfirmationView({ const doAbort = !creds ? undefined - : withErrorHandler( - () => bank.abortWithdrawalById(creds, operationId), - (suc) => { - onAbort(); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; - case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - } - }, + : notifyOnError( + makeSafeCall( + i18n, + () => bank.abortWithdrawalById(creds, operationId), + (suc) => { + onAbort(); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Conflict: + return i18n.str`The reserve operation has been confirmed previously and can't be aborted`; + case HttpStatusCode.BadRequest: + return i18n.str`The operation ID is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + } + }, + ), ); const [doConfirm, repeatConfirm] = !creds ? [undefined, undefined] : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, () => bank.confirmWithdrawalById(creds, {}, operationId, { challengeIds, @@ -371,7 +376,7 @@ export function NeedConfirmationView({ type="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={doConfirm} + onClick={!doConfirm ? undefined : notifyOnError(doConfirm)} > <i18n.Translate>Transfer</i18n.Translate> </ButtonBetter> @@ -539,7 +544,8 @@ export function ReadyView({ const doAbort = !creds ? undefined - : withErrorHandler( + : makeSafeCall( + i18n, () => bank.abortWithdrawalById(creds, operationId), (suc) => { onAbort(); diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx @@ -128,11 +128,8 @@ export function PaymentOptions({ <i18n.Translate>to a Taler wallet</i18n.Translate> </span> <svg - class="self-center flex-none h-5 w-5 text-indigo-600" - style={{ - visibility: - tab === "charge-wallet" ? "visible" : "hidden", - }} + data-selection={tab} + class="self-center flex-none h-5 w-5 text-indigo-600 invisible data-[selection=charge-wallet]:visible" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" @@ -181,11 +178,8 @@ export function PaymentOptions({ <i18n.Translate>to another bank account</i18n.Translate> </span> <svg - class="self-center flex-none h-5 w-5 text-indigo-600" - style={{ - visibility: - tab === "wire-transfer" ? "visible" : "hidden", - }} + data-selection={tab} + class="self-center flex-none h-5 w-5 text-indigo-600 invisible data-[selection=wire-transfer]:visible" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -32,23 +32,22 @@ import { stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { - Button, + ButtonBetter, InternationalizationAPI, LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, useLocalNotificationBetter, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { useBankState } from "../hooks/bank-state.js"; import { LoggedIn, useSessionState } from "../hooks/session.js"; import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; -import { UserAndToken } from "@gnu-taler/taler-util"; import { SolveMFAChallenges } from "./SolveMFA.js"; const TALER_SCREEN_ID = 106; @@ -99,7 +98,7 @@ export function PaytoWireTransferForm({ const trimmedAmountStr = amount?.trim(); const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); const paytoType = @@ -206,9 +205,10 @@ export function PaytoWireTransferForm({ type reqType = TalerCorebankApi.CreateTransactionRequest; - const [doTransfer, repeatTransfer] = mfa.withMfaHandler( + const [doTransfer, repeatTransfer, lastCallingArgs] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, (credentials: LoggedIn, request: reqType) => api.createTransaction(credentials, request, { challengeIds, @@ -255,7 +255,7 @@ export function PaytoWireTransferForm({ const sendHandler = !request || credentials.status !== "loggedIn" ? undefined - : () => doTransfer(credentials, request); + : () => notifyOnError(doTransfer)(credentials, request); if (mfa.pendingChallenge && repeatTransfer) { return ( @@ -263,6 +263,7 @@ export function PaytoWireTransferForm({ currentChallenge={mfa.pendingChallenge} description={i18n.str`Confirm wire transfer.`} onCancel={mfa.doCancelChallenge} + username={lastCallingArgs[0].username} onCompleted={repeatTransfer} /> ); @@ -513,7 +514,7 @@ export function PaytoWireTransferForm({ class="block text-sm font-medium leading-6 text-gray-900" > {i18n.str`Transfer subject`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <textarea @@ -548,7 +549,7 @@ export function PaytoWireTransferForm({ class="block text-sm font-medium leading-6 text-gray-900" > {i18n.str`Amount`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <InputAmount name="amount" @@ -576,7 +577,7 @@ export function PaytoWireTransferForm({ class="block text-sm font-medium leading-6 text-gray-900" > {i18n.str`Payto URI:`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <textarea @@ -660,15 +661,15 @@ export function PaytoWireTransferForm({ ) : ( <div /> )} - <Button + <ButtonBetter type="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" disabled={isRawPayto ? !!errorsPayto : !!errorsWire} - handler={{ onClick: sendHandler }} + onClick={sendHandler} > <i18n.Translate>Send</i18n.Translate> - </Button> + </ButtonBetter> </div> <LocalNotificationBanner notification={notification} /> </form> @@ -762,15 +763,17 @@ export function InputAmount( export function RenderAmount({ value, spec, - negative, - withColor, - hideSmall, + negative = false, + withColor = false, + hideSmall = false, + withSign = false, }: { spec: CurrencySpecification; value: AmountJson; hideSmall?: boolean; negative?: boolean; withColor?: boolean; + withSign?: boolean; }): VNode { const neg = !!negative; // convert to true or false @@ -784,7 +787,7 @@ export function RenderAmount({ data-negative={withColor ? neg : undefined} class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" > - {negative ? "- " : undefined} + {withSign && negative ? "- " : undefined} {currency} {normal}{" "} {!hideSmall && small && <sup class="-ml-1">{small}</sup>} </span> @@ -920,7 +923,7 @@ export function TextField({ <div class="sm:col-span-5"> <label for={id} class="block text-sm font-medium leading-6 text-gray-900"> {label} - {required && <b style={{ color: "red" }}> *</b>} + {required && <b class="text-[red]"> *</b>} </label> <div class="mt-2"> <Wrapper withIcon={rightIcons !== undefined}> diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -218,7 +218,7 @@ function RegistrationForm({ class="block text-sm font-medium leading-6 text-gray-900" > <i18n.Translate>Login username</i18n.Translate> - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input @@ -250,7 +250,7 @@ function RegistrationForm({ class="block text-sm font-medium leading-6 text-gray-900" > <i18n.Translate>Password</i18n.Translate> - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> </div> <div class="mt-2"> @@ -292,7 +292,7 @@ function RegistrationForm({ class="block text-sm font-medium leading-6 text-gray-900" > <i18n.Translate>Repeat password</i18n.Translate> - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> </div> <div class="mt-2"> @@ -324,7 +324,7 @@ function RegistrationForm({ class="block text-sm font-medium leading-6 text-gray-900" > <i18n.Translate>Full name</i18n.Translate> - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> </div> <div class="mt-2"> diff --git a/packages/bank-ui/src/pages/SolveMFA.tsx b/packages/bank-ui/src/pages/SolveMFA.tsx @@ -9,11 +9,13 @@ import { import { ButtonBetter, LocalNotificationBanner, + makeSafeCall, + NotificationMessage, ShowInputErrorLabel, undefinedIfEmpty, useBankCoreApiContext, useLocalNotificationBetter, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -21,7 +23,8 @@ import { useSessionState } from "../hooks/session.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; export interface Props { - onCompleted(challenges: string[]): void; + onCompleted(challenges: string[]): Promise<NotificationMessage | undefined>; + username: string; onCancel(): void; description: TranslatedString; currentChallenge: ChallengeResponse; @@ -31,34 +34,36 @@ function SolveChallenge({ challenge, onCancel, onSolved, + username, }: { onCancel: () => void; challenge: Challenge; onSolved: () => void; + username: string; }): VNode { const { i18n } = useTranslationContext(); const [tanCode, setTanCode] = useState<string>(); - const session = useSessionState(); - const username = - session.state.status === "loggedIn" ? session.state.username : "merchant"; const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const errors = undefinedIfEmpty({ code: !tanCode ? i18n.str`Required` : undefined, }); - const doVerification = - !tanCode || !username - ? undefined - : withErrorHandler( + const doVerification = !tanCode + ? undefined + : notifyOnError( + makeSafeCall( + i18n, () => api.confirmChallenge(username, challenge.challenge_id, { tan: tanCode, }), - onSolved, + (success) => { + onSolved(); + }, (resp) => { switch (resp.case) { case HttpStatusCode.Unauthorized: @@ -71,8 +76,8 @@ function SolveChallenge({ return i18n.str`Failed to validate the verification code.`; } }, - ); - + ), + ); return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -90,10 +95,24 @@ function SolveChallenge({ </span> </h2> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - The verification code sent to the email address starting with{" "} - <b>"{"asd@qwe.com"}"</b> - </i18n.Translate> + {(function (c): VNode { + switch (c.tan_channel) { + case TanChannel.EMAIL: + return ( + <i18n.Translate> + The verification code sent to the email address starting + with <b>"{c.tan_info}"</b> + </i18n.Translate> + ); + case TanChannel.SMS: + return ( + <i18n.Translate> + The verification code sent to the phone number starting + with <b>"{c.tan_info}"</b> + </i18n.Translate> + ); + } + })(challenge)} </p> </div> @@ -167,6 +186,7 @@ function SolveChallenge({ export function SolveMFAChallenges({ currentChallenge, + username, description, onCompleted, onCancel, @@ -175,10 +195,7 @@ export function SolveMFAChallenges({ const [solved, setSolved] = useState<string[]>([]); const [selected, setSelected] = useState<Challenge>(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); - const session = useSessionState(); - const username = - session.state.status === "loggedIn" ? session.state.username : "merchant"; + const [notification, notifyOnError] = useLocalNotificationBetter(); const { lib: { bank: api }, } = useBankCoreApiContext(); @@ -188,6 +205,7 @@ export function SolveMFAChallenges({ <SolveChallenge onCancel={() => setSelected(undefined)} challenge={selected} + username={username} onSolved={() => { setSelected(undefined); setSolved([...solved, selected.challenge_id]); @@ -203,30 +221,33 @@ export function SolveMFAChallenges({ ? currentSolved.length === currentChallenge.challenges.length : currentSolved.length > 0; - const sendMessage = withErrorHandler( - (user: string, ch: Challenge) => api.sendChallenge(user, ch.challenge_id), - (success, user, ch) => { - setSelected(ch); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Failed to send the verification code.`; - case HttpStatusCode.Forbidden: - return i18n.str`Failed to send the verification code.`; - case HttpStatusCode.NotFound: - return i18n.str`Failed to send the verification code.`; - case HttpStatusCode.TooManyRequests: - return i18n.str`Failed to send the verification code.`; - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return i18n.str`Failed to send the verification code.`; - } - }, + const sendMessage = notifyOnError( + makeSafeCall( + i18n, + (user: string, ch: Challenge) => api.sendChallenge(user, ch.challenge_id), + (success, user, ch) => { + setSelected(ch); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Failed to send the verification code.`; + case HttpStatusCode.Forbidden: + return i18n.str`Failed to send the verification code.`; + case HttpStatusCode.NotFound: + return i18n.str`Failed to send the verification code.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`Failed to send the verification code.`; + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: + return i18n.str`Failed to send the verification code.`; + } + }, + ), ); const doComplete = !hasSolvedEnough ? undefined - : async () => onCompleted(solved); + : async () => notifyOnError(onCompleted)(solved); return ( <Fragment> diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -27,6 +27,7 @@ import { Attention, ButtonBetter, LocalNotificationBanner, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, @@ -67,7 +68,7 @@ export function WithdrawalConfirmationQuestion({ const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); const { @@ -82,7 +83,8 @@ export function WithdrawalConfirmationQuestion({ const [doConfirm, repeatConfirm] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, (creds: LoggedIn, opId: string) => api.confirmWithdrawalById(creds, {}, opId, { challengeIds }), (success) => { @@ -118,12 +120,13 @@ export function WithdrawalConfirmationQuestion({ const confirmHandler = !creds ? undefined - : () => doConfirm(creds, withdrawUri.withdrawalOperationId); + : () => notifyOnError(doConfirm)(creds, withdrawUri.withdrawalOperationId); const abortHandler = !creds ? undefined - : () => - withErrorHandler( + : notifyOnError(() => + makeSafeCall( + i18n, (creds, opId) => api.abortWithdrawalById(creds, opId), (success) => { onAborted(); @@ -141,7 +144,8 @@ export function WithdrawalConfirmationQuestion({ } } }, - )(creds, withdrawUri.withdrawalOperationId); + )(creds, withdrawUri.withdrawalOperationId), + ); if (mfa.pendingChallenge && repeatConfirm) { return ( @@ -150,6 +154,7 @@ export function WithdrawalConfirmationQuestion({ description={i18n.str`Complete withdrawal.`} onCancel={mfa.doCancelChallenge} onCompleted={repeatConfirm} + username={details.username} /> ); } diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -31,6 +31,7 @@ import { Loading, LocalNotificationBanner, RouteDefinition, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, @@ -88,7 +89,7 @@ export function ShowAccountDetails({ const [submitAccount, setSubmitAccount] = useState< TalerCorebankApi.AccountReconfiguration | undefined >(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); const result = useAccountDetails(account); @@ -115,7 +116,8 @@ export function ShowAccountDetails({ const [doUpdate, repeatUpdate] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, (creds: LoggedIn, account: TalerCorebankApi.AccountReconfiguration) => bank.updateAccount(creds, account, { challengeIds }), (success) => { @@ -156,7 +158,9 @@ export function ShowAccountDetails({ ); const updateHandler = - !creds || !submitAccount ? undefined : () => doUpdate(creds, submitAccount); + !creds || !submitAccount + ? undefined + : () => notifyOnError(doUpdate)(creds, submitAccount); const url = bank.getRevenueAPI(account); const baseURL = url.href; @@ -179,6 +183,7 @@ export function ShowAccountDetails({ currentChallenge={mfa.pendingChallenge} description={i18n.str`Update account information.`} onCancel={mfa.doCancelChallenge} + username={account} onCompleted={repeatUpdate} /> ); diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -19,6 +19,7 @@ import { LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, @@ -88,7 +89,7 @@ export function UpdateAccountPassword({ ? i18n.str`Repeated password doesn't match` : undefined, }); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); const request = !password @@ -99,10 +100,11 @@ export function UpdateAccountPassword({ }; const [doUpdatePassword, repeatUpdatePassword] = - !token || !request + !token || !request || !!errors ? [undefined, undefined] : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, () => api.updatePassword({ username: accountName, token }, request, { challengeIds, @@ -141,6 +143,7 @@ export function UpdateAccountPassword({ <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} description={i18n.str`Update account password.`} + username={accountName} onCancel={mfa.doCancelChallenge} onCompleted={repeatUpdatePassword} /> @@ -187,7 +190,7 @@ export function UpdateAccountPassword({ for="password" > {i18n.str`Current password`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input @@ -222,7 +225,7 @@ export function UpdateAccountPassword({ for="password" > {i18n.str`New password`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input @@ -250,7 +253,7 @@ export function UpdateAccountPassword({ for="repeat" > {i18n.str`Type it again`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input @@ -289,8 +292,9 @@ export function UpdateAccountPassword({ type="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" - disabled={!!errors} - onClick={doUpdatePassword} + onClick={ + !doUpdatePassword ? undefined : notifyOnError(doUpdatePassword) + } > <i18n.Translate>Change</i18n.Translate> </ButtonBetter> diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -331,7 +331,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ for="username" > {i18n.str`Login username`} - {editableUsername && <b style={{ color: "red" }}> *</b>} + {editableUsername && <b class="text-[red]"> *</b>} </label> <div class="mt-2"> <input @@ -366,7 +366,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ for="name" > {i18n.str`Full name`} - {editableName && <b style={{ color: "red" }}> *</b>} + {editableName && <b class="text-[red]"> *</b>} </label> <div class="mt-2"> <input diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -159,6 +159,7 @@ export function AccountList({ <RenderAmount value={balance} negative={balanceIsDebit} + withSign spec={config.currency_specification} /> </span> diff --git a/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx b/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx @@ -220,7 +220,7 @@ export function ConversionRateClassForm( for="username" > {i18n.str`Name`} - {editableForm && <b style={{ color: "red" }}> *</b>} + {editableForm && <b class="text-[red]"> *</b>} </label> <div class="mt-2"> <input diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -385,10 +385,9 @@ export function DownloadStats({ routeCancel }: Props): VNode { <div> <div class="relative mb-5 h-5 rounded-full bg-gray-200"> <div - class="h-full animate-pulse rounded-full bg-blue-500" - style={{ - width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`, - }} + class={`h-full animate-pulse rounded-full bg-blue-500 w-[${Math.round( + (lastStep.step / lastStep.total) * 100, + )}%]`} > <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> <i18n.Translate> diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -27,6 +27,7 @@ import { LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, @@ -67,7 +68,7 @@ export function RemoveAccount({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); if (!result) { @@ -119,35 +120,6 @@ export function RemoveAccount({ ); } - const [doDelete, repeatDelete] = !token - ? [undefined, undefined] - : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => - withErrorHandler( - () => - api.deleteAccount({ username: account, token }, { challengeIds }), - (success) => { - notifyInfo(i18n.str`Account removed`); - onUpdateSuccess(); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`No 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: { - onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } - } - }, - ), - ); - const errors = undefinedIfEmpty({ accountName: !accountName ? i18n.str`Required` @@ -156,11 +128,43 @@ export function RemoveAccount({ : undefined, }); + const [doDelete, repeatDelete] = + !token || !!errors + ? [undefined, undefined] + : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => + makeSafeCall( + i18n, + () => + api.deleteAccount({ username: account, token }, { challengeIds }), + (success) => { + notifyInfo(i18n.str`Account removed`); + onUpdateSuccess(); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`No 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: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + } + }, + ), + ); + if (mfa.pendingChallenge && repeatDelete) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} description={i18n.str`Remove account.`} + username={account} onCancel={mfa.doCancelChallenge} onCompleted={repeatDelete} /> @@ -243,8 +247,7 @@ export function RemoveAccount({ type="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" - disabled={!!errors} - onClick={doDelete} + onClick={!doDelete ? undefined : notifyOnError(doDelete)} > <i18n.Translate>Delete</i18n.Translate> </ButtonBetter> diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -32,6 +32,7 @@ import { LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, + makeSafeCall, notifyInfo, useBankCoreApiContext, useChallengeHandler, @@ -81,9 +82,10 @@ type ErrorFrom<T> = { [P in keyof T]+?: string; }; +const RANDOM_STRING = encodeCrock(getRandomBytes(32)); + export function CreateCashout({ account: accountName, - onCashout, focus, routeClose, @@ -124,7 +126,7 @@ export function CreateCashout({ const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); const rateResp = useConversionRateForUser(accountName, creds?.token); const conversionResp = useConversionInfo(); - const [notification, withErrorHandler] = useLocalNotificationBetter(); + const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); if (!resultAccount) { @@ -234,7 +236,8 @@ export function CreateCashout({ : true; const notZero = Amounts.isNonZero(inputAmount); - const conversionCalculator = withErrorHandler( + const conversionCalculator = makeSafeCall( + i18n, (isDebit: boolean, input: AmountJson, fee: AmountJson) => isDebit ? calculateFromDebit(input, fee) @@ -298,12 +301,13 @@ export function CreateCashout({ const [createCashout, repeatCashout] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - withErrorHandler( + makeSafeCall( + i18n, (creds: LoggedIn, calc: TransCalc, subject: string) => api.createCashout( creds, { - request_uid: encodeCrock(getRandomBytes(32)), + request_uid: RANDOM_STRING, amount_credit: Amounts.stringify(calc.credit), amount_debit: Amounts.stringify(calc.debit), subject, @@ -354,7 +358,7 @@ export function CreateCashout({ const cashoutHandler = !!errors || !creds || !subject ? undefined - : () => createCashout(creds, calc, subject); + : () => notifyOnError(createCashout)(creds, calc, subject); const cashoutDisabled = !resultAccount.body.cashout_payto_uri; @@ -373,6 +377,7 @@ export function CreateCashout({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + username={accountName} description={i18n.str`Create cashout.`} onCancel={mfa.doCancelChallenge} onCompleted={repeatCashout} @@ -479,7 +484,7 @@ export function CreateCashout({ for="subject" > {i18n.str`Transfer subject`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> <div class="mt-2"> <input @@ -598,7 +603,7 @@ export function CreateCashout({ for="amount" > {i18n.str`Amount`} - <b style={{ color: "red" }}> *</b> + <b class="text-[red]"> *</b> </label> {/* <button type="button" diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx @@ -128,13 +128,13 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { </section> <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> <div class="px-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 "> <div class="sm:col-span-5"> <dl class="space-y-4"> {result.body.creation_time.t_s !== "never" ? ( <div class="justify-between items-center flex "> <dt class=" text-gray-600"> - <i18n.Translate>Created</i18n.Translate> + <i18n.Translate>Date</i18n.Translate> </dt> <dd class="text-sm "> <Time @@ -165,7 +165,7 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { <div class="flex items-center justify-between border-t-2 afu pt-4"> <dt class="flex items-center text-gray-600"> <span> - <i18n.Translate>Credited</i18n.Translate> + <i18n.Translate>Transfered</i18n.Translate> </span> </dt> <dd class="text-sm "> @@ -183,8 +183,7 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { </div> </div> - <br /> - <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> <a href={routeClose.url({})} name="close" diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx @@ -14,31 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - OperationAlternative, - OperationFail, - OperationOk, - OperationResult, - TranslatedString, -} from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { HTMLAttributes, useState } from "preact/compat"; import { useTranslationContext } from "../index.browser.js"; -// import { useBankCoreApiContext } from "../context/config.js"; -// function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void { - -export type OnOperationSuccesReturnType<T, K extends any[]> = ( - result: T extends OperationOk<any> ? T : never, - ...args: K -) => TranslatedString | 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; export interface ButtonHandler { onClick: (() => Promise<void>) | undefined; diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx @@ -138,7 +138,7 @@ export function LangSelector({ {!hidden && ( <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - style={type === "icon" ? { right: 0, width: 200 } : {}} + // style={type === "icon" ? { right: 0, width: 200 } : {}} tabIndex={-1} role="listbox" aria-labelledby="listbox-label" diff --git a/packages/web-util/src/hooks/useChallenge.ts b/packages/web-util/src/hooks/useChallenge.ts @@ -40,11 +40,12 @@ interface MfaState { */ doCancelChallenge: () => void; - withMfaHandler: <Type extends Array<any>>( - builder: CallbackFactory<Type>, + withMfaHandler: <Type extends Array<any>, R>( + builder: CallbackFactory<Type, R>, ) => [ - (...args: Type) => Promise<void>, // function for the first call - (newChallenges: string[]) => Promise<void>, // function to repeat with new chIds + (...args: Type) => Promise<R>, // function for the first call + (newChallenges: string[]) => Promise<R>, // function to repeat with new chIds + Type, ]; } @@ -70,9 +71,9 @@ interface MfaHandler { /** * asd */ -type CallbackFactory<T extends any[]> = ( +type CallbackFactory<T extends any[] ,R> = ( h: MfaHandler, -) => (...args: T) => Promise<void>; +) => (...args: T) => Promise<R>; /** * Take a function that may require MFA and return and MfaState @@ -84,7 +85,7 @@ type CallbackFactory<T extends any[]> = ( */ export function useChallengeHandler(): MfaState { const [current, onChallengeRequired] = useState<ChallengeResponse>(); - const asd = useRef<any[]>([]); + const ref = useRef<any[]>([]); let exeOrder = 0; /** @@ -92,34 +93,35 @@ export function useChallengeHandler(): MfaState { * @param builder * @returns */ - function withMfaHandler<T extends any[]>( - builder: CallbackFactory<T>, + function withMfaHandler<T extends any[], R>( + builder: CallbackFactory<T, R>, ): [ - ReturnType<CallbackFactory<T>>, - (newChallenges: string[]) => Promise<void>, + ReturnType<CallbackFactory<T, R>>, + (newChallenges: string[]) => Promise<R>, + T ] { const thisIdx = exeOrder; exeOrder = exeOrder + 1; - async function saveArgsAndProceed(...currentArgs: T): Promise<void> { - asd.current[thisIdx] = currentArgs; + async function saveArgsAndProceed(...currentArgs: T): Promise<R> { + ref.current[thisIdx] = currentArgs; return builder({ challengeIds: undefined, onChallengeRequired, })(...currentArgs); } - async function repeatCall(challengeIds: string[]): Promise<void> { - if (!asd.current[thisIdx]) + async function repeatCall(challengeIds: string[]): Promise<R> { + if (!ref.current[thisIdx]) throw Error("calling repeat function without doing the first call"); return builder({ challengeIds, onChallengeRequired, - })(...asd.current[thisIdx]); + })(...ref.current[thisIdx]); } - return [saveArgsAndProceed, repeatCall]; + return [saveArgsAndProceed, repeatCall, ref.current[thisIdx]]; } function reset() { diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -1,5 +1,6 @@ import { AbsoluteTime, + assertUnreachable, Duration, OperationAlternative, OperationFail, @@ -10,11 +11,7 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; -import { - ButtonHandler, - OnOperationFailReturnType, - OnOperationSuccesReturnType, -} from "../components/Button.js"; +import { ButtonHandler } from "../components/Button.js"; import { InternationalizationAPI, memoryMap, @@ -307,101 +304,101 @@ export function useLocalNotificationHandler(): [ return [notif, makeHandler, setter]; } -type HandlerMakerBetter = < - K extends any[], - T extends OperationResult<A, B>, - A, - B, ->( - onClick: (...args: K) => Promise<T>, - onOperationSuccess: OnOperationSuccesReturnType<T, K>, - onOperationFail?: OnOperationFailReturnType<T, K>, - onOperationComplete?: () => void, -) => (...args: K) => Promise<void>; - -export function useLocalNotificationBetter(): [ - Notification | undefined, - HandlerMakerBetter, - (n: NotificationMessage) => void, -] { - const { i18n } = useTranslationContext(); - const [value, setter] = useState<NotificationMessage>(); - const notif = !value - ? undefined - : { - message: value, - acknowledge: () => { - setter(undefined); - }, - }; - - function makeHandler<K extends any[], T extends OperationResult<A, B>, A, B>( - doAction: (...args: K) => Promise<T>, - onOperationSuccess: OnOperationSuccesReturnType<T, K>, - onOperationFail?: OnOperationFailReturnType<T, K>, - onOperationComplete?: () => void, - ): () => Promise<void> { - const onNotification = setter; - return async (...args: K): Promise<void> => { - try { - const resp = await doAction(...args); - if (resp) { - if (resp.type === "ok") { - const result: OperationOk<any> = resp; - // @ts-expect-error this is an operationOk - const msg = onOperationSuccess(result, ...args); - if (msg) { - notifyInfo(msg); - } - } - if (resp.type === "fail") { - const d = "detail" in resp ? resp.detail : undefined; - - const title = !onOperationFail - ? i18n.str`Unexpected error` - : onOperationFail(resp as any, ...args); - onNotification({ - title, - type: "error", - description: - d && d.hint ? (d.hint as TranslatedString) : undefined, - debug: d, - when: AbsoluteTime.now(), - }); - } - } - if (onOperationComplete) { - onOperationComplete(); - } - return; - } catch (error: unknown) { - console.error(error); - - if (error instanceof TalerError) { - onNotification(buildUnifiedRequestErrorMessage(i18n, error)); - } else { - const description = ( - error instanceof Error ? error.message : String(error) - ) as TranslatedString; - - onNotification({ - title: i18n.str`Operation failed`, - type: "error", - description, - when: AbsoluteTime.now(), - }); - } - if (onOperationComplete) { - onOperationComplete(); - } - return; - } - // setRunning(false); - }; - } - - return [notif, makeHandler, setter]; -} +// type HandlerMakerBetter = < +// K extends any[], +// T extends OperationResult<A, B>, +// A, +// B, +// >( +// onClick: (...args: K) => Promise<T>, +// onOperationSuccess: OnOperationSuccesReturnType<T, K>, +// onOperationFail?: OnOperationFailReturnType<T, K>, +// onOperationComplete?: () => void, +// ) => (...args: K) => Promise<void>; + +// export function useLocalNotificationBetter(): [ +// Notification | undefined, +// HandlerMakerBetter, +// (n: NotificationMessage) => void, +// ] { +// const { i18n } = useTranslationContext(); +// const [value, setter] = useState<NotificationMessage>(); +// const notif = !value +// ? undefined +// : { +// message: value, +// acknowledge: () => { +// setter(undefined); +// }, +// }; + +// function makeHandler<K extends any[], T extends OperationResult<A, B>, A, B>( +// doAction: (...args: K) => Promise<T>, +// onOperationSuccess: OnOperationSuccesReturnType<T, K>, +// onOperationFail?: OnOperationFailReturnType<T, K>, +// onOperationComplete?: () => void, +// ): () => Promise<void> { +// const onNotification = setter; +// return async (...args: K): Promise<void> => { +// try { +// const resp = await doAction(...args); +// if (resp) { +// if (resp.type === "ok") { +// const result: OperationOk<any> = resp; +// // @ts-expect-error this is an operationOk +// const msg = onOperationSuccess(result, ...args); +// if (msg) { +// notifyInfo(msg); +// } +// } +// if (resp.type === "fail") { +// const d = "detail" in resp ? resp.detail : undefined; + +// const title = !onOperationFail +// ? i18n.str`Unexpected error` +// : onOperationFail(resp as any, ...args); +// onNotification({ +// title, +// type: "error", +// description: +// d && d.hint ? (d.hint as TranslatedString) : undefined, +// debug: d, +// when: AbsoluteTime.now(), +// }); +// } +// } +// if (onOperationComplete) { +// onOperationComplete(); +// } +// return; +// } catch (error: unknown) { +// console.error(error); + +// if (error instanceof TalerError) { +// onNotification(buildUnifiedRequestErrorMessage(i18n, error)); +// } else { +// const description = ( +// error instanceof Error ? error.message : String(error) +// ) as TranslatedString; + +// onNotification({ +// title: i18n.str`Operation failed`, +// type: "error", +// description, +// when: AbsoluteTime.now(), +// }); +// } +// if (onOperationComplete) { +// onOperationComplete(); +// } +// return; +// } +// // setRunning(false); +// }; +// } + +// return [notif, makeHandler, setter]; +// } function buildUnifiedRequestErrorMessage( i18n: InternationalizationAPI, @@ -492,3 +489,123 @@ function buildUnifiedRequestErrorMessage( } return result; } + +export type FunctionThatMayFail<T extends any[]> = ( + ...args: T +) => Promise<NotificationMessage | undefined>; + +type FunctionWrapperForButton = <T extends any[]>( + fn: FunctionThatMayFail<T>, +) => (...args: T) => Promise<void>; + +export function useLocalNotificationBetter(): [ + Notification | undefined, + FunctionWrapperForButton, +] { + const [value, setter] = useState<NotificationMessage>(); + const notif = !value + ? undefined + : { + message: value, + acknowledge: () => { + setter(undefined); + }, + }; + + function wrapForButtons<T extends any[]>( + fn: (...args: T) => Promise<NotificationMessage | undefined>, + ): (...args: T) => Promise<void> { + return async (...params: T): Promise<void> => { + const error = await fn(...params); + if (error) { + setter(error); + } + }; + } + + return [notif, wrapForButtons]; +} + +/** + * Convert an function that return an operation into a function that return + * a notification if it fail. + * + * @param i18n + * @param doAction + * @param onOperationSuccess + * @param onOperationFail + * @returns + */ +export function makeSafeCall< + K extends any[], + T extends OperationResult<A, B>, + A, + B, +>( + i18n: InternationalizationAPI, + doAction: (...args: K) => Promise<T>, + onOperationSuccess: OnOperationSuccesReturnType<T, K>, + onOperationFail?: OnOperationFailReturnType<T, K>, +): (...args: K) => Promise<NotificationMessage | undefined> { + return async (...args: K): Promise<NotificationMessage | undefined> => { + try { + const resp = await doAction(...args); + switch (resp.type) { + case "ok": { + const result: OperationOk<any> = resp; + // @ts-expect-error this is an operationOk + const msg = onOperationSuccess(result, ...args); + if (msg) { + notifyInfo(msg); + } + return undefined; + } + case "fail": { + const d = "detail" in resp ? resp.detail : undefined; + + const title = !onOperationFail + ? i18n.str`Unexpected error` + : onOperationFail(resp as any, ...args); + return { + title, + type: "error", + description: d && d.hint ? (d.hint as TranslatedString) : undefined, + debug: d, + when: AbsoluteTime.now(), + }; + } + default: { + assertUnreachable(resp); + } + } + } catch (error: unknown) { + console.error(error); + + if (error instanceof TalerError) { + return buildUnifiedRequestErrorMessage(i18n, error); + } else { + const description = ( + error instanceof Error ? error.message : String(error) + ) as TranslatedString; + + return { + title: i18n.str`Operation failed`, + type: "error", + description, + when: AbsoluteTime.now(), + }; + } + } + }; +} +export type OnOperationSuccesReturnType<T, K extends any[]> = ( + result: T extends OperationOk<any> ? T : never, + ...args: K +) => TranslatedString | 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;