taler-typescript-core

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

commit 9739e6aa986284f5bd7147624aa5e14bbd21eab8
parent 35435d32112a596930b72ddf9f43b553fdbaba97
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 20 Oct 2025 12:28:44 -0300

wip, danger:

Diffstat:
Mpackages/bank-ui/src/Routing.tsx | 2+-
Mpackages/bank-ui/src/pages/LoginForm.tsx | 2+-
Mpackages/bank-ui/src/pages/OperationState/views.tsx | 2+-
Mpackages/bank-ui/src/pages/PaytoWireTransferForm.tsx | 2+-
Mpackages/bank-ui/src/pages/SolveMFA.tsx | 151+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx | 222+++++++++++++++++++++++++++++++------------------------------------------------
Mpackages/bank-ui/src/pages/account/ShowAccountDetails.tsx | 2+-
Mpackages/bank-ui/src/pages/account/UpdateAccountPassword.tsx | 2+-
Mpackages/bank-ui/src/pages/admin/RemoveAccount.tsx | 2+-
Mpackages/bank-ui/src/pages/regional/CreateCashout.tsx | 191+++++++++++++++++++++++++++++++++++--------------------------------------------
Mpackages/web-util/src/components/Button.tsx | 9++++-----
Mpackages/web-util/src/hooks/useChallenge.ts | 129+++++++++----------------------------------------------------------------------
Mpackages/web-util/src/hooks/useNotifications.ts | 638++++++++++++++++++++-----------------------------------------------------------
13 files changed, 433 insertions(+), 921 deletions(-)

diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -134,7 +134,7 @@ function PublicRounting({ } as TokenRequest; const [doAutomaticLogin, repeatLogin, lastCallingArgs] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => + ({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, (username: string, password: string) => diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx @@ -109,7 +109,7 @@ export function LoginForm({ } as TokenRequest; const [doLogin, repeatLogin] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => + ({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, (username: string, password: string) => diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -106,7 +106,7 @@ export function NeedConfirmationView({ const [doConfirm, repeatConfirm] = !creds ? [undefined, undefined] - : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => + : mfa.withMfaHandler(({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, () => diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -213,7 +213,7 @@ export function PaytoWireTransferForm({ type reqType = TalerCorebankApi.CreateTransactionRequest; const [doTransfer, repeatTransfer, lastCallingArgs] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => + ({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, (credentials: LoggedIn, request: reqType) => diff --git a/packages/bank-ui/src/pages/SolveMFA.tsx b/packages/bank-ui/src/pages/SolveMFA.tsx @@ -3,6 +3,7 @@ import { Challenge, ChallengeResponse, HttpStatusCode, + opEmptySuccess, TalerErrorCode, TanChannel, TranslatedString, @@ -10,8 +11,9 @@ import { import { ButtonBetter, LocalNotificationBanner, - makeSafeCall, NotificationMessage, + safeFunctionHandler, + SafeHandlerTemplate, ShowInputErrorLabel, Time, undefinedIfEmpty, @@ -20,11 +22,11 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { useState, useEffect } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; export interface Props { - onCompleted(challenges: string[]): Promise<NotificationMessage | undefined>; + onCompleted: SafeHandlerTemplate<[challenges: string[]], any>; username: string; onCancel(): void; description: TranslatedString; @@ -71,34 +73,30 @@ function SolveChallenge({ }; }, []); - const doVerification = !tanCode - ? undefined - : notifyOnError( - makeSafeCall( - i18n, - () => - api.confirmChallenge(username, challenge.challenge_id, { - tan: tanCode, - }), - (success) => { - onSolved(); - }, - (resp) => { - switch (resp.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.`; - } - }, - ), - ); + const doVerification = notifyOnError( + safeFunctionHandler( + (tan: string) => + api.confirmChallenge(username, challenge.challenge_id, { tan }), + !errors ? [tanCode!] : undefined, + ), + ); + + doVerification.onFail = (resp) => { + switch (resp.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.`; + } + }; + doVerification.onSuccess = onSolved; + return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -272,46 +270,43 @@ export function SolveMFAChallenges({ : currentSolved.length > 0; const sendMessage = notifyOnError( - makeSafeCall( - i18n, - (user: string, ch: Challenge) => api.sendChallenge(user, ch.challenge_id), - (success, user, ch) => { - if (success.body.earliest_retransmission) { - setRetransmission({ - ...retransmission, - [ch.tan_channel]: AbsoluteTime.now(), - // AbsoluteTime.fromProtocolTimestamp( - // success.body.earliest_retransmission, - // ), - }); - } - setSelected({ - ch, - expiration: !success.body.solve_expiration - ? AbsoluteTime.never() - : AbsoluteTime.fromProtocolTimestamp(success.body.solve_expiration), - }); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Unauthorized: - return i18n.str`Failed 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.`; - } - }, + safeFunctionHandler((ch: Challenge) => + api.sendChallenge(username, ch.challenge_id), ), ); + sendMessage.onSuccess = (success, ch) => { + if (success.body.earliest_retransmission) { + setRetransmission({ + ...retransmission, + [ch.tan_channel]: AbsoluteTime.fromProtocolTimestamp( + success.body.earliest_retransmission, + ), + }); + } + setSelected({ + ch, + expiration: !success.body.solve_expiration + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(success.body.solve_expiration), + }); + }; - const doComplete = !hasSolvedEnough - ? undefined - : async () => notifyOnError(onCompleted)(solved); + sendMessage.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Failed 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.`; + } + }; + + const doComplete = notifyOnError(onCompleted.withArgs(solved)); return ( <Fragment> @@ -377,19 +372,23 @@ export function SolveMFAChallenges({ hasSolvedEnough || solved.indexOf(challenge.challenge_id) !== -1; - const doSelect = noNeedToComplete - ? undefined - : async () => { + const doSelect = notifyOnError( + safeFunctionHandler( + async () => { setSelected({ ch: challenge, expiration: AbsoluteTime.never(), }); - }; + return opEmptySuccess(); + }, + noNeedToComplete ? undefined : [], + ), + ); const doSend = - noNeedToComplete || !username - ? undefined - : () => sendMessage(username, challenge); + alreadySent || noNeedToComplete + ? sendMessage + : sendMessage.withArgs(challenge); return ( <div class="rounded-xl border px-2 my-2"> @@ -430,7 +429,7 @@ export function SolveMFAChallenges({ type="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={alreadySent ? undefined : doSend} + onClick={doSend} > <i18n.Translate>Send me a message</i18n.Translate> </ButtonBetter> diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -18,7 +18,8 @@ import { AmountJson, Amounts, HttpStatusCode, - PaytoUri, + PaytoType, + Paytos, TalerErrorCode, WithdrawUriResult, assertUnreachable, @@ -27,29 +28,22 @@ import { Attention, ButtonBetter, LocalNotificationBanner, - makeSafeCall, - notifyInfo, - MfaHandler, - safeFunctionCall, + safeFunctionHandler, useBankCoreApiContext, useChallengeHandler, useLocalNotificationBetter, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { mutate } from "swr"; -import { usePreferences } from "../hooks/preferences.js"; import { LoggedIn, useSessionState } from "../hooks/session.js"; import { LoginForm } from "./LoginForm.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; import { SolveMFAChallenges } from "./SolveMFA.js"; -import { Paytos } from "@gnu-taler/taler-util"; -import { PaytoType } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 114; interface Props { - onAborted: () => void; withdrawUri: WithdrawUriResult; details: { account: Paytos.URI; @@ -58,21 +52,11 @@ interface Props { amount?: AmountJson; }; } -/** - * Additional authentication required to complete the operation. - * Not providing a back button, only abort. - */ -export function WithdrawalConfirmationQuestion({ - onAborted, - details, - withdrawUri, -}: Props): VNode { - const { i18n } = useTranslationContext(); - const [settings] = usePreferences(); + +function useComponentState(opid: string) { const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [notification, notifyOnError] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); const { @@ -85,127 +69,95 @@ export function WithdrawalConfirmationQuestion({ ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); - /** - * Continue here: - * - * Make the useNotification hook take "handler|undefine" - * to cover the most common use case. - * - * Usenotification hook also should take the new SafeHandler interface - * - * safeFunctionCall should also include the default error handler that shows - * the generic erros (talererror and http errors) - */ - // const fn = safeFunctionCall( - // (mfa: MfaHandler, creds: LoggedIn, opId: string) => - // api.confirmWithdrawalById(creds, {}, opId, mfa), - // ); - - // fn.onSuccess = () => { - // mutate(() => true); // clean any info that we have - // if (!settings.showWithdrawalSuccess) { - // notifyInfo(i18n.str`Wire transfer completed!`); - // } - // }; + const confirm = safeFunctionHandler( + (creds: LoggedIn, challengeIds: string[]) => + api.confirmWithdrawalById(creds, {}, opid, { + challengeIds, + }), + !creds ? undefined : [creds, []], + ); - // fn.onFail = (fail, mfa, a, b) => { - // switch (fail.case) { - // case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - // return i18n.str`The withdrawal has been aborted previously and can't be confirmed`; - // case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - // return i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`; - // case HttpStatusCode.BadRequest: - // return i18n.str`The operation ID is invalid.`; - // case HttpStatusCode.NotFound: - // return i18n.str`The operation was not found.`; - // case TalerErrorCode.BANK_UNALLOWED_DEBIT: - // return i18n.str`Your balance is not sufficient for the operation.`; - // case TalerErrorCode.BANK_AMOUNT_DIFFERS: - // return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; - // case TalerErrorCode.BANK_AMOUNT_REQUIRED: - // return i18n.str`The bank requires a bank account which has not been specified yet.`; - // case HttpStatusCode.Accepted: { - // mfa.onChallengeRequired(fail.body); - // return i18n.str`A second factor authentication is required.`; - // } - // } - // }; + confirm.onSuccess = () => { + mutate(() => true); // clean any info that we have + }; - // const [doConfirm2, repeatConfirm2] = mfa.addMfaHandler( fn ) + const repeat = confirm.lambda((ids: string[]) => { + return [confirm.args![0], ids]; + }); - const [doConfirm, repeatConfirm] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - makeSafeCall( - i18n, - (creds: LoggedIn, opId: string) => - api.confirmWithdrawalById(creds, {}, opId, { challengeIds }), - (success) => { - mutate(() => true); // clean any info that we have - if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`); - } - }, - (fail) => { - switch (fail.case) { - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return i18n.str`The withdrawal has been aborted previously and can't be confirmed`; - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`; - case HttpStatusCode.BadRequest: - return i18n.str`The operation ID is invalid.`; - case HttpStatusCode.NotFound: - return i18n.str`The operation was not found.`; - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return i18n.str`Your balance is not sufficient for the operation.`; - case TalerErrorCode.BANK_AMOUNT_DIFFERS: - return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; - case TalerErrorCode.BANK_AMOUNT_REQUIRED: - return i18n.str`The bank requires a bank account which has not been specified yet.`; - case HttpStatusCode.Accepted: { - onChallengeRequired(fail.body); - return i18n.str`A second factor authentication is required.`; - } - } - }, - ), + const abort = safeFunctionHandler( + api.abortWithdrawalById, + !creds ? undefined : [creds, opid], ); - const confirmHandler = !creds - ? undefined - : () => notifyOnError(doConfirm)(creds, withdrawUri.withdrawalOperationId); + const spec = config.currency_specification; + + return { + mfa, + wireFee, + spec, + abort, + confirm, + repeat, + }; +} + +/** + * Additional authentication required to complete the operation. + * Not providing a back button, only abort. + */ +export function WithdrawalConfirmationQuestion({ + details, + withdrawUri, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const { mfa, wireFee, spec, abort, confirm, repeat } = + useComponentState(withdrawUri.withdrawalOperationId); + + const [notification, saveNotification, defaultUnexpectedFailureMessages] = useLocalNotificationBetter(); + confirm.onUnexpectedFailure = defaultUnexpectedFailureMessages; - const abortHandler = !creds - ? undefined - : notifyOnError(() => - makeSafeCall( - i18n, - (creds, opId) => api.abortWithdrawalById(creds, opId), - (success) => { - onAborted(); - }, - (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.`; - default: { - assertUnreachable(fail); - } - } - }, - )(creds, withdrawUri.withdrawalOperationId), - ); + confirm.onFail = saveNotification((fail) => { + switch (fail.case) { + case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: + return i18n.str`The withdrawal has been aborted previously and can't be confirmed`; + case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: + return i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`; + case HttpStatusCode.BadRequest: + return i18n.str`The operation ID is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`The operation was not found.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Your balance is not sufficient for the operation.`; + case TalerErrorCode.BANK_AMOUNT_DIFFERS: + return i18n.str`The starting withdrawal amount and the confirmation amount differs.`; + case TalerErrorCode.BANK_AMOUNT_REQUIRED: + return i18n.str`The bank requires a bank account which has not been specified yet.`; + case HttpStatusCode.Accepted: { + mfa.onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + } + }); - if (mfa.pendingChallenge && repeatConfirm) { + abort.onFail = saveNotification((fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str``; + case HttpStatusCode.NotFound: + return i18n.str`The withdrawal operation has been aborted.`; + case HttpStatusCode.Conflict: + return i18n.str`The withdrawal operation has been confirmed previously and can’t be aborted.`; + } + }); + + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} description={i18n.str`Complete withdrawal.`} onCancel={mfa.doCancelChallenge} - onCompleted={repeatConfirm} + onCompleted={repeat} username={details.username} /> ); @@ -395,7 +347,7 @@ export function WithdrawalConfirmationQuestion({ {details.amount !== undefined ? ( <RenderAmount value={details.amount} - spec={config.currency_specification} + spec={spec} /> ) : ( <i18n.Translate> @@ -415,7 +367,7 @@ export function WithdrawalConfirmationQuestion({ value={wireFee} negative withColor - spec={config.currency_specification} + spec={spec} /> </dd> </div> @@ -431,7 +383,7 @@ export function WithdrawalConfirmationQuestion({ type="button" name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={abortHandler} + onClick={abort} > <i18n.Translate>Cancel</i18n.Translate> </ButtonBetter> @@ -439,7 +391,7 @@ export function WithdrawalConfirmationQuestion({ 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={confirmHandler} + onClick={confirm} > <i18n.Translate>Transfer</i18n.Translate> </ButtonBetter> diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -111,7 +111,7 @@ export function ShowAccountDetails({ } const [doUpdate, repeatUpdate] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => + ({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, (creds: LoggedIn, account: TalerCorebankApi.AccountReconfiguration) => diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -102,7 +102,7 @@ export function UpdateAccountPassword({ const [doUpdatePassword, repeatUpdatePassword] = !token || !request || !!errors ? [undefined, undefined] - : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => + : mfa.withMfaHandler(({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, () => diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -132,7 +132,7 @@ export function RemoveAccount({ const [doDelete, repeatDelete] = !token || !!errors ? [undefined, undefined] - : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => + : mfa.withMfaHandler(({ ids: challengeIds, onChallengeRequired }) => makeSafeCall( i18n, () => diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -33,8 +33,9 @@ import { LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, - makeSafeCall, notifyInfo, + repeatLastCall, + safeFunctionHandler, useBankCoreApiContext, useChallengeHandler, useLocalNotificationBetter, @@ -62,6 +63,7 @@ import { import { SolveMFAChallenges } from "../SolveMFA.js"; import { TalerCorebankApi } from "@gnu-taler/taler-util"; import { Paytos } from "@gnu-taler/taler-util"; +import { opFixedSuccess } from "@gnu-taler/taler-util"; const TALER_SCREEN_ID = 127; @@ -266,34 +268,33 @@ function CreateCashoutInternal({ : true; const notZero = Amounts.isNonZero(inputAmount); - const conversionCalculator = makeSafeCall( - i18n, - (isDebit: boolean, input: AmountJson, fee: AmountJson) => - isDebit - ? calculateFromDebit(input, fee) - : calculateFromCredit(input, fee), - (success) => { - setCalculation(success.body); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Conflict: - return i18n.str`The amount is too small.`; - case HttpStatusCode.BadRequest: - return i18n.str`Server didn't like our request`; - case HttpStatusCode.NotImplemented: - return i18n.str`Conversion is not enabled.`; + const conversionCalculator = safeFunctionHandler( + async (isDebit: boolean, input: AmountJson, fee: AmountJson) => { + if (notZero && higerThanMin) { + return isDebit + ? calculateFromDebit(input, fee) + : calculateFromCredit(input, fee); + } else { + return opFixedSuccess(zeroCalc); } }, ); + conversionCalculator.onSuccess = (success) => setCalculation(success.body); + conversionCalculator.args = [form.isDebit ?? false, inputAmount, sellFee]; + conversionCalculator.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Conflict: + return i18n.str`The amount is too small.`; + case HttpStatusCode.BadRequest: + return i18n.str`Server didn't like our request`; + case HttpStatusCode.NotImplemented: + return i18n.str`Conversion is not enabled.`; + } + }; useEffect(() => { async function doAsync() { - if (notZero && higerThanMin) { - await conversionCalculator(form.isDebit ?? false, inputAmount, sellFee); - } else { - setCalculation(zeroCalc); - } + await conversionCalculator.call(); } doAsync(); }, [form.amount, form.isDebit, notZero, higerThanMin, rate.cashout_fee]); @@ -336,81 +337,76 @@ function CreateCashoutInternal({ }); const trimmedAmountStr = form.amount?.trim(); - const [createCashout, repeatCashout] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - makeSafeCall( - i18n, - (calc: TransCalc, subject: string) => - api.createCashout( - session, - { - request_uid: RANDOM_STRING, - amount_credit: Amounts.stringify(calc.credit), - amount_debit: Amounts.stringify(calc.debit), - subject, - }, - { challengeIds }, - ), - (success) => { - notifyInfo(i18n.str`Cashout created`); - onCashout(); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Accepted: { - // updateBankState("currentChallenge", { - // operation: "create-cashout", - // id: String(resp.body.challenge_id), - // sent: AbsoluteTime.never(), - // request, - // }); - onChallengeRequired(fail.body); - return i18n.str`Second factor authentication required.`; - } - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return i18n.str`Duplicated 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.`; - } - } - }, - ), + const challengeIds = mfa.pendingChallenge?.challenges.map( + (d) => d.challenge_id, ); - const subject = form.subject; - const cashoutHandler = - !!errors || !subject - ? undefined - : () => notifyOnError(createCashout)(calc, subject); + + const apiCashout = safeFunctionHandler((calc: TransCalc, subject: string) => + api.createCashout( + session, + { + request_uid: RANDOM_STRING, + amount_credit: Amounts.stringify(calc.credit), + amount_debit: Amounts.stringify(calc.debit), + subject, + }, + { challengeIds }, + ), + ); + apiCashout.onSuccess = (success) => { + notifyInfo(i18n.str`Cashout created`); + onCashout(); + }; + apiCashout.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Accepted: { + mfa.onChallengeRequired(fail.body); + return i18n.str`Second factor authentication required.`; + } + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: + return i18n.str`Duplicated 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.`; + } + } + }; + + apiCashout.args = !errors && subject ? [calc, subject] : undefined; + + const cashoutHandler = notifyOnError(apiCashout); + const repeatCashout = repeatLastCall(apiCashout); const cashoutDisabled = !accountData.cashout_payto_uri; const cashoutAccount = !accountData.cashout_payto_uri ? undefined : Paytos.fromString(accountData.cashout_payto_uri); - const cashoutAccountName = !cashoutAccount || cashoutAccount.type === "fail" - ? undefined - : cashoutAccount.body.displayName; + const cashoutAccountName = + !cashoutAccount || cashoutAccount.type === "fail" + ? undefined + : cashoutAccount.body.displayName; - const cashoutLegalName = !cashoutAccount || cashoutAccount.type === "fail" - ? undefined - : cashoutAccount.body.params["receiver-name"]; + const cashoutLegalName = + !cashoutAccount || cashoutAccount.type === "fail" + ? undefined + : cashoutAccount.body.params["receiver-name"]; - if (mfa.pendingChallenge && repeatCashout) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} @@ -646,25 +642,6 @@ function CreateCashoutInternal({ {i18n.str`Amount`} <b class="text-[red]"> *</b> </label> - {/* <button - type="button" - data-enabled={form.isDebit} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - form.isDebit = !form.isDebit; - updateForm(structuredClone(form)); - }} - > - <span - aria-hidden="true" - data-enabled={form.isDebit} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> */} </div> <div class="mt-2"> <InputAmount diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx @@ -16,7 +16,7 @@ import { Fragment, VNode, h } from "preact"; import { HTMLAttributes, useState } from "preact/compat"; -import { useTranslationContext } from "../index.browser.js"; +import { SafeHandlerTemplate, useTranslationContext } from "../index.browser.js"; export interface ButtonHandler { @@ -70,7 +70,7 @@ export function Button({ type PropsBetter = Omit<HTMLAttributes<HTMLButtonElement>, "onClick"> & { - onClick: (() => Promise<void>) | undefined + onClick: SafeHandlerTemplate<any, void, any> | undefined } /** * FIXME: removed deprecated and change for this one @@ -83,7 +83,6 @@ export function ButtonBetter({ onClick, ...rest }: PropsBetter): VNode { - const { i18n } = useTranslationContext(); const [running, setRunning] = useState(false); return ( <button @@ -91,11 +90,11 @@ export function ButtonBetter({ disabled={disabled || running || !onClick} onClick={(e) => { e.preventDefault(); - if (!onClick) { + if (!onClick || !onClick.args) { return; } setRunning(true); - onClick().finally(() => { + onClick.call().finally(() => { setRunning(false); }); }} diff --git a/packages/web-util/src/hooks/useChallenge.ts b/packages/web-util/src/hooks/useChallenge.ts @@ -19,20 +19,22 @@ */ import { ChallengeResponse } from "@gnu-taler/taler-util"; -import { useCallback, useRef, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; +import { SafeHandlerTemplate } from "./useNotifications.js"; /** * State of the current MFA operation and handler to manage * the state and retry. * */ -export interface MfaState { +export interface MfaState<Errors> { /** * If a mfa has been started this will contain * the challenge response. */ pendingChallenge: ChallengeResponse | undefined; + onChallengeRequired: (c: ChallengeResponse) => void; /** * Cancel the current pending challenge. * @@ -40,38 +42,6 @@ export interface MfaState { */ doCancelChallenge: () => void; - /** - * Similar to withMfaHandler. - * Take a function that expects an MfaHanlder on the first argument. - * Returns the original function for the first call, another to repeat with the new credentials - * and the list of pending challenges. - * @param d - * @returns - */ - addMfaHandler: <Type extends Array<any>, R>( - fn: (mfa: MfaHandler, ...args: Type) => Promise<R>, - ) => [ - (...args: Type) => Promise<R>, // function for the first call - (newChallenges: string[]) => Promise<R>, // function to repeat with new chIds - Type, - ]; - - /** - * Similar to addMfaHandler. - * Takes a function that receive an MFA handler all execute business logic that - * may require a MFA. - * Returns the original function for the first call, another to repeat with the new credentials - * and the list of pending challenges. - * @param builder - * @returns - */ - withMfaHandler: <Type extends Array<any>, R>( - builder: CallbackFactory<Type, R>, - ) => [ - (...args: Type) => Promise<R>, // function for the first call - (newChallenges: string[]) => Promise<R>, // function to repeat with new chIds - Type, - ]; } /** @@ -90,7 +60,7 @@ export interface MfaHandler { * Challenges that are already solved and can be used for the operation. * If this is undefined it may mean that it is the first call. */ - challengeIds: string[] | undefined; + ids: string[] | undefined; } /** @@ -101,94 +71,23 @@ type CallbackFactory<T extends any[], R> = ( ) => (...args: T) => Promise<R>; /** - * Take a function that may require MFA and return and MfaState - * to solve the MFA challenges. - * - * - * @param cf A function that receives MfaHandler with callback and solved challenges and returns a the function to be guarded. * @returns */ -export function useChallengeHandler(): MfaState { - const [current, onChallengeRequired] = useState<ChallengeResponse>(); - const ref = useRef<any[]>([]); - let exeOrder = 0; +export function useChallengeHandler<T>(): MfaState<T> { + const [current, setCurrent] = useState<{challenge: ChallengeResponse, action: SafeHandlerTemplate<any, void, T>}>(); - /** - * This have the same machanism that useEffect, needs to be called always on order - * @param builder - * @returns - */ - function withMfaHandler<T extends any[], R>( - builder: CallbackFactory<T, R>, - ): [ - ReturnType<CallbackFactory<T, R>>, - (newChallenges: string[]) => Promise<R>, - T, - ] { - const thisIdx = exeOrder; - exeOrder = exeOrder + 1; - - async function saveArgsAndProceed(...currentArgs: T): Promise<R> { - ref.current[thisIdx] = currentArgs; - return builder({ - challengeIds: undefined, - onChallengeRequired, - })(...currentArgs); - } - - 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, - })(...ref.current[thisIdx]); - } - - return [saveArgsAndProceed, repeatCall, ref.current[thisIdx]]; - } - - function addMfaHandler<T extends any[], R>( - fn: (mfa: MfaHandler, ...args: T) => Promise<R>, - ): [ - ReturnType<CallbackFactory<T, R>>, - (newChallenges: string[]) => Promise<R>, - T, - ] { - const thisIdx = exeOrder; - exeOrder = exeOrder + 1; - - async function saveArgsAndProceed(...currentArgs: T): Promise<R> { - ref.current[thisIdx] = currentArgs; - const mfa = { - challengeIds: undefined, - onChallengeRequired, - }; - return fn(mfa, ...currentArgs); - } - - async function repeatCall(challengeIds: string[]): Promise<R> { - if (!ref.current[thisIdx]) - throw Error("calling repeat function without doing the first call"); - const mfa = { - challengeIds, - onChallengeRequired, - }; - return fn(mfa, ...ref.current[thisIdx]); - } - - return [saveArgsAndProceed, repeatCall, ref.current[thisIdx]]; + function reset() { + setCurrent(undefined); } - function reset() { - onChallengeRequired(undefined); + function onChallengeRequired(c: ChallengeResponse, action: SafeHandlerTemplate<any, void, T>) { + setCurrent({challenge: c, action}) } return { - withMfaHandler, - addMfaHandler, doCancelChallenge: reset, - pendingChallenge: current, + onChallengeRequired, + pendingChallenge: current?.challenge, + repeatAction, }; } diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -149,558 +149,244 @@ function hash(msg: NotificationMessage): string { return hashCode(str); } -function errorMap<T extends OperationFail<unknown>>( - resp: T, - map: (d: T["case"]) => TranslatedString, -): void { - notify({ - type: "error", - title: map(resp.case), - description: (resp.detail?.hint as TranslatedString) ?? "", - debug: resp.detail, - when: AbsoluteTime.now(), - }); -} - -export type ErrorNotificationHandler = ( - cb: (notify: typeof errorMap) => Promise<void>, -) => Promise<void>; - -/** - * @deprecated use useLocalNotificationHandler - * - * @returns - */ -export function useLocalNotification(): [ - Notification | undefined, - (n: NotificationMessage) => void, - ErrorNotificationHandler, -] { - const { i18n } = useTranslationContext(); - - const [value, setter] = useState<NotificationMessage>(); - const notif = !value - ? undefined - : { - message: value, - acknowledge: () => { - setter(undefined); - }, - }; - - async function errorHandling(cb: (notify: typeof errorMap) => Promise<void>) { - try { - return await cb(errorMap); - } catch (error: unknown) { - if (error instanceof TalerError) { - notify(buildUnifiedRequestErrorMessage(i18n, error)); - } else { - notifyError( - i18n.str`Operation failed, please report`, - (error instanceof Error - ? error.message - : JSON.stringify(error)) as TranslatedString, - ); - } - } - } - return [notif, setter, errorHandling]; -} - -type HandlerMaker = <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, -) => ButtonHandler; - -/** - * @deprecated use useLocalNotificationBetter - * @returns - */ -export function useLocalNotificationHandler(): [ - Notification | undefined, - HandlerMaker, - (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, - ): ButtonHandler { - const onNotification = setter; - return { - onClick: 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, +function translateTalerError( cause: TalerError, -): ErrorNotification { - let result: ErrorNotification; + i18n: InternationalizationAPI, +): TranslatedString { switch (cause.errorDetail.code) { case TalerErrorCode.GENERIC_TIMEOUT: { - result = { - type: "error", - title: i18n.str`Request timeout`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Request timeout`; } case TalerErrorCode.GENERIC_CLIENT_INTERNAL_ERROR: { - result = { - type: "error", - title: i18n.str`Request cancelled`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Request cancelled`; } case TalerErrorCode.WALLET_HTTP_REQUEST_GENERIC_TIMEOUT: { - result = { - type: "error", - title: i18n.str`Request timeout`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Request timeout`; } case TalerErrorCode.WALLET_HTTP_REQUEST_THROTTLED: { - result = { - type: "error", - title: i18n.str`Request throttled`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Request throttled`; } case TalerErrorCode.WALLET_RECEIVED_MALFORMED_RESPONSE: { - result = { - type: "error", - title: i18n.str`Malformed response`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Malformed response`; } case TalerErrorCode.WALLET_NETWORK_ERROR: { - result = { - type: "error", - title: i18n.str`Network error`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Network error`; } case TalerErrorCode.WALLET_UNEXPECTED_REQUEST_ERROR: { - result = { - type: "error", - title: i18n.str`Unexpected request error`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Unexpected request error`; } default: { - result = { - type: "error", - title: i18n.str`Unexpected error`, - description: cause.message as TranslatedString, - debug: cause.errorDetail, - when: AbsoluteTime.now(), - }; - break; + return i18n.str`Unexpected error`; } } - return result; } +/** + * 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>; -type FunctionWrapperForButton = <T extends any[]>( +type FunctionWrapperForButton = <T extends any[], R>( fn: FunctionThatMayFail<T>, -) => (...args: T) => Promise<void>; +) => FunctionThatMayFail<T>; +type ReplaceReturnType<T extends (...a: any) => any, TNewReturn> = ( + ...a: Parameters<T> +) => TNewReturn; + +/** + * Initialize a notification handler. + * @returns a tuple of notifiaction 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, - FunctionWrapperForButton, + <T extends OnOperationFailReturnType<any, any>>( + f: ReplaceReturnType<T, TranslatedString>, + ) => T, + () => (cause: unknown) => void, ] { - const [value, setter] = useState<NotificationMessage>(); + const { i18n } = useTranslationContext(); + const [value, save] = useState<NotificationMessage>(); const notif = !value ? undefined : { message: value, acknowledge: () => { - setter(undefined); + save(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); + function wrap<T extends OnOperationFailReturnType<any, any>>( + fn: ReplaceReturnType<T, TranslatedString>, + ): T { + const asd = (...args: Parameters<T>) => { + const errors = fn(...args); + if (errors) { + save(failWithTitle(args[0], errors)); } + return errors; }; + return asd as any; } - 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>, -): FunctionThatMayFail<K> { - 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); - + function onUnexpected(): (cause: unknown) => void { + return (error) => { if (error instanceof TalerError) { - return buildUnifiedRequestErrorMessage(i18n, error); + save({ + title: translateTalerError(error, i18n), + type: "error", + description: + error && error.errorDetail.hint + ? (error.errorDetail.hint as TranslatedString) + : undefined, + debug: error, + when: AbsoluteTime.now(), + }); } else { const description = ( error instanceof Error ? error.message : String(error) ) as TranslatedString; - return { + save({ title: i18n.str`Operation failed`, type: "error", description, + debug: error, when: AbsoluteTime.now(), - }; + }); } - } - }; + }; + } + + return [notif, wrap, onUnexpected]; } -export interface SafeHandler< - K extends any[], - T extends OperationResult<A, B>, - A, - B, -> { - (...args: K): Promise<NotificationMessage | undefined>; - onSuccess: OnOperationSuccesReturnType<T, K>; - onFail: OnOperationFailReturnType<T, K>; - onUnexpectedFailure: OnOperationUnexpectedFailReturnType<K>; +/** + * A function converted into a safe handler. + * + * + */ +export interface SafeHandlerTemplate<Args extends any[], Errors> { + readonly args: Args | undefined; + /** + * call the action with the arguments + */ + call(): Promise<void>; + /** + * creates another handler for the same actions but different arguments + * @param e + */ + lambda<OtherArgs extends any[]>( + e: (...d: OtherArgs) => Args, + ): SafeHandlerTemplate<OtherArgs, Error>; + /** + * creates another handler with new arguements + * @param args + */ + withArgs(...args: Args): SafeHandlerTemplate<Args, Error>; + + onSuccess: OnOperationSuccesReturnType<Errors, Args>; + onFail: OnOperationFailReturnType<Errors, Args>; + onUnexpectedFailure: OnOperationUnexpectedFailReturnType; } +export function failWithTitle( + fail: OperationFail<any>, + title: TranslatedString, +): NotificationMessage { + const d = fail.detail; + return { + title, + type: "error", + description: d && d.hint ? (d.hint as TranslatedString) : undefined, + debug: d, + when: AbsoluteTime.now(), + }; +} /** * Convert an function that return an operation into a function that return * a notification if it fail. * * @returns */ -export function safeFunctionCall< - K extends any[], - T extends OperationResult<A, B>, - A, - B, ->(doAction: (...args: K) => Promise<T>): SafeHandler<K, T, A, B> { - const handler = (async ( - ...args: K - ): Promise<NotificationMessage | undefined> => { - try { - const resp = await doAction(...args); - switch (resp.type) { - case "ok": { - const result: OperationOk<any> = resp; - const msg = handler.onSuccess(result as any, ...args); - if (msg) { - notifyInfo(msg); +export function safeFunctionHandler< + Args extends any[], + R extends OperationResult<any, any>, +>( + doAction: (...args: Args) => Promise<R>, + args?: Args, +): SafeHandlerTemplate<Args, R> { + const handler: SafeHandlerTemplate<Args, R> = { + args, + withArgs(...newArgs) { + return { + ...handler, + args: newArgs, + }; + }, + lambda(converter) { + type D = Parameters<typeof converter>; + type R = SafeHandlerTemplate<D, Error>; + const r = { + ...handler, + args: undefined, + withArgs(...args: D) { + const d = converter(...args); + const e = handler.withArgs(...d); + return e; + }, + }; + return r as any as R; + }, + call: async (): Promise<void> => { + if (!handler.args) return; + try { + const resp = await doAction(...handler.args); + switch (resp.type) { + case "ok": { + handler.onSuccess(resp as any, ...handler.args); + return; + } + case "fail": { + handler.onFail(resp as any, ...handler.args); + return; + } + default: { + assertUnreachable(resp); } - return undefined; - } - case "fail": { - const d = "detail" in resp ? resp.detail : undefined; - const title = handler.onFail(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) { + // This functions should not throw, this is a problem. + console.error(`Error: `, error); + handler.onUnexpectedFailure(error); + return; } - } catch (error: unknown) { - // This functions should not throw, this is a problem. - console.error(`Error: `, error); - - if (error instanceof TalerError) { - return { - title: handler.onUnexpectedFailure(error, ...args), - type: "error", - description: - error && error.errorDetail.hint - ? (error.errorDetail.hint as TranslatedString) - : undefined, - debug: error, - when: AbsoluteTime.now(), - }; - } else { - const description = ( - error instanceof Error ? error.message : String(error) - ) as TranslatedString; - - return { - title: `Operation failed` as TranslatedString, - type: "error", - description, - when: AbsoluteTime.now(), - }; - } - } - }) as SafeHandler<K, T, A, B>; - handler.onFail = () => "<unhandled failure>" as TranslatedString; - handler.onSuccess = () => {}; - handler.onUnexpectedFailure = () => - "<unhandled unexpected failure>" as TranslatedString; + }, + onFail: () => undefined, + onSuccess: () => {}, + onUnexpectedFailure: () => + "<unhandled unexpected failure>" as TranslatedString, + }; return handler; } export type OnOperationSuccesReturnType<T, K extends any[]> = ( result: T extends OperationOk<any> ? T : never, ...args: K -) => TranslatedString | void; +) => 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; +) => void; -export type OnOperationUnexpectedFailReturnType<K extends any[]> = ( - e: TalerError, - ...args: K -) => TranslatedString; +export type OnOperationUnexpectedFailReturnType = (e: unknown) => void;