taler-typescript-core

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

commit 3840861e3c9a2cd392ce9522755b88dddff75ad9
parent 4359c2a9da120afca3901e04baa27403bdc7cafe
Author: Sebastian <sebasjm@gmail.com>
Date:   Tue, 23 Sep 2025 12:30:07 -0300

fix #10250

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 11+++++------
Mpackages/bank-ui/src/Routing.tsx | 236++++++++++++++++++++++++-------------------------------------------------------
Mpackages/bank-ui/src/hooks/bank-state.ts | 17++++++++---------
Mpackages/bank-ui/src/hooks/regional.ts | 75++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpackages/bank-ui/src/pages/AccountPage/index.ts | 8+++-----
Mpackages/bank-ui/src/pages/AccountPage/state.ts | 10++++------
Mpackages/bank-ui/src/pages/AccountPage/views.tsx | 52----------------------------------------------------
Mpackages/bank-ui/src/pages/ConversionRateClassDetails.tsx | 30++++++++++++++++++++++++++----
Mpackages/bank-ui/src/pages/LoginForm.tsx | 114++++++++++++++++++++++++++++++++++++++++---------------------------------------
Mpackages/bank-ui/src/pages/OperationState/index.ts | 26++++++++------------------
Mpackages/bank-ui/src/pages/OperationState/state.ts | 78++++++++++++++++++++++++++++++++++--------------------------------------------
Mpackages/bank-ui/src/pages/OperationState/views.tsx | 303++++++++++++++++++++++++++++++-------------------------------------------------
Mpackages/bank-ui/src/pages/PaymentOptions.tsx | 9++++-----
Mpackages/bank-ui/src/pages/PaytoWireTransferForm.tsx | 259+++++++++++++++++++++++++++++++++----------------------------------------------
Mpackages/bank-ui/src/pages/QrCodeSection.tsx | 3+--
Mpackages/bank-ui/src/pages/SolveChallengePage.tsx | 1822++++++++++++++++++++++++++++++++++++++++----------------------------------------
Apackages/bank-ui/src/pages/SolveMFA.tsx | 571+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/bank-ui/src/pages/WalletWithdrawForm.tsx | 10++++------
Mpackages/bank-ui/src/pages/WireTransfer.tsx | 17++++-------------
Mpackages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx | 260++++++++++++++++++++++++++-----------------------------------------------------
Mpackages/bank-ui/src/pages/WithdrawalOperationPage.tsx | 4+---
Mpackages/bank-ui/src/pages/WithdrawalQRCode.tsx | 4----
Mpackages/bank-ui/src/pages/account/CashoutListForAccount.tsx | 8++------
Mpackages/bank-ui/src/pages/account/ShowAccountDetails.tsx | 209+++++++++++++++++++++++++------------------------------------------------------
Mpackages/bank-ui/src/pages/account/UpdateAccountPassword.tsx | 175+++++++++++++++++++++++++++++--------------------------------------------------
Mpackages/bank-ui/src/pages/admin/AdminHome.tsx | 8++------
Mpackages/bank-ui/src/pages/admin/RemoveAccount.tsx | 133++++++++++++++++++++++++++++++++-----------------------------------------------
Mpackages/bank-ui/src/pages/regional/ConversionConfig.tsx | 25++++++++++++++++++++++---
Mpackages/bank-ui/src/pages/regional/CreateCashout.tsx | 259+++++++++++++++++++++++++++++++++++--------------------------------------------
Mpackages/bank-ui/src/settings.json | 8++++----
Mpackages/merchant-backoffice-ui/src/components/SolveMFA.tsx | 43+++++++++++++++++++------------------------
Dpackages/merchant-backoffice-ui/src/hooks/challenge.ts | 144-------------------------------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/admin/create/index.tsx | 31++++++++++++++++---------------
Mpackages/merchant-backoffice-ui/src/paths/admin/list/index.tsx | 15+++++++++------
Mpackages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx | 17+++++++++++------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx | 11++++++-----
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx | 18++++++++++++------
Mpackages/merchant-backoffice-ui/src/paths/instance/password/index.tsx | 20++++++++++++--------
Mpackages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx | 17++++++++++-------
Mpackages/merchant-backoffice-ui/src/paths/instance/update/index.tsx | 20++++++++++++--------
Mpackages/merchant-backoffice-ui/src/paths/login/index.tsx | 22+++++++++++-----------
Mpackages/merchant-backoffice-ui/src/paths/newAccount/index.tsx | 28+++++++++++-----------------
Mpackages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx | 17+++++++++--------
Mpackages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts | 9++++-----
Mpackages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts | 2+-
Mpackages/taler-util/src/http-client/bank-core.ts | 246++++++++++++++++++++++++++++++++++---------------------------------------------
Mpackages/taler-util/src/http-client/merchant.ts | 11+++++++----
Mpackages/taler-util/src/types-taler-corebank.ts | 40++--------------------------------------
Mpackages/taler-util/src/types-taler-merchant.ts | 14+++++++-------
Mpackages/web-util/src/components/Button.tsx | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/web-util/src/hooks/index.ts | 1+
Apackages/web-util/src/hooks/useChallenge.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/hooks/useNotifications.ts | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
53 files changed, 2958 insertions(+), 2856 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx @@ -19,6 +19,7 @@ import { assertUnreachable, HttpStatusCode, MeasureInformation, + opEmptySuccess, opFixedSuccess, parsePaytoUri, PaytoString, @@ -167,7 +168,7 @@ export function Summary({ }; return lib.exchange.makeAmlDesicion(session, request); }, - () => { + (suc) => { clearUp(); }, (fail) => { @@ -190,13 +191,11 @@ export function Summary({ const requiresConfirmation = MROS_REPORT_COMPLETED; if (submitHandler && requiresConfirmation) { - const originalHandler = submitHandler.onClick; - submitHandler.onClick = async function d(): ReturnType< - typeof submitHandler.onClick - > { + const originalHandler = submitHandler.onClick!; + submitHandler.onClick = async function d() { if (!submitConfirmation) { setSubmitConfirmation(true); - return opFixedSuccess(undefined); + return; } return originalHandler(); }; diff --git a/packages/bank-ui/src/Routing.tsx b/packages/bank-ui/src/Routing.tsx @@ -18,8 +18,10 @@ import { LocalNotificationBanner, urlPattern, useBankCoreApiContext, + useChallengeHandler, useCurrentLocation, useLocalNotification, + useLocalNotificationBetter, useNavigationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -47,7 +49,6 @@ import { LoginForm, SESSION_DURATION } from "./pages/LoginForm.js"; import { PublicHistoriesPage } from "./pages/PublicHistoriesPage.js"; import { RegistrationPage } from "./pages/RegistrationPage.js"; import { ShowNotifications } from "./pages/ShowNotifications.js"; -import { SolveChallengePage } from "./pages/SolveChallengePage.js"; import { WireTransfer } from "./pages/WireTransfer.js"; import { WithdrawalOperationPage } from "./pages/WithdrawalOperationPage.js"; import { CashoutListForAccount } from "./pages/account/CashoutListForAccount.js"; @@ -62,6 +63,8 @@ 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; @@ -102,7 +105,6 @@ const publicPages = { /\/operation\/(?<wopid>[a-zA-Z0-9-]+)/, ({ wopid }) => `#/operation/${wopid}`, ), - solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"), }; function PublicRounting({ @@ -117,10 +119,10 @@ function PublicRounting({ const { i18n } = useTranslationContext(); const location = useCurrentLocation(publicPages); const { navigateTo } = useNavigationContext(); - const [, updateBankState] = useBankState(); const { config, lib } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); useEffect(() => { if (location === undefined) { @@ -128,81 +130,71 @@ function PublicRounting({ } }, [location]); - async function doAutomaticLogin(username: string, password: string) { - await handleError(async () => { - const tokenRequest = { - scope: "readwrite", - duration: SESSION_DURATION, - refreshable: true, - } as TokenRequest; - const resp = await lib.bank.createAccessToken( - username, - { type: "basic", password }, - tokenRequest, - ); - if (resp.type === "ok") { - onLoggedUser( - username, - createRFC8959AccessTokenEncoded(resp.body.access_token), - AbsoluteTime.fromProtocolTimestamp(resp.body.expiration), - ); - } else { - switch (resp.case) { - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "login", - id: String(resp.body.challenge_id), - location: privatePages.home.url({}), - sent: AbsoluteTime.never(), - request: { - tokenRequest, - username, - password, - }, - }); - return; - } - case HttpStatusCode.Unauthorized: { - return notify({ - type: "error", - title: i18n.str`Wrong credentials for "${username}"`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.GENERIC_FORBIDDEN: { - return notify({ - type: "error", - title: i18n.str`You have no permission to this account.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_ACCOUNT_LOCKED: { - return notify({ - type: "error", - title: i18n.str`This account is locked. If you have a active session you can change the password or contact the administrator.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case HttpStatusCode.NotFound: { - return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + const tokenRequest = { + scope: "readwrite", + duration: SESSION_DURATION, + refreshable: true, + } as TokenRequest; + + const [doAutomaticLogin, repeatLogin] = mfa.withMfaHandler( + ({ challengeIds, onChallengeRequired }) => + withErrorHandler( + (username: string, password: string) => + lib.bank.createAccessToken( + username, + { type: "basic", password }, + tokenRequest, + { challengeIds }, + ), + (success, username) => { + onLoggedUser( + username, + createRFC8959AccessTokenEncoded(success.body.access_token), + AbsoluteTime.fromProtocolTimestamp(success.body.expiration), + ); + }, + (fail, username) => { + switch (fail.case) { + case HttpStatusCode.Accepted: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case TalerErrorCode.GENERIC_FORBIDDEN: + return i18n.str`You have no permission to this account.`; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + return i18n.str`This account is locked. If you have a active session you can change the password or contact the administrator.`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; } - default: - assertUnreachable(resp); - } - } - }); + }, + ), + ); + + if (mfa.pendingChallenge && repeatLogin) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + description={i18n.str`New web session`} + // currentChallenge={{ + // challenges: [ + // { + // challenge_id: "123", + // tan_channel: TanChannel.EMAIL, + // tan_info: "asd@qwe.com" + // }, + // { + // challenge_id: "123", + // tan_channel: TanChannel.SMS, + // tan_info: "+54 89798" + // }], + // combi_and: false + // }} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatLogin} + /> + ); } switch (location.name) { @@ -213,12 +205,7 @@ function PublicRounting({ <div class="sm:mx-auto sm:w-full sm:max-w-sm"> <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to ${config.bank_name}!`}</h2> </div> - <LoginForm - routeRegister={publicPages.register} - onAuthorizationRequired={() => - navigateTo(publicPages.solveSecondFactor.url({})) - } - /> + <LoginForm routeRegister={publicPages.register} /> </Fragment> ); } @@ -233,9 +220,6 @@ function PublicRounting({ origin="from-wallet-ui" onOperationAborted={() => navigateTo(publicPages.login.url({}))} routeClose={publicPages.login} - onAuthorizationRequired={() => - navigateTo(publicPages.solveSecondFactor.url({})) - } /> ); } @@ -250,14 +234,6 @@ function PublicRounting({ </Fragment> ); } - case "solveSecondFactor": { - return ( - <SolveChallengePage - onChallengeCompleted={() => navigateTo(publicPages.login.url({}))} - routeClose={publicPages.login} - /> - ); - } default: assertUnreachable(location); } @@ -275,7 +251,6 @@ const privatePages = { }>(/\/account\/wire-transfer/, () => "#/account/wire-transfer"), home: urlPattern(/\/account/, () => "#/account"), notifications: urlPattern(/\/notifications/, () => "#/notifications"), - solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"), cashoutCreate: urlPattern(/\/new-cashout/, () => "#/new-cashout"), cashoutDetails: urlPattern<{ cid: string }>( /\/cashout\/(?<cid>[a-zA-Z0-9]+)/, @@ -358,9 +333,6 @@ function PrivateRouting({ origin="from-wallet-ui" onOperationAborted={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } /> ); } @@ -372,17 +344,6 @@ function PrivateRouting({ origin="from-bank-ui" onOperationAborted={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } - /> - ); - } - case "solveSecondFactor": { - return ( - <SolveChallengePage - onChallengeCompleted={() => navigateTo(privatePages.home.url({}))} - routeClose={privatePages.home} /> ); } @@ -405,15 +366,11 @@ function PrivateRouting({ <ShowAccountDetails account={location.values.account} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - routeHere={privatePages.accountDetails} routeMyAccountCashout={privatePages.myAccountCashouts} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeClose={privatePages.home} /> ); @@ -423,16 +380,12 @@ function PrivateRouting({ <UpdateAccountPassword focus account={location.values.account} - routeHere={privatePages.accountChangePassword} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} routeMyAccountCashout={privatePages.myAccountCashouts} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeClose={privatePages.home} /> ); @@ -441,11 +394,7 @@ function PrivateRouting({ return ( <RemoveAccount account={location.values.account} - routeHere={privatePages.accountDelete} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeCancel={privatePages.home} /> ); @@ -454,7 +403,6 @@ function PrivateRouting({ return ( <CashoutListForAccount account={location.values.account} - routeCreateCashout={privatePages.cashoutCreate} routeCashoutDetails={privatePages.cashoutDetails} routeClose={privatePages.home} routeMyAccountCashout={privatePages.myAccountCashouts} @@ -463,9 +411,6 @@ function PrivateRouting({ routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} onCashout={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } /> ); } @@ -473,11 +418,7 @@ function PrivateRouting({ return ( <RemoveAccount account={username} - routeHere={privatePages.accountDelete} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeCancel={privatePages.home} /> ); @@ -486,16 +427,12 @@ function PrivateRouting({ return ( <ShowAccountDetails account={username} - routeHere={privatePages.accountDetails} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} routeMyAccountCashout={privatePages.myAccountCashouts} routeConversionConfig={privatePages.conversionConfig} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeClose={privatePages.home} /> ); @@ -505,16 +442,12 @@ function PrivateRouting({ <UpdateAccountPassword focus account={username} - routeHere={privatePages.accountChangePassword} onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} routeMyAccountCashout={privatePages.myAccountCashouts} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeClose={privatePages.home} /> ); @@ -524,16 +457,12 @@ function PrivateRouting({ <CashoutListForAccount account={username} routeCashoutDetails={privatePages.cashoutDetails} - routeCreateCashout={privatePages.cashoutCreate} routeMyAccountCashout={privatePages.myAccountCashouts} routeMyAccountDelete={privatePages.myAccountDelete} routeMyAccountDetails={privatePages.myAccountDetails} routeMyAccountPassword={privatePages.myAccountPassword} routeConversionConfig={privatePages.conversionConfig} onCashout={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeClose={privatePages.home} /> ); @@ -543,9 +472,6 @@ function PrivateRouting({ if (isAdmin) { return ( <AdminHome - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeCreateAccount={privatePages.accountCreate} routeRemoveAccount={privatePages.accountDelete} routeShowAccount={privatePages.accountDetails} @@ -571,13 +497,9 @@ function PrivateRouting({ routeOperationDetails={privatePages.startOperation} routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} - routeSolveSecondFactor={privatePages.solveSecondFactor} routeCashout={privatePages.myAccountCashouts} routeClose={privatePages.home} onClose={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } onOperationCreated={(wopid) => navigateTo(privatePages.startOperation.url({ wopid })) } @@ -588,10 +510,6 @@ function PrivateRouting({ return ( <CreateCashout account={username} - routeHere={privatePages.cashoutCreate} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } onCashout={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} /> @@ -611,10 +529,6 @@ function PrivateRouting({ toAccount={location.values.account} withAmount={location.values.amount} withSubject={location.values.subject} - routeHere={privatePages.wireTranserCreate} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } routeCancel={privatePages.home} onSuccess={() => navigateTo(privatePages.home.url({}))} /> @@ -631,12 +545,8 @@ function PrivateRouting({ routePublicAccounts={privatePages.publicAccountList} routeOperationDetails={privatePages.startOperation} routeCashout={privatePages.myAccountCashouts} - routeSolveSecondFactor={privatePages.solveSecondFactor} routeClose={privatePages.home} onClose={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } onOperationCreated={(wopid) => navigateTo(privatePages.startOperation.url({ wopid })) } @@ -668,13 +578,9 @@ function PrivateRouting({ routeCreateWireTransfer={privatePages.wireTranserCreate} routePublicAccounts={privatePages.publicAccountList} routeOperationDetails={privatePages.startOperation} - routeSolveSecondFactor={privatePages.solveSecondFactor} routeCashout={privatePages.myAccountCashouts} routeClose={privatePages.home} onClose={() => navigateTo(privatePages.home.url({}))} - onAuthorizationRequired={() => - navigateTo(privatePages.solveSecondFactor.url({})) - } onOperationCreated={(wopid) => navigateTo(privatePages.startOperation.url({ wopid })) } diff --git a/packages/bank-ui/src/hooks/bank-state.ts b/packages/bank-ui/src/hooks/bank-state.ts @@ -33,7 +33,6 @@ import { useLocalStorage, } from "@gnu-taler/web-util/browser"; -const { codecForTanTransmission } = TalerCorebankApi; export type ChallengeInProgess = | LoginChallenge @@ -49,7 +48,7 @@ type BaseChallenge<OpType extends string, ReqType> = { operation: OpType; sent: AbsoluteTime; location: AppLocation | undefined; - info?: TalerCorebankApi.TanTransmission; + // info?: TalerCorebankApi.TanTransmission; request: ReqType; }; @@ -91,7 +90,7 @@ const codecForChallengeUpdatePassword = (): Codec<UpdatePasswordChallenge> => .property("id", codecForString()) .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) .build("UpdatePasswordChallenge"); @@ -102,7 +101,7 @@ const codecForChallengeDeleteAccount = (): Codec<DeleteAccountChallenge> => .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) .property("request", codecForString()) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .build("DeleteAccountChallenge"); const codecForChallengeUpdateAccount = (): Codec<UpdateAccountChallenge> => @@ -111,7 +110,7 @@ const codecForChallengeUpdateAccount = (): Codec<UpdateAccountChallenge> => .property("id", codecForString()) .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition .build("UpdateAccountChallenge"); @@ -122,7 +121,7 @@ const codecForChallengeCreateTransaction = .property("id", codecForString()) .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition .build("CreateTransactionChallenge"); @@ -133,7 +132,7 @@ const codecForChallengeConfirmWithdrawal = .property("id", codecForString()) .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition .build("ConfirmWithdrawalChallenge"); @@ -145,7 +144,7 @@ const codecForChallengeCashout = (): Codec<CashoutChallenge> => .property("id", codecForString()) .property("location", codecForAppLocation()) .property("sent", codecForAbsoluteTime) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition .build("CashoutChallenge"); @@ -155,7 +154,7 @@ const codecForLoginChallenge = (): Codec<LoginChallenge> => .property("id", codecForString()) .property("location", codecOptional(codecForAppLocation())) .property("sent", codecForAbsoluteTime) - .property("info", codecOptional(codecForTanTransmission())) + // .property("info", codecOptional(codecForTanTransmission())) .property("request", codecForAny()) //FIXME: complete definition .build("LoginChallenge"); diff --git a/packages/bank-ui/src/hooks/regional.ts b/packages/bank-ui/src/hooks/regional.ts @@ -38,21 +38,25 @@ import { PAGINATED_LIST_REQUEST } from "../utils.js"; import { buildPaginatedResult } from "./account.js"; import { TalerBankConversionHttpClient } from "@gnu-taler/taler-util"; import { assertUnreachable } from "@gnu-taler/taler-util"; +import { TalerBankConversionErrorsByMethod } from "@gnu-taler/taler-util"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 const useSWR = _useSWR as unknown as SWRHook; -export type TransferCalculation = - | { - debit: AmountJson; - credit: AmountJson; - beforeFee: AmountJson; - } - | "amount-is-too-small"; +export type TransCalc = { + debit: AmountJson; + credit: AmountJson; + beforeFee: AmountJson; +}; +export type TransferCalculation = TransCalc | "amount-is-too-small"; type EstimatorFunction = ( amount: AmountJson, fee: AmountJson, -) => Promise<TransferCalculation>; +) => Promise< + | OperationOk<TransCalc> + | TalerBankConversionErrorsByMethod<"getCashinRate"> + | TalerBankConversionErrorsByMethod<"getCashoutRate"> +>; type ConversionEstimators = { estimateByCredit: EstimatorFunction; @@ -171,38 +175,23 @@ function buildEstimatorWithTheBackend( } } if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.Conflict: { - return "amount-is-too-small"; - } - // this below can't happen - case HttpStatusCode.NotImplemented: //it should not be able to call this function - case HttpStatusCode.BadRequest: //we are using just one parameter - if (resp.detail) { - throw TalerError.fromUncheckedDetail(resp.detail); - } else { - throw TalerError.fromException( - new Error("failed to get conversion cashin rate"), - ); - } - } + return resp; } const credit = Amounts.parseOrThrow(resp.body.amount_credit); const debit = Amounts.parseOrThrow(resp.body.amount_debit); const beforeFee = Amounts.sub(credit, fee).amount; - return { + return opFixedSuccess({ debit, beforeFee, credit, - }; + }); }; } - function buildConversionEstimatorsWithTheBackend( conversion: TalerBankConversionHttpClient, - direction: "cashin" | "cashout" + direction: "cashin" | "cashout", ): ConversionEstimators { const { state } = useSessionState(); const token = state.status === "loggedIn" ? state.token : undefined; @@ -210,12 +199,16 @@ function buildConversionEstimatorsWithTheBackend( estimateByCredit: buildEstimatorWithTheBackend( conversion, token, - direction == "cashin" ? "cashin-rate-from-credit" : "cashout-rate-from-credit", + direction == "cashin" + ? "cashin-rate-from-credit" + : "cashout-rate-from-credit", ), estimateByDebit: buildEstimatorWithTheBackend( conversion, token, - direction == "cashin" ? "cashin-rate-from-debit" : "cashout-rate-from-debit", + direction == "cashin" + ? "cashin-rate-from-debit" + : "cashout-rate-from-debit", ), }; } @@ -225,14 +218,14 @@ export function useCashinEstimator(): ConversionEstimators { lib: { conversion }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversion, "cashin") + return buildConversionEstimatorsWithTheBackend(conversion, "cashin"); } export function useCashoutEstimator(): ConversionEstimators { const { lib: { conversion }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversion, "cashout") + return buildConversionEstimatorsWithTheBackend(conversion, "cashout"); } export function useCashinEstimatorForClass( @@ -241,7 +234,10 @@ export function useCashinEstimatorForClass( const { lib: { conversionForClass }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashin") + return buildConversionEstimatorsWithTheBackend( + conversionForClass(classId), + "cashin", + ); } export function useCashoutEstimatorForClass( @@ -250,7 +246,10 @@ export function useCashoutEstimatorForClass( const { lib: { conversionForClass }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversionForClass(classId), "cashout") + return buildConversionEstimatorsWithTheBackend( + conversionForClass(classId), + "cashout", + ); } export function useCashinEstimatorByUser( @@ -259,7 +258,10 @@ export function useCashinEstimatorByUser( const { lib: { conversionForUser }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashin") + return buildConversionEstimatorsWithTheBackend( + conversionForUser(username), + "cashin", + ); } export function useCashoutEstimatorByUser( @@ -268,7 +270,10 @@ export function useCashoutEstimatorByUser( const { lib: { conversionForUser }, } = useBankCoreApiContext(); - return buildConversionEstimatorsWithTheBackend(conversionForUser(username), "cashout") + return buildConversionEstimatorsWithTheBackend( + conversionForUser(username), + "cashout", + ); } export async function revalidateBusinessAccounts() { diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts @@ -29,7 +29,7 @@ import { InvalidIbanView, ReadyView } from "./views.js"; export interface Props { account: string; - onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; onClose: () => void; tab: "charge-wallet" | "wire-transfer" | undefined; @@ -48,7 +48,6 @@ export interface Props { amount?: string; }>; routeOperationDetails: RouteDefinition<{ wopid: string }>; - routeSolveSecondFactor: RouteDefinition; } export type State = @@ -80,7 +79,7 @@ export namespace State { tab: "charge-wallet" | "wire-transfer" | undefined; limit: AmountJson; balance: AmountJson; - onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; onClose: () => void; routeClose: RouteDefinition; @@ -98,7 +97,6 @@ export namespace State { amount?: string; }>; routeOperationDetails: RouteDefinition<{ wopid: string }>; - routeSolveSecondFactor: RouteDefinition; } export interface InvalidIban { @@ -110,7 +108,7 @@ export namespace State { status: "login"; reason: "not-found" | "forbidden"; routeRegister?: RouteDefinition; - onAuthorizationRequired: () => void; + } } diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts @@ -30,14 +30,13 @@ export function useComponentState({ routeChargeWallet, routeCreateWireTransfer, routePublicAccounts, - routeSolveSecondFactor, routeOperationDetails, routeWireTransfer, routeCashout, onOperationCreated, onClose, routeClose, - onAuthorizationRequired, + }: Props): State { const result = useAccountDetails(account); @@ -60,13 +59,13 @@ export function useComponentState({ case HttpStatusCode.Unauthorized: return { status: "login", - onAuthorizationRequired, + reason: "forbidden", }; case HttpStatusCode.NotFound: return { status: "login", - onAuthorizationRequired, + reason: "not-found", }; default: { @@ -111,8 +110,7 @@ export function useComponentState({ routeOperationDetails, routeCreateWireTransfer, routePublicAccounts, - routeSolveSecondFactor, - onAuthorizationRequired, + onClose, routeClose, routeChargeWallet, diff --git a/packages/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx @@ -75,54 +75,6 @@ function ShowDemoInfo({ ); } -function ShowPedingOperation({ - routeSolveSecondFactor, -}: { - routeSolveSecondFactor: RouteDefinition; -}): VNode { - const { i18n } = useTranslationContext(); - const [bankState, updateBankState] = useBankState(); - if (!bankState.currentChallenge) return <Fragment />; - const title = ((op): TranslatedString => { - switch (op) { - case "delete-account": - return i18n.str`Pending account delete operation`; - case "login": - return i18n.str`Pending login`; - case "update-account": - return i18n.str`Pending account update operation`; - case "update-password": - return i18n.str`Pending password update operation`; - case "create-transaction": - return i18n.str`Pending transaction operation`; - case "confirm-withdrawal": - return i18n.str`Pending withdrawal operation`; - case "create-cashout": - return i18n.str`Pending cashout operation`; - } - })(bankState.currentChallenge.operation); - return ( - <Attention - title={title} - type="warning" - onClose={() => { - updateBankState("currentChallenge", undefined); - }} - > - <i18n.Translate> - You can complete or cancel the operation in - </i18n.Translate>{" "} - <a - class="font-semibold text-yellow-700 hover:text-yellow-600" - name="complete operation" - href={routeSolveSecondFactor.url({})} - > - <i18n.Translate>this page</i18n.Translate> - </a> - </Attention> - ); -} - export function ReadyView({ tab, account, @@ -134,15 +86,12 @@ export function ReadyView({ routeCreateWireTransfer, routePublicAccounts, routeOperationDetails, - routeSolveSecondFactor, onClose, routeClose, onOperationCreated, - onAuthorizationRequired, }: State.Ready): VNode { return ( <Fragment> - <ShowPedingOperation routeSolveSecondFactor={routeSolveSecondFactor} /> <ShowDemoInfo routePublicAccounts={routePublicAccounts} /> <PaymentOptions tab={tab} @@ -155,7 +104,6 @@ export function ReadyView({ routeClose={routeClose} onClose={onClose} onOperationCreated={onOperationCreated} - onAuthorizationRequired={onAuthorizationRequired} /> <Transactions account={account} diff --git a/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx b/packages/bank-ui/src/pages/ConversionRateClassDetails.tsx @@ -806,16 +806,38 @@ function TestConversionClass({ classId }: { classId: number }): VNode { `${info.fiat_currency}:${amount}`, ); const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); - const cashin = await calculateCashinFromDebit(in_amount, in_fee); + const respCashin = await calculateCashinFromDebit(in_amount, in_fee); - if (cashin === "amount-is-too-small") { - setCalc(undefined); + const cashin = + respCashin.type === "ok" + ? respCashin.body + : respCashin.case === HttpStatusCode.Conflict + ? ("amount-is-too-small" as const) + : undefined; + + if (!cashin || cashin === "amount-is-too-small") { + setCalc(undefined); // silent failure return; } const out_fee = Amounts.parseOrThrow(info.conversion_rate.cashout_fee); - const cashout = await calculateCashoutFromDebit(cashin.credit, out_fee); + const respCashout = await calculateCashoutFromDebit( + cashin.credit, + out_fee, + ); + + const cashout = + respCashout.type === "ok" + ? respCashout.body + : respCashout.case === HttpStatusCode.Conflict + ? ("amount-is-too-small" as const) + : undefined; + if (!cashout) { + setCalc(undefined); // silent failure + return; + } + setCalc({ cashin, cashout }); }); } diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx @@ -26,6 +26,8 @@ import { RouteDefinition, ShowInputErrorLabel, useBankCoreApiContext, + useChallengeHandler, + useLocalNotificationBetter, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -38,6 +40,7 @@ import { USERNAME_REGEX } from "./RegistrationPage.js"; import { TalerErrorCode } from "@gnu-taler/taler-util"; import { useBankState } from "../hooks/bank-state.js"; import { TokenRequest } from "@gnu-taler/taler-util"; +import { SolveMFAChallenges } from "./SolveMFA.js"; const TALER_SCREEN_ID = 104; @@ -56,12 +59,12 @@ export function LoginForm({ currentUser, fixedUser, routeRegister, - onAuthorizationRequired, + // }: { fixedUser?: boolean; currentUser?: string; routeRegister?: RouteDefinition; - onAuthorizationRequired: () => void; + // }): VNode { const session = useSessionState(); @@ -75,11 +78,11 @@ export function LoginForm({ const [password, setPassword] = useState<string | undefined>(); const { i18n } = useTranslationContext(); const { - lib: { bank: authenticator }, + lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, withErrorHandler] = useLocalNotificationHandler(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const { config } = useBankCoreApiContext(); - const [, updateBankState] = useBankState(); const errors = undefinedIfEmpty({ username: !username @@ -92,10 +95,7 @@ export function LoginForm({ async function doLogout() { if (sessionState) { - authenticator.deleteAccessToken( - sessionState.username, - sessionState.token, - ); + await api.deleteAccessToken(sessionState.username, sessionState.token); } session.logOut(); } @@ -106,53 +106,55 @@ export function LoginForm({ refreshable: true, } as TokenRequest; - const loginHandler = - !username || !password - ? undefined - : withErrorHandler( - async () => - authenticator.createAccessToken( - username, - { type: "basic", password }, - tokenRequest, + const [doLogin, repeatLogin] = mfa.withMfaHandler( + ({ challengeIds, onChallengeRequired }) => + withErrorHandler( + (username: string, password: string) => + api.createAccessToken( + username, + { type: "basic", password }, + tokenRequest, + { challengeIds }, + ), + (result, username) => { + session.logIn({ + username, + token: createRFC8959AccessTokenEncoded(result.body.access_token), + expiration: AbsoluteTime.fromProtocolTimestamp( + result.body.expiration, ), - (result) => { - session.logIn({ - username, - token: createRFC8959AccessTokenEncoded(result.body.access_token), - expiration: AbsoluteTime.fromProtocolTimestamp( - result.body.expiration, - ), - }); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "login", - id: String(fail.body.challenge_id), - location: undefined, - sent: AbsoluteTime.never(), - request: { - tokenRequest, - username, - password, - }, - }); - onAuthorizationRequired(); - return i18n.str`Second factor authentication required.`; - } - case TalerErrorCode.GENERIC_FORBIDDEN: - return i18n.str`You have no permission to this account.`; - case TalerErrorCode.BANK_ACCOUNT_LOCKED: - return i18n.str`You have no permission to this account.`; - case HttpStatusCode.Unauthorized: - return i18n.str`Wrong credentials for "${username}"`; - case HttpStatusCode.NotFound: - return i18n.str`Account not found`; + }); + }, + (fail, username) => { + switch (fail.case) { + case HttpStatusCode.Accepted: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; } - }, - ); + case TalerErrorCode.GENERIC_FORBIDDEN: + return i18n.str`You have no permission to this account.`; + case TalerErrorCode.BANK_ACCOUNT_LOCKED: + return i18n.str`You have no permission to this account.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Wrong credentials for "${username}"`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + } + }, + ), + ); + const loginHandler = + !username || !password ? undefined : () => doLogin(username, password); + + if (mfa.pendingChallenge && repeatLogin) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatLogin} + /> + ); + } return ( <div class="flex min-h-full flex-col justify-center "> @@ -250,7 +252,7 @@ export function LoginForm({ 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={loginHandler} + handler={{ onClick: loginHandler }} > <i18n.Translate>Check</i18n.Translate> </Button> @@ -262,7 +264,7 @@ export function LoginForm({ 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={loginHandler} + handler={{ onClick: loginHandler }} > <i18n.Translate>Log in</i18n.Translate> </Button> diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts @@ -19,6 +19,7 @@ import { AmountJson, PaytoUri, TalerCoreBankErrorsByMethod, + TalerCoreBankHttpClient, TalerError, WithdrawUriResult, } from "@gnu-taler/taler-util"; @@ -39,11 +40,10 @@ import { export interface Props { currency: string; - onAuthorizationRequired: () => void; + routeClose: RouteDefinition; onAbort: () => void; focus?: boolean; - routeHere: RouteDefinition<{ wopid: string }>; } export type State = @@ -82,9 +82,8 @@ export namespace State { error: undefined; uri: WithdrawUriResult; focus?: boolean; - onAbort: () => Promise< - TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined - >; + onAbort: () => void; + operationId: string; routeClose: RouteDefinition; } @@ -105,19 +104,10 @@ export namespace State { } export interface NeedConfirmation { status: "need-confirmation"; - onAuthorizationRequired: () => void; + account: string; - routeHere: RouteDefinition<{ wopid: string }>; - onAbort: - | undefined - | (() => Promise< - TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined - >); - onConfirm: - | undefined - | (() => Promise< - TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined - >); + onAbort: () => void; + error: undefined; details: { account: PaytoUri; @@ -125,7 +115,7 @@ export namespace State { username: string; amount?: AmountJson; }; - id: string; + operationId: string; } export interface Aborted { status: "aborted"; diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts @@ -17,7 +17,6 @@ import { Amounts, HttpStatusCode, - OperationFail, TalerCoreBankErrorsByMethod, TalerCorebankApi, TalerError, @@ -28,7 +27,6 @@ import { } from "@gnu-taler/taler-util"; import { useBankCoreApiContext, utils } from "@gnu-taler/web-util/browser"; import { useEffect, useState } from "preact/hooks"; -import { mutate } from "swr"; import { useSettingsContext } from "../../context/settings.js"; import { useWithdrawalDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; @@ -41,8 +39,6 @@ export function useComponentState({ routeClose, onAbort, focus, - routeHere, - onAuthorizationRequired, }: Props): utils.RecursiveState<State> { const [preference] = usePreferences(); const settings = useSettingsContext(); @@ -102,35 +98,35 @@ export function useComponentState({ const wid = withdrawalOperationId; - async function doAbort(): Promise< - | OperationFail<HttpStatusCode.NotFound> - | OperationFail<HttpStatusCode.Conflict> - | OperationFail<HttpStatusCode.BadRequest> - | undefined - > { - if (!creds) return; - const resp = await bank.abortWithdrawalById(creds, wid); - if (resp.type === "ok") { - // updateBankState("currentWithdrawalOperationId", undefined) - onAbort(); - } else { - return resp; - } - return undefined; - } - - async function doConfirm(): Promise< - TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined - > { - if (!creds) return; - const resp = await bank.confirmWithdrawalById(creds, {}, wid); - if (resp.type === "ok") { - mutate(() => true); //clean withdrawal state - } else { - return resp; - } - return undefined; - } + // async function doAbort(): Promise< + // | OperationFail<HttpStatusCode.NotFound> + // | OperationFail<HttpStatusCode.Conflict> + // | OperationFail<HttpStatusCode.BadRequest> + // | undefined + // > { + // if (!creds) return; + // const resp = await bank.abortWithdrawalById(creds, wid); + // if (resp.type === "ok") { + // // updateBankState("currentWithdrawalOperationId", undefined) + // onAbort(); + // } else { + // return resp; + // } + // return undefined; + // } + + // async function doConfirm(): Promise< + // TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined + // > { + // if (!creds) return; + // const resp = await bank.confirmWithdrawalById(creds, {}, wid); + // if (resp.type === "ok") { + // mutate(() => true); //clean withdrawal state + // } else { + // return resp; + // } + // return undefined; + // } const uri = stringifyWithdrawUri({ bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href, @@ -216,12 +212,8 @@ export function useComponentState({ uri: parsedUri, routeClose, focus, - onAbort: !creds - ? async () => { - onAbort(); - return undefined; - } - : doAbort, + operationId: withdrawalOperationId, + onAbort, }; } @@ -248,18 +240,16 @@ export function useComponentState({ return { status: "need-confirmation", error: undefined, - routeHere, details: { account, reserve: data.selected_reserve_pub, username: data.username, amount: !data.amount ? undefined : Amounts.parse(data.amount), }, - onAuthorizationRequired, + account: data.username, - id: withdrawalOperationId, - onAbort: !creds ? undefined : doAbort, - onConfirm: !creds ? undefined : doConfirm, + operationId: withdrawalOperationId, + onAbort, }; }; } diff --git a/packages/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx @@ -15,32 +15,32 @@ */ import { - AbsoluteTime, Amounts, HttpStatusCode, - TalerCorebankApi, TalerErrorCode, - TranslatedString, assertUnreachable, stringifyWithdrawUri, } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, LocalNotificationBanner, notifyInfo, useBankCoreApiContext, - useLocalNotification, + useChallengeHandler, + useLocalNotificationBetter, useTalerWalletIntegrationAPI, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect } from "preact/hooks"; import { QR } from "../../components/QR.js"; -import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; +import { useSessionState } from "../../hooks/session.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; import { State } from "./index.js"; +import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 5; @@ -60,148 +60,91 @@ export function InvalidReserveView({ reserve }: State.InvalidReserve) { } export function NeedConfirmationView({ - onAbort: doAbort, - onConfirm: doConfirm, - routeHere, + onAbort, account, details, - id, - onAuthorizationRequired, + operationId, }: State.NeedConfirmation) { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); - const [notification, notify, errorHandler] = useLocalNotification(); - const [, updateBankState] = useBankState(); - const { config } = useBankCoreApiContext(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const mfa = useChallengeHandler(); + const { + config, + lib: { bank }, + } = useBankCoreApiContext(); const wireFee = config.wire_transfer_fees === undefined ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); - async function onCancel() { - errorHandler(async () => { - if (!doAbort) return; - const resp = await doAbort(); - if (!resp) return; - switch (resp.case) { - case HttpStatusCode.Conflict: - return notify({ - type: "error", - title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation ID is invalid.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - default: - assertUnreachable(resp); - } - }); - } + 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.`; + } + }, + ); + + const [doConfirm, repeatConfirm] = !creds + ? [undefined, undefined] + : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => + withErrorHandler( + () => + bank.confirmWithdrawalById(creds, {}, operationId, { + challengeIds, + }), + (suc) => { + 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 HttpStatusCode.Accepted: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + 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.`; + } + }, + ), + ); - async function onConfirm() { - errorHandler(async () => { - if (!doConfirm) return; - const request: TalerCorebankApi.BankAccountConfirmWithdrawalRequest & { - id: string; - } = { - id, - }; - const resp = await doConfirm(); - if (!resp) { - if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`); - } - return; - } - switch (resp.case) { - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return notify({ - type: "error", - title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return notify({ - type: "error", - title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation ID is invalid.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return notify({ - type: "error", - title: i18n.str`Your balance is not sufficient for the operation.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "confirm-withdrawal", - id: String(resp.body.challenge_id), - sent: AbsoluteTime.never(), - location: routeHere.url({ wopid: id }), - request, - }); - return onAuthorizationRequired(); - } - case TalerErrorCode.BANK_AMOUNT_DIFFERS: { - return notify({ - type: "error", - title: i18n.str`The starting withdrawal amount and the confirmation amount differs.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_AMOUNT_REQUIRED: { - return notify({ - type: "error", - title: i18n.str`The bank requires a bank account which has not been specified yet.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - default: - assertUnreachable(resp); - } - }); + if (mfa.pendingChallenge && repeatConfirm) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatConfirm} + /> + ); } return ( @@ -212,10 +155,7 @@ export function NeedConfirmationView({ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> </h3> <div class="mt-3 text-sm leading-6"> - <ShouldBeSameUser - username={account} - onAuthorizationRequired={onAuthorizationRequired} - > + <ShouldBeSameUser username={account}> <form class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" autoCapitalize="none" @@ -418,28 +358,22 @@ export function NeedConfirmationView({ </div> </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <button + <ButtonBetter type="button" name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={(e) => { - e.preventDefault(); - onCancel(); - }} + onClick={doAbort} > <i18n.Translate>Cancel</i18n.Translate> - </button> - <button + </ButtonBetter> + <ButtonBetter 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={(e) => { - e.preventDefault(); - onConfirm(); - }} + onClick={doConfirm} > <i18n.Translate>Transfer</i18n.Translate> - </button> + </ButtonBetter> </div> </form> </ShouldBeSameUser> @@ -584,51 +518,42 @@ export function ConfirmedView({ routeClose }: State.Confirmed) { export function ReadyView({ uri, focus, - onAbort: doAbort, + onAbort, + operationId, }: State.Ready): VNode { const { i18n } = useTranslationContext(); const walletInegrationApi = useTalerWalletIntegrationAPI(); - const [notification, notify, errorHandler] = useLocalNotification(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const { state: credentials } = useSessionState(); + const creds = credentials.status !== "loggedIn" ? undefined : credentials; + const { + config, + lib: { bank }, + } = useBankCoreApiContext(); const talerWithdrawUri = stringifyWithdrawUri(uri); useEffect(() => { walletInegrationApi.publishTalerAction(uri); }, []); - async function onAbort() { - errorHandler(async () => { - const hasError = await doAbort(); - if (!hasError) return; - switch (hasError.case) { - case HttpStatusCode.Conflict: - return notify({ - type: "error", - title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, - description: hasError.detail?.hint as TranslatedString, - debug: hasError.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation ID is invalid.`, - description: hasError.detail?.hint as TranslatedString, - debug: hasError.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: hasError.detail?.hint as TranslatedString, - debug: hasError.detail, - when: AbsoluteTime.now(), - }); - default: - assertUnreachable(hasError); - } - }); - } + 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.`; + } + }, + ); return ( <Fragment> @@ -702,7 +627,7 @@ export function ReadyView({ // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" class="text-sm font-semibold leading-6 text-gray-900" // handler={onAbortHandler} - onClick={onAbort} + onClick={doAbort} > <i18n.Translate>Cancel</i18n.Translate> </button> diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx @@ -69,7 +69,7 @@ export interface PaymentOptionProps { limit: AmountJson; balance: AmountJson; tab: "charge-wallet" | "wire-transfer" | undefined; - onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; onClose: () => void; @@ -99,7 +99,7 @@ export function PaymentOptions({ onOperationCreated, onClose, routeOperationDetails, - onAuthorizationRequired, + }: PaymentOptionProps): VNode { const { i18n } = useTranslationContext(); @@ -213,7 +213,7 @@ export function PaymentOptions({ focus limit={limit} balance={balance} - onAuthorizationRequired={onAuthorizationRequired} + onOperationCreated={onOperationCreated} onOperationAborted={onClose} routeCancel={routeClose} @@ -222,10 +222,9 @@ export function PaymentOptions({ {tab === "wire-transfer" && ( <PaytoWireTransferForm focus - routeHere={routeWireTransfer} limit={limit} balance={balance} - onAuthorizationRequired={onAuthorizationRequired} + onSuccess={onClose} routeCashout={routeCashout} routeCancel={routeClose} diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx @@ -15,7 +15,6 @@ */ import { - AbsoluteTime, AmountJson, AmountString, Amounts, @@ -33,21 +32,24 @@ import { stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { + Button, InternationalizationAPI, LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, notifyInfo, useBankCoreApiContext, - useLocalNotification, + useChallengeHandler, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { mutate } from "swr"; import { useBankState } from "../hooks/bank-state.js"; -import { useSessionState } from "../hooks/session.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; @@ -57,14 +59,8 @@ export interface Props { withSubject?: string; withAmount?: string; onSuccess: () => void; - onAuthorizationRequired: () => void; routeCancel?: RouteDefinition; routeCashout?: RouteDefinition; - routeHere: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; limit: AmountJson; balance: AmountJson; } @@ -77,8 +73,6 @@ export function PaytoWireTransferForm({ onSuccess, routeCancel, routeCashout, - routeHere, - onAuthorizationRequired, limit, balance, }: Props): VNode { @@ -97,7 +91,6 @@ export function PaytoWireTransferForm({ const [account, setAccount] = useState<string | undefined>(withAccount); const [subject, setSubject] = useState<string | undefined>(withSubject); const [amount, setAmount] = useState<string | undefined>(withAmount); - const [, updateBankState] = useBankState(); const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( undefined, @@ -106,7 +99,8 @@ export function PaytoWireTransferForm({ const trimmedAmountStr = amount?.trim(); const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const paytoType = config.wire_type === "X_TALER_BANK" @@ -150,15 +144,14 @@ export function PaytoWireTransferForm({ : validateRawPayto(parsed, limitWithFee, url.host, i18n, paytoType), }); - async function doSend() { - let payto_uri: PaytoString | undefined; - let sendingAmount: AmountString | undefined; + let payto_uri: PaytoString | undefined; + let sendingAmount: AmountString | undefined; - if (credentials.status !== "loggedIn") return; - let acName: string | undefined; - if (isRawPayto) { - const p = parsePaytoUri(rawPaytoInput!); - if (!p) return; + let acName: string | undefined; + if (isRawPayto) { + const p = parsePaytoUri(rawPaytoInput!); + + if (p) { sendingAmount = p.params.amount as AmountString; delete p.params.amount; // if this payto is valid then it already have message @@ -177,132 +170,101 @@ export function PaytoWireTransferForm({ : p.targetType === "x-taler-bank" ? p.account : assertUnreachable(p); - } else { - if (!account || !subject) return; - let payto; - acName = account; - switch (paytoType) { - case "x-taler-bank": { - payto = buildPayto("x-taler-bank", url.host, account); - - break; - } - case "iban": { - payto = buildPayto("iban", account, undefined); - break; - } - default: - assertUnreachable(paytoType); + } + } else if (account && subject) { + // if (!account || !subject) return; + let payto; + acName = account; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto("x-taler-bank", url.host, account); + + break; } - - payto.params.message = encodeURIComponent(subject); - payto_uri = stringifyPaytoUri(payto); - sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString; + case "iban": { + payto = buildPayto("iban", account, undefined); + break; + } + default: + assertUnreachable(paytoType); } - const puri = payto_uri; - const sAmount = sendingAmount; - - await handleError(async function createTransactionHandleError() { - const request: TalerCorebankApi.CreateTransactionRequest = { - payto_uri: puri, - amount: sAmount, - }; - const resp = await api.createTransaction(credentials, request); - mutate(() => true); - if (resp.type === "fail") { - switch (resp.case) { - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`Not enough permission to complete the operation.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_ADMIN_CREDITOR: - return notify({ - type: "error", - title: i18n.str`The bank administrator cannot be the transfer creditor.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_UNKNOWN_CREDITOR: - return notify({ - type: "error", - title: i18n.str`The destination account "${ + + payto.params.message = encodeURIComponent(subject); + payto_uri = stringifyPaytoUri(payto); + sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString; + } + const puri = payto_uri; + const sAmount = sendingAmount; + + const request: TalerCorebankApi.CreateTransactionRequest | undefined = + !payto_uri + ? undefined + : { + payto_uri: puri!, + amount: sAmount, + }; + + type reqType = TalerCorebankApi.CreateTransactionRequest; + + const [doTransfer, repeatTransfer] = mfa.withMfaHandler( + ({ challengeIds, onChallengeRequired }) => + withErrorHandler( + (credentials: LoggedIn, request: reqType) => + api.createTransaction(credentials, request, { + challengeIds, + }), + (success) => { + notifyInfo(i18n.str`The wire transfer was successfully completed!`); + onSuccess(); + setAmount(undefined); + setAccount(undefined); + setSubject(undefined); + rawPaytoInputSetter(undefined); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`The request was invalid or the payto://-URI used unacceptable features.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Not enough permission to complete the operation.`; + case TalerErrorCode.BANK_ADMIN_CREDITOR: + return i18n.str`The bank administrator cannot be the transfer creditor.`; + case TalerErrorCode.BANK_UNKNOWN_CREDITOR: + return i18n.str`The destination account "${ acName ?? puri - }" was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_SAME_ACCOUNT: - return notify({ - type: "error", - title: i18n.str`The origin and the destination of the transfer can't be the same.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return notify({ - type: "error", - title: i18n.str`Your balance is not sufficient for the operation.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The origin account "${puri}" was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: { - return notify({ - type: "error", - title: i18n.str`The attempt to create the transaction has failed. Please try again.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "create-transaction", - id: String(resp.body.challenge_id), - location: routeHere.url({ - account: account ?? "", - amount, - subject, - }), - sent: AbsoluteTime.never(), - request, - }); - return onAuthorizationRequired(); + }" was not found.`; + case TalerErrorCode.BANK_SAME_ACCOUNT: + return i18n.str`The origin and the destination of the transfer can't be the same.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Your balance is not sufficient for the operation.`; + case HttpStatusCode.NotFound: + return i18n.str`The origin account "${puri}" was not found.`; + case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: { + return i18n.str`The attempt to create the transaction has failed. Please try again.`; + } + case HttpStatusCode.Accepted: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + default: + assertUnreachable(fail); } - default: - assertUnreachable(resp); - } - } - notifyInfo(i18n.str`The wire transfer was successfully completed!`); - onSuccess(); - setAmount(undefined); - setAccount(undefined); - setSubject(undefined); - rawPaytoInputSetter(undefined); - }); + }, + ), + ); + const sendHandler = + !request || credentials.status !== "loggedIn" + ? undefined + : () => doTransfer(credentials, request); + + if (mfa.pendingChallenge && repeatTransfer) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatTransfer} + /> + ); } return ( @@ -697,18 +659,15 @@ export function PaytoWireTransferForm({ ) : ( <div /> )} - <button + <Button 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} - onClick={(e) => { - e.preventDefault(); - doSend(); - }} + handler={{ onClick: sendHandler }} > <i18n.Translate>Send</i18n.Translate> - </button> + </Button> </div> <LocalNotificationBanner notification={notification} /> </form> diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx @@ -57,9 +57,8 @@ export function QrCodeSection({ lib: { bank: api }, } = useBankCoreApiContext(); - const onAbortHandler = handleError( + const onAbortHandler = !creds ? undefined : handleError( async () => { - if (!creds) return undefined; return api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId); }, onAborted, diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx @@ -1,938 +1,938 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. +// /* +// This file is part of GNU Taler +// (C) 2022-2024 Taler Systems S.A. - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. +// GNU Taler is free software; you can redistribute it and/or modify it under the +// terms of the GNU General Public License as published by the Free Software +// Foundation; either version 3, or (at your option) any later version. - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +// WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +// A PARTICULAR PURPOSE. See the GNU General Public License for more details. - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ +// You should have received a copy of the GNU General Public License along with +// GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +// */ -import { - AbsoluteTime, - Amounts, - Duration, - HttpStatusCode, - TalerCorebankApi, - TalerError, - TalerErrorCode, - TokenSuccessResponse, - TranslatedString, - UserAndPassword, - UserAndToken, - assertUnreachable, - createRFC8959AccessTokenEncoded, - parsePaytoUri, -} from "@gnu-taler/taler-util"; -import { - Attention, - Loading, - LocalNotificationBanner, - RouteDefinition, - ShowInputErrorLabel, - Time, - useBankCoreApiContext, - useLocalNotification, - useNavigationContext, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; -import { useWithdrawalDetails } from "../hooks/account.js"; -import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; -import { useConversionInfo } from "../hooks/regional.js"; -import { useSessionState } from "../hooks/session.js"; -import { undefinedIfEmpty } from "../utils.js"; -import { RenderAmount } from "./PaytoWireTransferForm.js"; -import { OperationNotFound } from "./WithdrawalQRCode.js"; +// import { +// AbsoluteTime, +// Amounts, +// Duration, +// HttpStatusCode, +// TalerCorebankApi, +// TalerError, +// TalerErrorCode, +// TokenSuccessResponse, +// TranslatedString, +// UserAndPassword, +// UserAndToken, +// assertUnreachable, +// createRFC8959AccessTokenEncoded, +// parsePaytoUri, +// } from "@gnu-taler/taler-util"; +// import { +// Attention, +// Loading, +// LocalNotificationBanner, +// RouteDefinition, +// ShowInputErrorLabel, +// Time, +// useBankCoreApiContext, +// useLocalNotification, +// useNavigationContext, +// useTranslationContext, +// } from "@gnu-taler/web-util/browser"; +// import { Fragment, VNode, h } from "preact"; +// import { useEffect, useState } from "preact/hooks"; +// import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +// import { useWithdrawalDetails } from "../hooks/account.js"; +// import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; +// import { useConversionInfo } from "../hooks/regional.js"; +// import { useSessionState } from "../hooks/session.js"; +// import { undefinedIfEmpty } from "../utils.js"; +// import { RenderAmount } from "./PaytoWireTransferForm.js"; +// import { OperationNotFound } from "./WithdrawalQRCode.js"; -const TALER_SCREEN_ID = 111; +// const TALER_SCREEN_ID = 111; -type CredsType = - | { type: "basic-operation"; password: UserAndPassword } - | { type: "token-operation"; token: UserAndToken }; +// type CredsType = +// | { type: "basic-operation"; password: UserAndPassword } +// | { type: "token-operation"; token: UserAndToken }; -const TAN_PREFIX = "T-"; -const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/; -export function SolveChallengePage({ - onChallengeCompleted, - routeClose, -}: { - onChallengeCompleted: () => void; - routeClose: RouteDefinition; -}): VNode { - const { - lib: { bank: api }, - } = useBankCoreApiContext(); - const { i18n } = useTranslationContext(); - const [bankState, updateBankState] = useBankState(); - const [code, setCode] = useState<string | undefined>(undefined); - const [notification, notify, handleError] = useLocalNotification(); - const { navigateTo } = useNavigationContext(); - const session = useSessionState(); - const userSession = - session.state.status !== "loggedIn" ? undefined : session.state; +// const TAN_PREFIX = "T-"; +// const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/; +// export function SolveChallengePage({ +// onChallengeCompleted, +// routeClose, +// }: { +// onChallengeCompleted: () => void; +// routeClose: RouteDefinition; +// }): VNode { +// const { +// lib: { bank: api }, +// } = useBankCoreApiContext(); +// const { i18n } = useTranslationContext(); +// const [bankState, updateBankState] = useBankState(); +// const [code, setCode] = useState<string | undefined>(undefined); +// const [notification, notify, handleError] = useLocalNotification(); +// const { navigateTo } = useNavigationContext(); +// const session = useSessionState(); +// const userSession = +// session.state.status !== "loggedIn" ? undefined : session.state; - if (!bankState.currentChallenge) { - return ( - <div> - <span>no challenge to solve </span> - <a - href={routeClose.url({})} - name="close" - class="inline-flex items-center 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-500" - > - <i18n.Translate>Continue</i18n.Translate> - </a> - </div> - ); - } +// if (!bankState.currentChallenge) { +// return ( +// <div> +// <span>no challenge to solve </span> +// <a +// href={routeClose.url({})} +// name="close" +// class="inline-flex items-center 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-500" +// > +// <i18n.Translate>Continue</i18n.Translate> +// </a> +// </div> +// ); +// } - const ch = bankState.currentChallenge; - const errors = undefinedIfEmpty({ - code: !code - ? i18n.str`Required` - : !TAN_REGEX.test(code) - ? i18n.str`Confirmation codes are numerical, possibly beginning with 'T-.'` - : undefined, - }); +// const ch = bankState.currentChallenge; +// const errors = undefinedIfEmpty({ +// code: !code +// ? i18n.str`Required` +// : !TAN_REGEX.test(code) +// ? i18n.str`Confirmation codes are numerical, possibly beginning with 'T-.'` +// : undefined, +// }); - const creds = - ch.operation === "login" - ? ({ - type: "basic-operation", - password: ch.request, - } as CredsType) - : userSession - ? ({ - type: "token-operation", - token: userSession, - } as CredsType) - : undefined; +// const creds = +// ch.operation === "login" +// ? ({ +// type: "basic-operation", +// password: ch.request, +// } as CredsType) +// : userSession +// ? ({ +// type: "token-operation", +// token: userSession, +// } as CredsType) +// : undefined; - async function startChallenge() { - if (!creds) return; - await handleError(async () => { - const resp = await (creds.type === "basic-operation" - ? api.sendLoginChallenge(creds.password, ch.id) - : api.sendChallenge(creds.token, ch.id)); +// async function startChallenge() { +// if (!creds) return; +// await handleError(async () => { +// const resp = await (creds.type === "basic-operation" +// ? api.sendLoginChallenge(creds.password, ch.id) +// : api.sendChallenge(creds.token, ch.id)); - if (resp.type === "ok") { - const newCh = structuredClone(ch); - newCh.sent = AbsoluteTime.now(); - newCh.info = resp.body; - updateBankState("currentChallenge", newCh); - } else { - const newCh = structuredClone(ch); - newCh.sent = AbsoluteTime.now(); - newCh.info = undefined; - updateBankState("currentChallenge", newCh); - switch (resp.case) { - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`No cashout was found. The cashout process has probably already been aborted.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`No cashout was found. The cashout process has probably already been aborted.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Forbidden: - return notify({ - type: "error", - title: i18n.str`You have no rights to complete the challenge.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.TooManyRequests: - return notify({ - type: "error", - title: i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return notify({ - type: "error", - title: i18n.str`No cashout was found. The cashout process has probably already been aborted.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - default: - assertUnreachable(resp); - } - } - }); - } +// if (resp.type === "ok") { +// const newCh = structuredClone(ch); +// newCh.sent = AbsoluteTime.now(); +// newCh.info = resp.body; +// updateBankState("currentChallenge", newCh); +// } else { +// const newCh = structuredClone(ch); +// newCh.sent = AbsoluteTime.now(); +// newCh.info = undefined; +// updateBankState("currentChallenge", newCh); +// switch (resp.case) { +// case HttpStatusCode.NotFound: +// return notify({ +// type: "error", +// title: i18n.str`No cashout was found. The cashout process has probably already been aborted.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case HttpStatusCode.Unauthorized: +// return notify({ +// type: "error", +// title: i18n.str`No cashout was found. The cashout process has probably already been aborted.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case HttpStatusCode.Forbidden: +// return notify({ +// type: "error", +// title: i18n.str`You have no rights to complete the challenge.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case HttpStatusCode.TooManyRequests: +// return notify({ +// type: "error", +// title: i18n.str`Too many challenges are active right now, you must wait or confirm current challenges.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: +// return notify({ +// type: "error", +// title: i18n.str`No cashout was found. The cashout process has probably already been aborted.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// default: +// assertUnreachable(resp); +// } +// } +// }); +// } - async function completeChallenge() { - if (!creds || !code) return; - const tan = code.toUpperCase().startsWith(TAN_PREFIX) - ? code.substring(TAN_PREFIX.length) - : code; - await handleError(async () => { - { - const resp = await (creds.type === "basic-operation" - ? api.confirmLoginChallenge(creds.password, ch.id, { tan }) - : api.confirmChallenge(creds.token, ch.id, { tan })); - if (resp.type === "fail") { - setCode(""); - switch (resp.case) { - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Challenge not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`This user is not authorized to complete this challenge.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.TooManyRequests: - return notify({ - type: "error", - title: i18n.str`Too many attempts, try another code.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: - return notify({ - type: "error", - title: i18n.str`The confirmation code is wrong, try again.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: - return notify({ - type: "error", - title: i18n.str`The operation expired.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - default: - assertUnreachable(resp); - } - } - } - { - const resp = await (async (ch: ChallengeInProgess) => { - switch (ch.operation) { - case "delete-account": { - if (!userSession) { - // this should not happen since creds is present - throw Error( - `Session lost after challenge completed and operation retry. Try again or report.`, - ); - } - return await api.deleteAccount(userSession, ch.id); - } - case "update-account": { - if (!userSession) { - // this should not happen since creds is present - throw Error( - `Session lost after challenge completed and operation retry. Try again or report.`, - ); - } - return await api.updateAccount(userSession, ch.request, ch.id); - } - case "update-password": { - if (!userSession) { - // this should not happen since creds is present - throw Error( - `Session lost after challenge completed and operation retry. Try again or report.`, - ); - } - return await api.updatePassword(userSession, ch.request, ch.id); - } - case "create-transaction": { - if (!userSession) { - // this should not happen since creds is present - throw Error( - `Session lost after challenge completed and operation retry. Try again or report.`, - ); - } - return await api.createTransaction( - userSession, - ch.request, - ch.id, - ); - } - case "confirm-withdrawal": { - if (!userSession) { - // this should not happen since creds is present - throw Error( - `Session lost after challenge completed and operation retry. Try again or report.`, - ); - } - return await api.confirmWithdrawalById( - userSession, - ch.request, - ch.id, - ); - } - case "create-cashout": { - if (!userSession) { - // this should not happen since creds is present - throw Error( - `Session lost after challenge completed and operation retry. Try again or report.`, - ); - } - return await api.createCashout(userSession, ch.request, ch.id); - } +// async function completeChallenge() { +// if (!creds || !code) return; +// const tan = code.toUpperCase().startsWith(TAN_PREFIX) +// ? code.substring(TAN_PREFIX.length) +// : code; +// await handleError(async () => { +// { +// const resp = await (creds.type === "basic-operation" +// ? api.confirmLoginChallenge(creds.password, ch.id, { tan }) +// : api.confirmChallenge(creds.token, ch.id, { tan })); +// if (resp.type === "fail") { +// setCode(""); +// switch (resp.case) { +// case HttpStatusCode.NotFound: +// return notify({ +// type: "error", +// title: i18n.str`Challenge not found.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case HttpStatusCode.Unauthorized: +// return notify({ +// type: "error", +// title: i18n.str`This user is not authorized to complete this challenge.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case HttpStatusCode.TooManyRequests: +// return notify({ +// type: "error", +// title: i18n.str`Too many attempts, try another code.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: +// return notify({ +// type: "error", +// title: i18n.str`The confirmation code is wrong, try again.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: +// return notify({ +// type: "error", +// title: i18n.str`The operation expired.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// default: +// assertUnreachable(resp); +// } +// } +// } +// { +// const resp = await (async (ch: ChallengeInProgess) => { +// switch (ch.operation) { +// case "delete-account": { +// if (!userSession) { +// // this should not happen since creds is present +// throw Error( +// `Session lost after challenge completed and operation retry. Try again or report.`, +// ); +// } +// return await api.deleteAccount(userSession, ch.id); +// } +// case "update-account": { +// if (!userSession) { +// // this should not happen since creds is present +// throw Error( +// `Session lost after challenge completed and operation retry. Try again or report.`, +// ); +// } +// return await api.updateAccount(userSession, ch.request, ch.id); +// } +// case "update-password": { +// if (!userSession) { +// // this should not happen since creds is present +// throw Error( +// `Session lost after challenge completed and operation retry. Try again or report.`, +// ); +// } +// return await api.updatePassword(userSession, ch.request, ch.id); +// } +// case "create-transaction": { +// if (!userSession) { +// // this should not happen since creds is present +// throw Error( +// `Session lost after challenge completed and operation retry. Try again or report.`, +// ); +// } +// return await api.createTransaction( +// userSession, +// ch.request, +// ch.id, +// ); +// } +// case "confirm-withdrawal": { +// if (!userSession) { +// // this should not happen since creds is present +// throw Error( +// `Session lost after challenge completed and operation retry. Try again or report.`, +// ); +// } +// return await api.confirmWithdrawalById( +// userSession, +// ch.request, +// ch.id, +// ); +// } +// case "create-cashout": { +// if (!userSession) { +// // this should not happen since creds is present +// throw Error( +// `Session lost after challenge completed and operation retry. Try again or report.`, +// ); +// } +// return await api.createCashout(userSession, ch.request, ch.id); +// } - case "login": { - const result = await api.createAccessTokenBasic( - ch.request.username, - ch.request.password, - ch.request.tokenRequest, - ch.id, - ); - if (result.type === "ok") { - const d = result.body as TokenSuccessResponse; - session.logIn({ - username: ch.request.username, - token: createRFC8959AccessTokenEncoded(d.access_token), - expiration: AbsoluteTime.fromProtocolTimestamp( - result.body.expiration, - ), - }); - } - return result; - } +// case "login": { +// const result = await api.createAccessTokenBasic( +// ch.request.username, +// ch.request.password, +// ch.request.tokenRequest, +// ch.id, +// ); +// if (result.type === "ok") { +// const d = result.body as TokenSuccessResponse; +// session.logIn({ +// username: ch.request.username, +// token: createRFC8959AccessTokenEncoded(d.access_token), +// expiration: AbsoluteTime.fromProtocolTimestamp( +// result.body.expiration, +// ), +// }); +// } +// return result; +// } - default: - assertUnreachable(ch); - } - })(ch); +// default: +// assertUnreachable(ch); +// } +// })(ch); - if (resp.type === "fail") { - if (resp.case !== HttpStatusCode.Accepted) { - return notify({ - type: "error", - title: i18n.str`The operation failed.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - // another challenge required, save the request and the ID - // @ts-expect-error no need to check the type of request, since it will be the same as the previous request - updateBankState("currentChallenge", { - operation: ch.operation, - id: String(resp.body.challenge_id), - location: ch.location, - sent: AbsoluteTime.never(), - request: ch.request, - }); - return notify({ - type: "info", - title: i18n.str`The operation needs another confirmation to complete.`, - when: AbsoluteTime.now(), - }); - } - updateBankState("currentChallenge", undefined); - return onChallengeCompleted(); - } - }); - } +// if (resp.type === "fail") { +// if (resp.case !== HttpStatusCode.Accepted) { +// return notify({ +// type: "error", +// title: i18n.str`The operation failed.`, +// description: resp.detail?.hint as TranslatedString, +// debug: resp.detail, +// when: AbsoluteTime.now(), +// }); +// } +// // another challenge required, save the request and the ID +// // @ts-expect-error no need to check the type of request, since it will be the same as the previous request +// updateBankState("currentChallenge", { +// operation: ch.operation, +// id: String(resp.body.challenge_id), +// location: ch.location, +// sent: AbsoluteTime.never(), +// request: ch.request, +// }); +// return notify({ +// type: "info", +// title: i18n.str`The operation needs another confirmation to complete.`, +// when: AbsoluteTime.now(), +// }); +// } +// updateBankState("currentChallenge", undefined); +// return onChallengeCompleted(); +// } +// }); +// } - return ( - <Fragment> - <LocalNotificationBanner notification={notification} /> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <span - class="text-sm text-black font-semibold leading-6 " - id="availability-label" - > - <i18n.Translate>Confirm the operation</i18n.Translate> - </span> - </h2> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - This operation is protected with second factor authentication. In - order to complete it we need to verify your identity using the - authentication channel you provided. - </i18n.Translate> - </p> - </div> +// return ( +// <Fragment> +// <LocalNotificationBanner notification={notification} /> + // <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + // <div class="px-4 sm:px-0"> + // <h2 class="text-base font-semibold leading-7 text-gray-900"> + // <span + // class="text-sm text-black font-semibold leading-6 " + // id="availability-label" + // > + // <i18n.Translate>Confirm the operation</i18n.Translate> + // </span> + // </h2> + // <p class="mt-2 text-sm text-gray-500"> + // <i18n.Translate> + // This operation is protected with second factor authentication. In + // order to complete it we need to verify your identity using the + // authentication channel you provided. + // </i18n.Translate> + // </p> + // </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> - <ChallengeDetails - challenge={bankState.currentChallenge} - onStart={startChallenge} - onCancel={() => { - updateBankState("currentChallenge", undefined); - navigateTo(ch.location ?? routeClose.url({})); - }} - /> - {ch.info && ( - <div class="mt-2"> - <form - class="bg-white shadow-sm ring-1 ring-gray-900/5" - autoCapitalize="none" - autoCorrect="off" - onSubmit={(e) => { - e.preventDefault(); - }} - > - <div class="px-4 py-4"> - <label for="withdraw-amount"> - <i18n.Translate>Enter the confirmation code</i18n.Translate> - </label> - <div class="mt-2"> - <div class="relative rounded-md shadow-sm"> - <input - type="text" - // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - aria-describedby="answer" - autoFocus - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={code ?? ""} - required - onPaste={(e) => { - e.preventDefault(); - const pasted = e.clipboardData?.getData("text/plain"); - if (!pasted) return; - if (pasted.toUpperCase().startsWith(TAN_PREFIX)) { - const sub = pasted.substring(TAN_PREFIX.length); - setCode(sub); - return; - } - setCode(pasted); - }} - name="answer" - id="answer" - autocomplete="off" - onChange={(e): void => { - setCode(e.currentTarget.value); - }} - /> - </div> - <ShowInputErrorLabel - message={errors?.code} - isDirty={code !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - {((ch: TalerCorebankApi.TanChannel): VNode => { - switch (ch) { - case TalerCorebankApi.TanChannel.SMS: - return ( - <i18n.Translate> - You should have received a code on your mobile - phone. - </i18n.Translate> - ); - case TalerCorebankApi.TanChannel.EMAIL: - return ( - <i18n.Translate> - You should have received a code in your email. - </i18n.Translate> - ); - default: - assertUnreachable(ch); - } - })(ch.info.tan_channel)} - </p> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - The confirmation code starts with "{TAN_PREFIX}" followed - by numbers. - </i18n.Translate> - </p> - </div> - <div class="flex items-center justify-between border-gray-900/10 px-4 py-4 "> - <div /> - <button - type="submit" - name="confirm" - 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={(e) => { - completeChallenge(); - e.preventDefault(); - }} - > - <i18n.Translate>Confirm</i18n.Translate> - </button> - </div> - </form> - </div> - )} - </div> - </div> - </Fragment> - ); -} + // <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> +// <ChallengeDetails +// challenge={bankState.currentChallenge} +// onStart={startChallenge} +// onCancel={() => { +// updateBankState("currentChallenge", undefined); +// navigateTo(ch.location ?? routeClose.url({})); +// }} +// /> +// {ch.info && ( +// <div class="mt-2"> +// <form +// class="bg-white shadow-sm ring-1 ring-gray-900/5" +// autoCapitalize="none" +// autoCorrect="off" +// onSubmit={(e) => { +// e.preventDefault(); +// }} +// > +// <div class="px-4 py-4"> +// <label for="withdraw-amount"> +// <i18n.Translate>Enter the confirmation code</i18n.Translate> +// </label> +// <div class="mt-2"> +// <div class="relative rounded-md shadow-sm"> +// <input +// type="text" +// // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" +// aria-describedby="answer" +// autoFocus +// class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" +// value={code ?? ""} +// required +// onPaste={(e) => { +// e.preventDefault(); +// const pasted = e.clipboardData?.getData("text/plain"); +// if (!pasted) return; +// if (pasted.toUpperCase().startsWith(TAN_PREFIX)) { +// const sub = pasted.substring(TAN_PREFIX.length); +// setCode(sub); +// return; +// } +// setCode(pasted); +// }} +// name="answer" +// id="answer" +// autocomplete="off" +// onChange={(e): void => { +// setCode(e.currentTarget.value); +// }} +// /> +// </div> +// <ShowInputErrorLabel +// message={errors?.code} +// isDirty={code !== undefined} +// /> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {((ch: TalerCorebankApi.TanChannel): VNode => { +// switch (ch) { +// case TalerCorebankApi.TanChannel.SMS: +// return ( +// <i18n.Translate> +// You should have received a code on your mobile +// phone. +// </i18n.Translate> +// ); +// case TalerCorebankApi.TanChannel.EMAIL: +// return ( +// <i18n.Translate> +// You should have received a code in your email. +// </i18n.Translate> +// ); +// default: +// assertUnreachable(ch); +// } +// })(ch.info.tan_channel)} +// </p> +// <p class="mt-2 text-sm text-gray-500"> +// <i18n.Translate> +// The confirmation code starts with "{TAN_PREFIX}" followed +// by numbers. +// </i18n.Translate> +// </p> +// </div> +// <div class="flex items-center justify-between border-gray-900/10 px-4 py-4 "> +// <div /> +// <button +// type="submit" +// name="confirm" +// 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={(e) => { +// completeChallenge(); +// e.preventDefault(); +// }} +// > +// <i18n.Translate>Confirm</i18n.Translate> +// </button> +// </div> +// </form> +// </div> +// )} +// </div> +// </div> +// </Fragment> +// ); +// } -function ChallengeDetails({ - challenge, - onStart, - onCancel, -}: { - challenge: ChallengeInProgess; - onStart: () => void; - onCancel: () => void; -}): VNode { - const { i18n } = useTranslationContext(); - const { config } = useBankCoreApiContext(); +// function ChallengeDetails({ +// challenge, +// onStart, +// onCancel, +// }: { +// challenge: ChallengeInProgess; +// onStart: () => void; +// onCancel: () => void; +// }): VNode { +// const { i18n } = useTranslationContext(); +// const { config } = useBankCoreApiContext(); - const firstTime = AbsoluteTime.isNever(challenge.sent); - useEffect(() => { - if (firstTime) { - onStart(); - } - }, []); +// const firstTime = AbsoluteTime.isNever(challenge.sent); +// useEffect(() => { +// if (firstTime) { +// onStart(); +// } +// }, []); - const subtitle = ((op): TranslatedString => { - switch (op) { - case "delete-account": - return i18n.str`Removing account`; - case "update-account": - return i18n.str`Updating account values`; - case "update-password": - return i18n.str`Updating password`; - case "create-transaction": - return i18n.str`Making a wire transfer`; - case "confirm-withdrawal": - return i18n.str`Confirming withdrawal`; - case "create-cashout": - return i18n.str`Making a cashout`; - case "login": - return i18n.str`Authentication`; - } - })(challenge.operation); +// const subtitle = ((op): TranslatedString => { +// switch (op) { +// case "delete-account": +// return i18n.str`Removing account`; +// case "update-account": +// return i18n.str`Updating account values`; +// case "update-password": +// return i18n.str`Updating password`; +// case "create-transaction": +// return i18n.str`Making a wire transfer`; +// case "confirm-withdrawal": +// return i18n.str`Confirming withdrawal`; +// case "create-cashout": +// return i18n.str`Making a cashout`; +// case "login": +// return i18n.str`Authentication`; +// } +// })(challenge.operation); - return ( - <div class="px-4 mt-4 "> - <div class="w-full"> - <div class="border-gray-100"> - <h2 class="text-base font-semibold leading-10 text-gray-900"> - <span class=" text-black font-semibold leading-6 "> - <i18n.Translate>Operation:</i18n.Translate> - </span>{" "} - &nbsp; - <span class=" text-black font-normal leading-6 ">{subtitle}</span> - </h2> - <dl class="divide-y divide-gray-100"> - {((): VNode => { - switch (challenge.operation) { - case "delete-account": - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Type</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <i18n.Translate> - Updating account settings - </i18n.Translate> - </dd> - </div> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Account</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request} - </dd> - </div> - </Fragment> - ); - case "create-transaction": { - const payto = parsePaytoUri(challenge.request.payto_uri)!; - return ( - <Fragment> - {challenge.request.amount && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Amount</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={Amounts.parseOrThrow( - challenge.request.amount, - )} - spec={config.currency_specification} - /> - </dd> - </div> - )} - {payto.isKnown && payto.targetType === "iban" && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>To account</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {payto.iban} - </dd> - </div> - )} - </Fragment> - ); - } - case "confirm-withdrawal": - return ( - <ShowWithdrawalDetails - id={challenge.request.id} - request={challenge.request} - /> - ); - case "create-cashout": { - return <ShowCashoutDetails request={challenge.request} />; - } - case "update-account": { - return ( - <Fragment> - {challenge.request.cashout_payto_uri !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Cashout account</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.cashout_payto_uri} - </dd> - </div> - )} - {challenge.request.contact_data?.email !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Email</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.contact_data?.email} - </dd> - </div> - )} - {challenge.request.contact_data?.phone !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Phone</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.contact_data?.phone} - </dd> - </div> - )} - {challenge.request.debit_threshold !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Debit threshold</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={Amounts.parseOrThrow( - challenge.request.debit_threshold, - )} - spec={config.currency_specification} - /> - </dd> - </div> - )} - {challenge.request.is_public !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Is this account public? - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.is_public - ? i18n.str`Enable` - : i18n.str`Disable`} - </dd> - </div> - )} - {challenge.request.name !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Name</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.name} - </dd> - </div> - )} - {challenge.request.tan_channel !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate> - Authentication channel - </i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.tan_channel ?? i18n.str`Remove`} - </dd> - </div> - )} - </Fragment> - ); - } - case "login": { - return ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Username</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.username} - </dd> - </div> - ); - } - case "update-password": { - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>New password</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.request.new_password} - </dd> - </div> - </Fragment> - ); - } - default: - assertUnreachable(challenge); - } - })()} - </dl> - {challenge.info && ( - <h2 class="text-base font-semibold leading-7 text-gray-900 mt-4"> - <span - class="text-sm text-black font-semibold leading-6 " - id="availability-label" - > - <i18n.Translate>Challenge details</i18n.Translate> - </span> - </h2> - )} - <dl class="divide-y divide-gray-100"> - {challenge.sent.t_ms !== "never" && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Sent at</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <Time - format="dd/MM/yyyy HH:mm:ss" - timestamp={challenge.sent} - relative={Duration.fromSpec({ days: 1 })} - /> - </dd> - </div> - )} - {challenge.info && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - {((ch: TalerCorebankApi.TanChannel): VNode => { - switch (ch) { - case TalerCorebankApi.TanChannel.SMS: - return <i18n.Translate>To phone</i18n.Translate>; - case TalerCorebankApi.TanChannel.EMAIL: - return <i18n.Translate>To email</i18n.Translate>; - default: - assertUnreachable(ch); - } - })(challenge.info.tan_channel)} - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {challenge.info.tan_info} - </dd> - </div> - )} - </dl> - </div> - <div class="mt-6 mb-4 flex justify-between"> - <button - type="button" - name="cancel" - class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - {challenge.info ? ( - <button - 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={(e) => { - onStart(); - e.preventDefault(); - }} - > - <i18n.Translate>Send again</i18n.Translate> - </button> - ) : ( - <div> sending code ...</div> - )} - </div> - </div> - </div> - ); -} +// return ( + // <div class="px-4 mt-4 "> + // <div class="w-full"> + // <div class="border-gray-100"> + // <h2 class="text-base font-semibold leading-10 text-gray-900"> + // <span class=" text-black font-semibold leading-6 "> + // <i18n.Translate>Operation:</i18n.Translate> + // </span>{" "} + // &nbsp; + // <span class=" text-black font-normal leading-6 ">{subtitle}</span> + // </h2> + // <dl class="divide-y divide-gray-100"> +// {((): VNode => { +// switch (challenge.operation) { +// case "delete-account": +// return ( +// <Fragment> + // <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + // <dt class="text-sm font-medium leading-6 text-gray-900"> + // <i18n.Translate>Type</i18n.Translate> + // </dt> + // <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + // <i18n.Translate> + // Updating account settings + // </i18n.Translate> + // </dd> + // </div> + // <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + // <dt class="text-sm font-medium leading-6 text-gray-900"> + // <i18n.Translate>Account</i18n.Translate> + // </dt> + // <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + // {challenge.request} + // </dd> + // </div> +// </Fragment> +// ); +// case "create-transaction": { +// const payto = parsePaytoUri(challenge.request.payto_uri)!; +// return ( +// <Fragment> +// {challenge.request.amount && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Amount</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// <RenderAmount +// value={Amounts.parseOrThrow( +// challenge.request.amount, +// )} +// spec={config.currency_specification} +// /> +// </dd> +// </div> +// )} +// {payto.isKnown && payto.targetType === "iban" && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>To account</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {payto.iban} +// </dd> +// </div> +// )} +// </Fragment> +// ); +// } +// case "confirm-withdrawal": +// return ( +// <ShowWithdrawalDetails +// id={challenge.request.id} +// request={challenge.request} +// /> +// ); +// case "create-cashout": { +// return <ShowCashoutDetails request={challenge.request} />; +// } +// case "update-account": { +// return ( +// <Fragment> +// {challenge.request.cashout_payto_uri !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Cashout account</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.cashout_payto_uri} +// </dd> +// </div> +// )} +// {challenge.request.contact_data?.email !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Email</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.contact_data?.email} +// </dd> +// </div> +// )} +// {challenge.request.contact_data?.phone !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Phone</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.contact_data?.phone} +// </dd> +// </div> +// )} +// {challenge.request.debit_threshold !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Debit threshold</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// <RenderAmount +// value={Amounts.parseOrThrow( +// challenge.request.debit_threshold, +// )} +// spec={config.currency_specification} +// /> +// </dd> +// </div> +// )} +// {challenge.request.is_public !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate> +// Is this account public? +// </i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.is_public +// ? i18n.str`Enable` +// : i18n.str`Disable`} +// </dd> +// </div> +// )} +// {challenge.request.name !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Name</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.name} +// </dd> +// </div> +// )} +// {challenge.request.tan_channel !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate> +// Authentication channel +// </i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.tan_channel ?? i18n.str`Remove`} +// </dd> +// </div> +// )} +// </Fragment> +// ); +// } +// case "login": { +// return ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Username</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.username} +// </dd> +// </div> +// ); +// } +// case "update-password": { +// return ( +// <Fragment> +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>New password</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {challenge.request.new_password} +// </dd> +// </div> +// </Fragment> +// ); +// } +// default: +// assertUnreachable(challenge); +// } +// })()} +// </dl> +// {challenge.info && ( + // <h2 class="text-base font-semibold leading-7 text-gray-900 mt-4"> + // <span + // class="text-sm text-black font-semibold leading-6 " + // id="availability-label" + // > + // <i18n.Translate>Challenge details</i18n.Translate> + // </span> + // </h2> +// )} + // <dl class="divide-y divide-gray-100"> +// {challenge.sent.t_ms !== "never" && ( + // <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + // <dt class="text-sm font-medium leading-6 text-gray-900"> + // <i18n.Translate>Sent at</i18n.Translate> + // </dt> + // <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + // <Time + // format="dd/MM/yyyy HH:mm:ss" + // timestamp={challenge.sent} + // relative={Duration.fromSpec({ days: 1 })} + // /> + // </dd> + // </div> +// )} +// {challenge.info && ( + // <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + // <dt class="text-sm font-medium leading-6 text-gray-900"> + // {((ch: TalerCorebankApi.TanChannel): VNode => { + // switch (ch) { + // case TalerCorebankApi.TanChannel.SMS: + // return <i18n.Translate>To phone</i18n.Translate>; + // case TalerCorebankApi.TanChannel.EMAIL: + // return <i18n.Translate>To email</i18n.Translate>; + // default: + // assertUnreachable(ch); + // } + // })(challenge.info.tan_channel)} + // </dt> + // <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + // {challenge.info.tan_info} + // </dd> + // </div> +// )} +// </dl> +// </div> + // <div class="mt-6 mb-4 flex justify-between"> + // <button + // type="button" + // name="cancel" + // class="text-sm font-semibold leading-6 text-gray-900" + // onClick={onCancel} + // > + // <i18n.Translate>Cancel</i18n.Translate> + // </button> + // {challenge.info ? ( + // <button + // 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={(e) => { + // onStart(); + // e.preventDefault(); + // }} + // > + // <i18n.Translate>Send again</i18n.Translate> + // </button> + // ) : ( + // <div> sending code ...</div> + // )} + // </div> +// </div> +// </div> +// ); +// } -function ShowWithdrawalDetails({ - id, - request, -}: { - id: string; - request: TalerCorebankApi.BankAccountConfirmWithdrawalRequest; -}): VNode { - const details = useWithdrawalDetails(id); - const { i18n } = useTranslationContext(); - const { config } = useBankCoreApiContext(); - if (!details) { - return <Loading />; - } - if (details instanceof TalerError) { - return <ErrorLoadingWithDebug error={details} />; - } - if (details.type === "fail") { - switch (details.case) { - case HttpStatusCode.BadRequest: - case HttpStatusCode.NotFound: - return <OperationNotFound routeClose={undefined} />; - default: - assertUnreachable(details); - } - } +// function ShowWithdrawalDetails({ +// id, +// request, +// }: { +// id: string; +// request: TalerCorebankApi.BankAccountConfirmWithdrawalRequest; +// }): VNode { +// const details = useWithdrawalDetails(id); +// const { i18n } = useTranslationContext(); +// const { config } = useBankCoreApiContext(); +// if (!details) { +// return <Loading />; +// } +// if (details instanceof TalerError) { +// return <ErrorLoadingWithDebug error={details} />; +// } +// if (details.type === "fail") { +// switch (details.case) { +// case HttpStatusCode.BadRequest: +// case HttpStatusCode.NotFound: +// return <OperationNotFound routeClose={undefined} />; +// default: +// assertUnreachable(details); +// } +// } - return ( - <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.body.amount !== undefined ? ( - <RenderAmount - value={Amounts.parseOrThrow(details.body.amount)} - spec={config.currency_specification} - /> - ) : ( - <i18n.Translate>No amount has yet been determined.</i18n.Translate> - )} - </dd> - </div> - {details.body.selected_reserve_pub !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Withdraw reserve ID</i18n.Translate> - </dt> - <dd - class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0" - title={details.body.selected_reserve_pub} - > - {details.body.selected_reserve_pub.substring(0, 16)}... - </dd> - </div> - )} - {details.body.selected_exchange_account !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>To account</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {details.body.selected_exchange_account} - </dd> - </div> - )} - </Fragment> - ); -} +// return ( +// <Fragment> +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {details.body.amount !== undefined ? ( +// <RenderAmount +// value={Amounts.parseOrThrow(details.body.amount)} +// spec={config.currency_specification} +// /> +// ) : ( +// <i18n.Translate>No amount has yet been determined.</i18n.Translate> +// )} +// </dd> +// </div> +// {details.body.selected_reserve_pub !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Withdraw reserve ID</i18n.Translate> +// </dt> +// <dd +// class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0" +// title={details.body.selected_reserve_pub} +// > +// {details.body.selected_reserve_pub.substring(0, 16)}... +// </dd> +// </div> +// )} +// {details.body.selected_exchange_account !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>To account</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {details.body.selected_exchange_account} +// </dd> +// </div> +// )} +// </Fragment> +// ); +// } -function ShowCashoutDetails({ - request, -}: { - request: TalerCorebankApi.CashoutRequest; -}): VNode { - const { i18n } = useTranslationContext(); - const info = useConversionInfo(); - if (!info) { - return <Loading />; - } +// function ShowCashoutDetails({ +// request, +// }: { +// request: TalerCorebankApi.CashoutRequest; +// }): VNode { +// const { i18n } = useTranslationContext(); +// const info = useConversionInfo(); +// if (!info) { +// return <Loading />; +// } - if (info instanceof TalerError) { - return <ErrorLoadingWithDebug error={info} />; - } - if (info.type === "fail") { - switch (info.case) { - case HttpStatusCode.NotImplemented: { - return ( - <Attention type="danger" title={i18n.str`Cashout is disabled`}> - <i18n.Translate> - Cashout should be enabled in the configuration, the conversion - rate should be initialized with fee(s), rates and a rounding mode. - </i18n.Translate> - </Attention> - ); - } - default: - assertUnreachable(info); - } - } +// if (info instanceof TalerError) { +// return <ErrorLoadingWithDebug error={info} />; +// } +// if (info.type === "fail") { +// switch (info.case) { +// case HttpStatusCode.NotImplemented: { +// return ( +// <Attention type="danger" title={i18n.str`Cashout is disabled`}> +// <i18n.Translate> +// Cashout should be enabled in the configuration, the conversion +// rate should be initialized with fee(s), rates and a rounding mode. +// </i18n.Translate> +// </Attention> +// ); +// } +// default: +// assertUnreachable(info); +// } +// } - return ( - <Fragment> - {request.subject !== undefined && ( - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900"> - <i18n.Translate>Subject</i18n.Translate> - </dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - {request.subject} - </dd> - </div> - )} - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Debit</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={Amounts.parseOrThrow(request.amount_credit)} - spec={info.body.regional_currency_specification} - /> - </dd> - </div> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Credit</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount - value={Amounts.parseOrThrow(request.amount_credit)} - spec={info.body.fiat_currency_specification} - /> - </dd> - </div> - </Fragment> - ); -} +// return ( +// <Fragment> +// {request.subject !== undefined && ( +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900"> +// <i18n.Translate>Subject</i18n.Translate> +// </dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// {request.subject} +// </dd> +// </div> +// )} +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900">Debit</dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// <RenderAmount +// value={Amounts.parseOrThrow(request.amount_credit)} +// spec={info.body.regional_currency_specification} +// /> +// </dd> +// </div> +// <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> +// <dt class="text-sm font-medium leading-6 text-gray-900">Credit</dt> +// <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> +// <RenderAmount +// value={Amounts.parseOrThrow(request.amount_credit)} +// spec={info.body.fiat_currency_specification} +// /> +// </dd> +// </div> +// </Fragment> +// ); +// } diff --git a/packages/bank-ui/src/pages/SolveMFA.tsx b/packages/bank-ui/src/pages/SolveMFA.tsx @@ -0,0 +1,571 @@ +import { + Challenge, + ChallengeResponse, + HttpStatusCode, + TalerErrorCode, + TanChannel, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { + ButtonBetter, + LocalNotificationBanner, + ShowInputErrorLabel, + Time, + undefinedIfEmpty, + useBankCoreApiContext, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useSessionState } from "../hooks/session.js"; +import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { TalerCorebankApi } from "@gnu-taler/taler-util"; + +export interface Props { + onCompleted(challenges: string[]): void; + onCancel(): void; + description: TranslatedString; + currentChallenge: ChallengeResponse; +} + +interface Form { + code: string; +} + +function SolveChallenge({ + challenge, + onCancel, + onSolved, +}: { + onCancel: () => void; + challenge: Challenge; + onSolved: () => void; +}): 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 errors = undefinedIfEmpty({ + code: !tanCode ? i18n.str`Required` : undefined, + }); + + const doVerification = + !tanCode || !username + ? undefined + : withErrorHandler( + () => + api.confirmChallenge(username, challenge.challenge_id, { + tan: tanCode, + }), + onSolved, + (resp) => { + switch (resp.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Failed to validate the verification code.`; + case HttpStatusCode.TooManyRequests: + return i18n.str`Failed to validate the verification code.`; + case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: + return i18n.str`Failed to validate the verification code.`; + case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + return i18n.str`Failed to validate the verification code.`; + } + }, + ); + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > + <i18n.Translate> + Submit the transmitted code number. + </i18n.Translate> + </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> + </p> + </div> + + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <div class="px-4 mt-4 "> + <form + class="space-y-6" + noValidate + onSubmit={(e) => { + e.preventDefault(); + }} + autoCapitalize="none" + autoCorrect="off" + > + <div> + <label + for="username" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Code</i18n.Translate> + </label> + <div class="mt-2"> + <input + ref={doAutoFocus} + type="text" + name="username" + id="username" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={tanCode ?? ""} + // disabled={fixedUser} + enterkeyhint="next" + placeholder="T-12345678" + autocomplete="username" + title={i18n.str`Username of the account`} + required + onInput={(e): void => { + setTanCode(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.code} + isDirty={tanCode !== undefined} + /> + </div> + </div> + </form> + <div class="mt-6 mb-4 flex justify-between"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} + > + <i18n.Translate>Back</i18n.Translate> + </button> + + <ButtonBetter + 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={doVerification} + > + <i18n.Translate>Verify</i18n.Translate> + </ButtonBetter> + </div> + </div> + </div> + </div> + {/* <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title"> + <i18n.Translate>Validation code sent.</i18n.Translate> + </p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + {(function (): VNode { + switch (challenge.tan_channel) { + case TanChannel.SMS: + return ( + <i18n.Translate> + The verification code sent to the phone number starting + with "<b>{challenge.tan_info}</b>" + </i18n.Translate> + ); + case TanChannel.EMAIL: + return ( + <i18n.Translate> + The verification code sent to the email address starting + with "<b>{challenge.tan_info}</b>" + </i18n.Translate> + ); + } + })()} + </section> + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} + > + <button class="button" onClick={onCancel}> + <i18n.Translate>Back</i18n.Translate> + </button> + <ButtonBetter + type="is-info" + disabled={errors !== undefined} + onClick={doVerification} + > + <i18n.Translate>Verify</i18n.Translate> + </ButtonBetter> + </footer> + </div> + </div> + </div> */} + </Fragment> + ); +} + +export function SolveMFAChallenges({ + currentChallenge, + description, + onCompleted, + onCancel, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const [solved, setSolved] = useState<string[]>([]); + // const [selected, setSelected] = useState<Challenge | undefined>({ + // challenge_id: "123", + // tan_channel: TanChannel.EMAIL, + // tan_info: "asd@qwe.com", + // }); + const [selected, setSelected] = useState<Challenge>(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const session = useSessionState(); + const username = + session.state.status === "loggedIn" ? session.state.username : "merchant"; + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + + if (selected) { + return ( + <SolveChallenge + onCancel={() => setSelected(undefined)} + challenge={selected} + onSolved={() => { + setSelected(undefined); + setSolved([...solved, selected.challenge_id]); + }} + /> + ); + } + + const currentSolved = currentChallenge.challenges.filter( + ({ challenge_id }) => solved.indexOf(challenge_id) !== -1, + ); + const hasSolvedEnough = currentChallenge.combi_and + ? 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 doComplete = !hasSolvedEnough + ? undefined + : async () => onCompleted(solved); + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <span + class="text-sm text-black font-semibold leading-6 " + id="availability-label" + > + <i18n.Translate> + Multi-factor authentication required + </i18n.Translate> + </span> + </h2> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + This operation is protected with second factor authentication. In + order to complete it we need to verify your identity using the + authentication channel you provided. + </i18n.Translate> + </p> + </div> + + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <div class="px-4 mt-4 "> + <div class="w-full"> + <div class="border-gray-100"> + <h2 class="text-base font-semibold leading-10 text-gray-900"> + <span class=" text-black font-semibold leading-6 "> + {description} + </span> + </h2> + </div> + </div> + {/* <dl class="divide-y divide-gray-100"> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Type</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <i18n.Translate>Updating account settings</i18n.Translate> + </dd> + </div> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Account</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + asd + </dd> + </div> + </dl> */} + <h2 class="text-base leading-7 text-gray-900 "> + <span class="text-sm leading-6 " id="availability-label"> + {currentChallenge.challenges.length === 1 ? ( + <i18n.Translate> + The next challenge needs to be completed to confirm the + operation. + </i18n.Translate> + ) : currentChallenge.combi_and ? ( + <i18n.Translate> + All the next challenges need to be completed to confirm the + operation. + </i18n.Translate> + ) : ( + <i18n.Translate> + One of the next challenges need to be completed to confirm + the operation. + </i18n.Translate> + )} + </span> + </h2> + {currentChallenge.challenges.map((challenge) => { + const noNeedToComplete = + hasSolvedEnough || + solved.indexOf(challenge.challenge_id) !== -1; + + const doSelect = noNeedToComplete + ? undefined + : async () => { + setSelected(challenge); + }; + + const doSend = + noNeedToComplete || !username + ? undefined + : () => sendMessage(username, challenge); + + return ( + <div class="rounded-xl border px-2 my-2"> + <dl class="divide-y divide-gray-100"> + <div class="px-4 py-2 sm:grid sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + {((ch: TanChannel): VNode => { + switch (ch) { + case TanChannel.SMS: + return ( + <i18n.Translate> + To an phone starting with " + {challenge.tan_info}" + </i18n.Translate> + ); + case TanChannel.EMAIL: + return ( + <i18n.Translate> + To an email starting with " + {challenge.tan_info}" + </i18n.Translate> + ); + } + })(challenge.tan_channel)} + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:mt-0"> + <div class="flex justify-between"> + <ButtonBetter + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={doSelect} + > + <i18n.Translate>I have a code</i18n.Translate> + </ButtonBetter> + + <ButtonBetter + 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={doSend} + > + <i18n.Translate>Send me a message</i18n.Translate> + </ButtonBetter> + </div> + </dd> + </div> + </dl> + </div> + ); + })} + + <div class="mt-6 mb-4 flex justify-between"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <ButtonBetter + 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={doComplete} + > + <i18n.Translate>Complete</i18n.Translate> + </ButtonBetter> + </div> + </div> + </div> + </div> + + {/* <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title"></p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + {currentChallenge.combi_and ? ( + <i18n.Translate> + You need to complete all of this requirements. + </i18n.Translate> + ) : ( + <i18n.Translate> + You need to complete at least one of this requirements. + </i18n.Translate> + )} + </section> + {currentChallenge.challenges.map((challenge) => { + return ( + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + {(function (): VNode { + switch (challenge.tan_channel) { + case TanChannel.SMS: + return ( + <i18n.Translate> + An SMS to the phone number starting with{" "} + {challenge.tan_info} + </i18n.Translate> + ); + case TanChannel.EMAIL: + return ( + <i18n.Translate> + An email to the address starting with{" "} + {challenge.tan_info} + </i18n.Translate> + ); + } + })()} + + <div + style={{ + justifyContent: "space-between", + display: "flex", + }} + > + <button + disabled={ + hasSolvedEnough || + solved.indexOf(challenge.challenge_id) !== -1 + } + class="button" + onClick={() => { + setSelected(challenge); + }} + > + <i18n.Translate>I have a code</i18n.Translate> + </button> + <ButtonBetter + disabled={ + hasSolvedEnough || + solved.indexOf(challenge.challenge_id) !== -1 + } + onClick={ + !username + ? undefined + : () => sendMessage(username, challenge) + } + > + <i18n.Translate>Send me a message</i18n.Translate> + </ButtonBetter> + </div> + </section> + ); + })} + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} + > + <button class="button" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <ButtonBetter + type="is-info" + onClick={ + !hasSolvedEnough ? undefined : async () => onCompleted(solved) + } + > + <i18n.Translate>Complete</i18n.Translate> + </ButtonBetter> + </footer> + </div> + </div> + </div> */} + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx @@ -22,7 +22,7 @@ import { TalerCorebankApi, TranslatedString, assertUnreachable, - parseWithdrawUri + parseWithdrawUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -31,7 +31,9 @@ import { ShowInputErrorLabel, notifyError, useBankCoreApiContext, + useChallengeHandler, useLocalNotification, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; @@ -366,7 +368,6 @@ export function WalletWithdrawForm({ limit, balance, routeCancel, - onAuthorizationRequired, onOperationCreated, onOperationAborted, routeOperationDetails, @@ -375,7 +376,7 @@ export function WalletWithdrawForm({ balance: AmountJson; focus?: boolean; routeOperationDetails: RouteDefinition<{ wopid: string }>; - onAuthorizationRequired: () => void; + onOperationCreated: (wopid: string) => void; onOperationAborted: () => void; routeCancel: RouteDefinition; @@ -433,11 +434,8 @@ export function WalletWithdrawForm({ <OperationState focus={focus} currency={limit.currency} - onAuthorizationRequired={onAuthorizationRequired} routeClose={routeCancel} - routeHere={routeOperationDetails} onAbort={onOperationAborted} - // route={routeCancel} /> )} </div> diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx @@ -38,22 +38,15 @@ export function WireTransfer({ toAccount, withSubject, withAmount, - onAuthorizationRequired, + routeCancel, - routeHere, onSuccess, }: { onSuccess?: () => void; - routeHere: RouteDefinition<{ - account?: string; - subject?: string; - amount?: string; - }>; toAccount?: string; withSubject?: string; withAmount?: string; routeCancel?: RouteDefinition; - onAuthorizationRequired: () => void; }): VNode { const { i18n } = useTranslationContext(); const r = useSessionState(); @@ -67,16 +60,16 @@ export function WireTransfer({ return ( <Fragment> <ErrorLoadingWithDebug error={result} /> - <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired} /> + <LoginForm currentUser={account} /> </Fragment> ); } if (result.type === "fail") { switch (result.case) { case HttpStatusCode.Unauthorized: - return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired} />; + return <LoginForm currentUser={account} />; case HttpStatusCode.NotFound: - return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired}/>; + return <LoginForm currentUser={account} />; default: assertUnreachable(result); } @@ -112,9 +105,7 @@ export function WireTransfer({ withAmount={withAmount} balance={positiveBalance} withSubject={withSubject} - routeHere={routeHere} limit={limit} - onAuthorizationRequired={onAuthorizationRequired} onSuccess={() => { notifyInfo(i18n.str`The wire transfer was successfully completed!`); if (onSuccess) onSuccess(); diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -15,49 +15,43 @@ */ import { - AbsoluteTime, AmountJson, Amounts, HttpStatusCode, PaytoUri, - PaytoUriIBAN, - PaytoUriTalerBank, - TalerCorebankApi, TalerErrorCode, - TranslatedString, WithdrawUriResult, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, LocalNotificationBanner, notifyInfo, - useLocalNotification, + useBankCoreApiContext, + useChallengeHandler, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { mutate } from "swr"; -import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; -import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; -import { useSessionState } from "../hooks/session.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { LoggedIn, useSessionState } from "../hooks/session.js"; import { LoginForm } from "./LoginForm.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { SolveMFAChallenges } from "./SolveMFA.js"; const TALER_SCREEN_ID = 114; interface Props { onAborted: () => void; withdrawUri: WithdrawUriResult; - routeHere: RouteDefinition<{ wopid: string }>; details: { account: PaytoUri; reserve: string; username: string; amount?: AmountJson; }; - onAuthorizationRequired: () => void; } /** * Additional authentication required to complete the operation. @@ -66,17 +60,15 @@ interface Props { export function WithdrawalConfirmationQuestion({ onAborted, details, - onAuthorizationRequired, - routeHere, withdrawUri, }: Props): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [, updateBankState] = useBankState(); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const { config, @@ -88,143 +80,77 @@ export function WithdrawalConfirmationQuestion({ ? Amounts.zeroOfCurrency(config.currency) : Amounts.parseOrThrow(config.wire_transfer_fees); - async function doTransfer() { - await handleError(async () => { - if (!creds) return; - const request: TalerCorebankApi.BankAccountConfirmWithdrawalRequest & { - id: string; - } = { id: withdrawUri.withdrawalOperationId }; - - const resp = await api.confirmWithdrawalById( - creds, - {}, - withdrawUri.withdrawalOperationId, - ); - if (resp.type === "ok") { - mutate(() => true); // clean any info that we have - if (!settings.showWithdrawalSuccess) { - notifyInfo(i18n.str`Wire transfer completed!`); - } - } else { - switch (resp.case) { - case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT: - return notify({ - type: "error", - title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return notify({ - type: "error", - title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation ID is invalid.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return notify({ - type: "error", - title: i18n.str`Your balance is not sufficient for the operation.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_AMOUNT_DIFFERS: { - return notify({ - type: "error", - title: i18n.str`The starting withdrawal amount and the confirmation amount differs.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_AMOUNT_REQUIRED: { - return notify({ - type: "error", - title: i18n.str`The bank requires a bank account which has not been specified yet.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + const [doConfirm, repeatConfirm] = mfa.withMfaHandler( + ({ challengeIds, onChallengeRequired }) => + withErrorHandler( + (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!`); } - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "confirm-withdrawal", - id: String(resp.body.challenge_id), - location: routeHere.url({ - wopid: withdrawUri.withdrawalOperationId, - }), - sent: AbsoluteTime.never(), - request, - }); - return onAuthorizationRequired(); + }, + (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.`; + } } - default: - assertUnreachable(resp); - } - } - }); - } + }, + ), + ); - async function doCancel() { - await handleError(async () => { - if (!creds) return; - const resp = await api.abortWithdrawalById( - creds, - withdrawUri.withdrawalOperationId, - ); - if (resp.type === "ok") { - onAborted(); - } else { - switch (resp.case) { - case HttpStatusCode.Conflict: - return notify({ - type: "error", - title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.BadRequest: - return notify({ - type: "error", - title: i18n.str`The operation ID is invalid.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The operation was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - default: { - assertUnreachable(resp); - } - } - } - }); + const confirmHandler = !creds + ? undefined + : () => doConfirm(creds, withdrawUri.withdrawalOperationId); + + const abortHandler = !creds + ? undefined + : () => + withErrorHandler( + (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); + + if (mfa.pendingChallenge && repeatConfirm) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatConfirm} + /> + ); } return ( @@ -237,10 +163,7 @@ export function WithdrawalConfirmationQuestion({ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate> </h3> <div class="mt-3 text-sm leading-6"> - <ShouldBeSameUser - username={details.username} - onAuthorizationRequired={onAuthorizationRequired} - > + <ShouldBeSameUser username={details.username}> <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <form class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" @@ -459,25 +382,22 @@ export function WithdrawalConfirmationQuestion({ </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - <button + <ButtonBetter type="button" name="cancel" class="text-sm font-semibold leading-6 text-gray-900" - onClick={doCancel} + onClick={abortHandler} > <i18n.Translate>Cancel</i18n.Translate> - </button> - <button + </ButtonBetter> + <ButtonBetter 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={(e) => { - e.preventDefault(); - doTransfer(); - }} + onClick={confirmHandler} > <i18n.Translate>Transfer</i18n.Translate> - </button> + </ButtonBetter> </div> </form> </div> @@ -492,11 +412,9 @@ export function WithdrawalConfirmationQuestion({ export function ShouldBeSameUser({ username, children, - onAuthorizationRequired, }: { username: string; children: ComponentChildren; - onAuthorizationRequired: () => void; }): VNode { const { state: credentials } = useSessionState(); const { i18n } = useTranslationContext(); @@ -504,11 +422,7 @@ export function ShouldBeSameUser({ return ( <Fragment> <Attention type="info" title={i18n.str`Authentication required`} /> - <LoginForm - currentUser={username} - fixedUser - onAuthorizationRequired={onAuthorizationRequired} - /> + <LoginForm currentUser={username} fixedUser /> </Fragment> ); } @@ -519,11 +433,7 @@ export function ShouldBeSameUser({ type="warning" title={i18n.str`This operation was created with another username`} /> - <LoginForm - currentUser={username} - fixedUser - onAuthorizationRequired={onAuthorizationRequired} - /> + <LoginForm currentUser={username} fixedUser /> </Fragment> ); } diff --git a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx @@ -26,13 +26,12 @@ const TALER_SCREEN_ID = 115; export function WithdrawalOperationPage({ operationId, - onAuthorizationRequired, + onOperationAborted, routeClose, origin, routeWithdrawalDetails, }: { - onAuthorizationRequired: () => void; operationId: string; origin: "from-bank-ui" | "from-wallet-ui"; onOperationAborted: () => void; @@ -66,7 +65,6 @@ export function WithdrawalOperationPage({ withdrawUri={parsedUri} origin={origin} routeWithdrawalDetails={routeWithdrawalDetails} - onAuthorizationRequired={onAuthorizationRequired} onOperationAborted={() => { updateBankState("currentWithdrawalOperationId", undefined); onOperationAborted(); diff --git a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx @@ -44,7 +44,6 @@ interface Props { onOperationAborted: () => void; routeClose: RouteDefinition; routeWithdrawalDetails: RouteDefinition<{ wopid: string }>; - onAuthorizationRequired: () => void; } /** * Offer the QR code (and a clickable taler://-link) to @@ -57,7 +56,6 @@ export function WithdrawalQRCode({ routeClose, origin, routeWithdrawalDetails, - onAuthorizationRequired, }: Props): VNode { const { i18n } = useTranslationContext(); const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId); @@ -250,14 +248,12 @@ export function WithdrawalQRCode({ return ( <WithdrawalConfirmationQuestion withdrawUri={withdrawUri} - routeHere={routeWithdrawalDetails} details={{ username: data.username, account, reserve: data.selected_reserve_pub, amount: !data.amount ? undefined : Amounts.parseOrThrow(data.amount), }} - onAuthorizationRequired={onAuthorizationRequired} onAborted={() => { notifyInfo(i18n.str`Operation aborted`); onOperationAborted(); diff --git a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx @@ -26,22 +26,20 @@ const TALER_SCREEN_ID = 117; interface Props { account: string; routeClose: RouteDefinition; - onAuthorizationRequired: () => void; + onCashout: () => void; routeCashoutDetails: RouteDefinition<{ cid: string }>; routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; - routeCreateCashout: RouteDefinition; routeConversionConfig: RouteDefinition; } export function CashoutListForAccount({ account, - onAuthorizationRequired, + onCashout, - routeCreateCashout, routeCashoutDetails, routeMyAccountCashout, routeMyAccountDelete, @@ -78,10 +76,8 @@ export function CashoutListForAccount({ <CreateCashout focus - routeHere={routeCreateCashout} routeClose={routeClose} onCashout={onCashout} - onAuthorizationRequired={onAuthorizationRequired} account={account} /> diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx @@ -26,13 +26,16 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, CopyButton, Loading, LocalNotificationBanner, RouteDefinition, notifyInfo, useBankCoreApiContext, + useChallengeHandler, useLocalNotification, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -41,10 +44,11 @@ import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js import { useAccountDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; -import { useSessionState } from "../../hooks/session.js"; +import { LoggedIn, useSessionState } from "../../hooks/session.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { AccountForm } from "../admin/AccountForm.js"; +import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 118; @@ -52,23 +56,21 @@ export function ShowAccountDetails({ account, routeClose, onUpdateSuccess, - onAuthorizationRequired, + routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, - routeHere, routeMyAccountPassword, routeConversionConfig, }: { routeClose: RouteDefinition; - routeHere: RouteDefinition<{ account: string }>; routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; routeConversionConfig: RouteDefinition; onUpdateSuccess: () => void; - onAuthorizationRequired: () => void; + account: string; }): VNode { const { i18n } = useTranslationContext(); @@ -86,8 +88,8 @@ export function ShowAccountDetails({ const [submitAccount, setSubmitAccount] = useState< TalerCorebankApi.AccountReconfiguration | undefined >(); - const [notification, notify, handleError] = useLocalNotification(); - const [, updateBankState] = useBankState(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const result = useAccountDetails(account); if (!result) { @@ -97,10 +99,7 @@ export function ShowAccountDetails({ return ( <Fragment> <ErrorLoadingWithDebug error={result} /> - <LoginForm - currentUser={account} - onAuthorizationRequired={onAuthorizationRequired} - /> + <LoginForm currentUser={account} /> </Fragment> ); } @@ -108,142 +107,56 @@ export function ShowAccountDetails({ switch (result.case) { case HttpStatusCode.Unauthorized: case HttpStatusCode.NotFound: - return ( - <LoginForm - currentUser={account} - onAuthorizationRequired={onAuthorizationRequired} - /> - ); + return <LoginForm currentUser={account} />; default: assertUnreachable(result); } } - async function doUpdate() { - if (!submitAccount || !creds) return; - await handleError(async () => { - const resp = await bank.updateAccount( - { - token: creds.token, - username: account, + const [doUpdate, repeatUpdate] = mfa.withMfaHandler( + ({ challengeIds, onChallengeRequired }) => + withErrorHandler( + (creds: LoggedIn, account: TalerCorebankApi.AccountReconfiguration) => + bank.updateAccount(creds, account, { challengeIds }), + (success) => { + notifyInfo(i18n.str`Account updated`); + onUpdateSuccess(); }, - submitAccount, - ); - - if (resp.type === "ok") { - notifyInfo(i18n.str`Account updated`); - onUpdateSuccess(); - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`The rights to change the account are not sufficient`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The username was not found`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: - return notify({ - type: "error", - title: i18n.str`You can't change the legal name, please contact the your account administrator.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: - return notify({ - type: "error", - title: i18n.str`You can't change the debt limit, please contact the your account administrator.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: - return notify({ - type: "error", - title: i18n.str`You can't change the cashout address, please contact the your account administrator.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_MISSING_TAN_INFO: - return notify({ - type: "error", - title: i18n.str`No information for the selected authentication channel.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "update-account", - id: String(resp.body.challenge_id), - location: routeHere.url({ account }), - sent: AbsoluteTime.never(), - request: submitAccount, - }); - return onAuthorizationRequired(); - } - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { - return notify({ - type: "error", - title: i18n.str`Authentication channel is not supported.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: { - return notify({ - type: "error", - title: i18n.str`Only the administrator can change the conversion rate.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: { - return notify({ - type: "error", - title: i18n.str`The conversion rate class doesn't exist.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: { - return notify({ - type: "error", - title: i18n.str`The password is too short. Can't have less than 8 characters.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: { - return notify({ - type: "error", - title: i18n.str`The password is too long. Can't have more than 64 characters.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`The rights to change the account are not sufficient`; + case HttpStatusCode.NotFound: + return i18n.str`The username was not found`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME: + return i18n.str`You can't change the legal name, please contact the your account administrator.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`You can't change the debt limit, please contact the your account administrator.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT: + return i18n.str`You can't change the cashout address, please contact the your account administrator.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case HttpStatusCode.Accepted: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_CONVERSION_RATE_CLASS: + return i18n.str`Only the administrator can change the conversion rate.`; + case TalerErrorCode.BANK_CONVERSION_RATE_CLASS_UNKNOWN: + return i18n.str`The conversion rate class doesn't exist.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; } - default: - assertUnreachable(resp); - } - } - }); - } + }, + ), + ); + + const updateHandler = + !creds || !submitAccount ? undefined : () => doUpdate(creds, submitAccount); const url = bank.getRevenueAPI(account); const baseURL = url.href; @@ -260,6 +173,16 @@ export function ShowAccountDetails({ accountToken: creds?.token, }; + if (mfa.pendingChallenge && repeatUpdate) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatUpdate} + /> + ); + } + return ( <Fragment> <LocalNotificationBanner notification={notification} showDebug={true} /> @@ -315,15 +238,15 @@ export function ShowAccountDetails({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <button + <ButtonBetter type="submit" name="update" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" disabled={!submitAccount} - onClick={doUpdate} + onClick={updateHandler} > <i18n.Translate>Update</i18n.Translate> - </button> + </ButtonBetter> </div> </AccountForm> </div> diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -13,29 +13,25 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; import { - AbsoluteTime, - HttpStatusCode, - TalerErrorCode, - TranslatedString, - assertUnreachable, -} from "@gnu-taler/taler-util"; -import { + ButtonBetter, LocalNotificationBanner, + RouteDefinition, ShowInputErrorLabel, notifyInfo, - useLocalNotification, + useBankCoreApiContext, + useChallengeHandler, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; import { useSessionState } from "../../hooks/session.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; import { undefinedIfEmpty } from "../../utils.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; +import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 119; @@ -43,24 +39,22 @@ export function UpdateAccountPassword({ account: accountName, routeClose, onUpdateSuccess, - onAuthorizationRequired, + routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, routeConversionConfig, focus, - routeHere, }: { routeClose: RouteDefinition; - routeHere: RouteDefinition<{ account: string }>; routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; routeConversionConfig: RouteDefinition; focus?: boolean; - onAuthorizationRequired: () => void; + onUpdateSuccess: () => void; account: string; }): VNode { @@ -75,7 +69,6 @@ export function UpdateAccountPassword({ const [current, setCurrent] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); const [repeat, setRepeat] = useState<string | undefined>(); - const [, updateBankState] = useBankState(); const accountIsTheCurrentUser = credentials.status === "loggedIn" @@ -95,100 +88,63 @@ export function UpdateAccountPassword({ ? i18n.str`Repeated password doesn't match` : undefined, }); - const [notification, notify, handleError] = useLocalNotification(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); - async function doChangePassword() { - if (!!errors || !password || !token) return; - await handleError(async () => { - const request = { + const request = !password + ? undefined + : { old_password: current, new_password: password, }; - const resp = await api.updatePassword( - { username: accountName, token }, - request, - ); - if (resp.type === "ok") { - notifyInfo(i18n.str`Password changed`); - onUpdateSuccess(); - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`Not authorized to change the password, maybe the session is invalid.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: - return notify({ - type: "error", - title: i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: - return notify({ - type: "error", - title: i18n.str`Your current password doesn't match, can't change to a new password.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "update-password", - id: String(resp.body.challenge_id), - location: routeHere.url({ account: accountName }), - sent: AbsoluteTime.never(), - request, - }); - return onAuthorizationRequired(); - } - case HttpStatusCode.Forbidden: { - return notify({ - type: "error", - title: i18n.str`You don't have the rights to change the password.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: { - return notify({ - type: "error", - title: i18n.str`The password is too short. Can't have less than 8 characters.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - case TalerErrorCode.BANK_PASSWORD_TOO_LONG: { - return notify({ - type: "error", - title: i18n.str`The password is too long. Can't have more than 64 characters.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - } - default: - assertUnreachable(resp); - } - } - }); - } + const [doUpdatePassword, repeatUpdatePassword] = + !token || !request + ? [undefined, undefined] + : mfa.withMfaHandler(({ challengeIds, onChallengeRequired }) => + withErrorHandler( + () => + api.updatePassword({ username: accountName, token }, request, { + challengeIds, + }), + (success) => { + notifyInfo(i18n.str`Password changed`); + onUpdateSuccess(); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Not authorized to change the password, maybe the session is invalid.`; + case HttpStatusCode.NotFound: + return i18n.str`Account not found`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD: + return i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`; + case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD: + return i18n.str`Your current password doesn't match, can't change to a new password.`; + case HttpStatusCode.Accepted: { + onChallengeRequired(fail.body); + return i18n.str`A second factor authentication is required.`; + } + case HttpStatusCode.Forbidden: + return i18n.str`You don't have the rights to change the password.`; + case TalerErrorCode.BANK_PASSWORD_TOO_SHORT: + return i18n.str`The password is too short. Can't have less than 8 characters.`; + case TalerErrorCode.BANK_PASSWORD_TOO_LONG: + return i18n.str`The password is too long. Can't have more than 64 characters.`; + } + }, + ), + ); + + if (mfa.pendingChallenge && repeatUpdatePassword) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatUpdatePassword} + /> + ); + } return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -328,18 +284,15 @@ export function UpdateAccountPassword({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <button + <ButtonBetter 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={(e) => { - e.preventDefault(); - doChangePassword(); - }} + onClick={doUpdatePassword} > <i18n.Translate>Change</i18n.Translate> - </button> + </ButtonBetter> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -59,7 +59,7 @@ interface Props { routeShowAccount: RouteDefinition<{ account: string }>; routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; routeShowCashoutsAccount: RouteDefinition<{ account: string }>; - onAuthorizationRequired: () => void; + routeCreateConversionRateClass: RouteDefinition; routeShowConversionRateClass: RouteDefinition<{ classId: string }>; } @@ -72,16 +72,12 @@ export function AdminHome({ routeCreateWireTransfer, routeCreateConversionRateClass, routeShowConversionRateClass, - onAuthorizationRequired, }: Props): VNode { const { config } = useBankCoreApiContext(); return ( <Fragment> <Metrics routeDownloadStats={routeDownloadStats} /> - <WireTransfer - routeHere={routeCreateWireTransfer} - onAuthorizationRequired={onAuthorizationRequired} - /> + <WireTransfer /> <Transactions account="admin" routeCreateWireTransfer={routeCreateWireTransfer} diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -14,34 +14,34 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { - AbsoluteTime, Amounts, HttpStatusCode, TalerError, TalerErrorCode, - TranslatedString, assertUnreachable, } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, Loading, LocalNotificationBanner, + RouteDefinition, ShowInputErrorLabel, notifyInfo, - useLocalNotification, + useBankCoreApiContext, + useChallengeHandler, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; import { useAccountDetails } from "../../hooks/account.js"; import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; -import { useBankState } from "../../hooks/bank-state.js"; -import { RouteDefinition } from "@gnu-taler/web-util/browser"; +import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 125; @@ -49,13 +49,11 @@ export function RemoveAccount({ account, routeCancel, onUpdateSuccess, - onAuthorizationRequired, + focus, - routeHere, }: { focus?: boolean; - routeHere: RouteDefinition<{ account: string }>; - onAuthorizationRequired: () => void; + routeCancel: RouteDefinition; onUpdateSuccess: () => void; account: string; @@ -69,8 +67,8 @@ export function RemoveAccount({ const { lib: { bank: api }, } = useBankCoreApiContext(); - const [notification, notify, handleError] = useLocalNotification(); - const [, updateBankState] = useBankState(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); if (!result) { return <Loading />; @@ -79,16 +77,16 @@ export function RemoveAccount({ return ( <Fragment> <ErrorLoadingWithDebug error={result} /> - <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired} /> + <LoginForm currentUser={account} /> </Fragment> ); } if (result.type === "fail") { switch (result.case) { case HttpStatusCode.Unauthorized: - return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired}/>; + return <LoginForm currentUser={account} />; case HttpStatusCode.NotFound: - return <LoginForm currentUser={account} onAuthorizationRequired={onAuthorizationRequired}/>; + return <LoginForm currentUser={account} />; default: assertUnreachable(result); } @@ -121,64 +119,34 @@ export function RemoveAccount({ ); } - async function doRemove() { - if (!token) return; - await handleError(async () => { - const resp = await api.deleteAccount({ username: account, token }); - if (resp.type === "ok") { - notifyInfo(i18n.str`Account removed`); - onUpdateSuccess(); - } else { - switch (resp.case) { - case HttpStatusCode.Unauthorized: - return notify({ - type: "error", - title: i18n.str`No enough permission to delete the account.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`The username was not found.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: - return notify({ - type: "error", - title: i18n.str`Can't delete a reserved username.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: - return notify({ - type: "error", - title: i18n.str`Can't delete an account with balance different than zero.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "delete-account", - id: String(resp.body.challenge_id), - sent: AbsoluteTime.never(), - location: routeHere.url({ account }), - request: account, - }); - return onAuthorizationRequired(); - } - default: { - assertUnreachable(resp); - } - } - } - }); - } + 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 @@ -188,6 +156,16 @@ export function RemoveAccount({ : undefined, }); + if (mfa.pendingChallenge && repeatDelete) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatDelete} + /> + ); + } + return ( <div> <LocalNotificationBanner notification={notification} /> @@ -260,18 +238,15 @@ export function RemoveAccount({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <button + <ButtonBetter 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={(e) => { - e.preventDefault(); - doRemove(); - }} + onClick={doDelete} > <i18n.Translate>Delete</i18n.Translate> - </button> + </ButtonBetter> </div> </form> </div> diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx @@ -172,9 +172,16 @@ function useComponentState({ `${info.fiat_currency}:${form.amount.value}`, ); const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee); - const cashin = await calculateCashinFromDebit(in_amount, in_fee); + const respCashin = await calculateCashinFromDebit(in_amount, in_fee); - if (cashin === "amount-is-too-small") { + const cashin = + respCashin.type === "ok" + ? respCashin.body + : respCashin.case === HttpStatusCode.Conflict + ? ("amount-is-too-small" as const) + : undefined; + + if (!cashin || cashin === "amount-is-too-small") { setCalc(undefined); return; } @@ -182,11 +189,23 @@ function useComponentState({ const out_fee = Amounts.parseOrThrow( info.conversion_rate.cashout_fee, ); - const cashout = await calculateCashoutFromDebit( + const respCashout = await calculateCashoutFromDebit( cashin.credit, out_fee, ); + const cashout = + respCashout.type === "ok" + ? respCashout.body + : respCashout.case === HttpStatusCode.Conflict + ? ("amount-is-too-small" as const) + : undefined; + + if (!cashout) { + setCalc(undefined); // silent failure + return; + } + setCalc({ cashin, cashout }); }); } diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx @@ -27,13 +27,16 @@ import { } from "@gnu-taler/taler-util"; import { Attention, + ButtonBetter, Loading, LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, notifyInfo, useBankCoreApiContext, + useChallengeHandler, useLocalNotification, + useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -42,12 +45,13 @@ import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js import { useAccountDetails } from "../../hooks/account.js"; import { useBankState } from "../../hooks/bank-state.js"; import { + TransCalc, TransferCalculation, useCashoutEstimatorByUser, useConversionInfo, useConversionRateForUser, } from "../../hooks/regional.js"; -import { useSessionState } from "../../hooks/session.js"; +import { LoggedIn, useSessionState } from "../../hooks/session.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { @@ -55,16 +59,16 @@ import { RenderAmount, doAutoFocus, } from "../PaytoWireTransferForm.js"; +import { AmountJson } from "@gnu-taler/taler-util"; +import { SolveMFAChallenges } from "../SolveMFA.js"; const TALER_SCREEN_ID = 127; interface Props { account: string; focus?: boolean; - onAuthorizationRequired: () => void; onCashout: () => void; routeClose: RouteDefinition; - routeHere: RouteDefinition; } type FormType = { @@ -79,10 +83,9 @@ type ErrorFrom<T> = { export function CreateCashout({ account: accountName, - onAuthorizationRequired, + onCashout, focus, - routeHere, routeClose, }: Props): VNode { const { i18n } = useTranslationContext(); @@ -118,11 +121,11 @@ export function CreateCashout({ } = useCashoutEstimatorByUser(accountName); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const [, updateBankState] = useBankState(); const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); - const [notification, notify, handleError] = useLocalNotification(); const rateResp = useConversionRateForUser(accountName, creds?.token); const conversionResp = useConversionInfo(); + const [notification, withErrorHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); if (!resultAccount) { return <Loading />; @@ -131,19 +134,9 @@ export function CreateCashout({ } else if (resultAccount.type === "fail") { switch (resultAccount.case) { case HttpStatusCode.Unauthorized: - return ( - <LoginForm - currentUser={accountName} - onAuthorizationRequired={onAuthorizationRequired} - /> - ); + return <LoginForm currentUser={accountName} />; case HttpStatusCode.NotFound: - return ( - <LoginForm - currentUser={accountName} - onAuthorizationRequired={onAuthorizationRequired} - /> - ); + return <LoginForm currentUser={accountName} />; default: assertUnreachable(resultAccount); } @@ -236,25 +229,41 @@ export function CreateCashout({ }`, ); + const higerThanMin = form.isDebit + ? Amounts.cmp(inputAmount, rate.cashout_min_amount) === 1 + : true; + const notZero = Amounts.isNonZero(inputAmount); + + const conversionCalculator = withErrorHandler( + (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.`; + } + }, + ); + useEffect(() => { async function doAsync() { - await handleError(async () => { - const higerThanMin = form.isDebit - ? Amounts.cmp(inputAmount, rate.cashout_min_amount) === 1 - : true; - const notZero = Amounts.isNonZero(inputAmount); - if (notZero && higerThanMin) { - const resp = await (form.isDebit - ? calculateFromDebit(inputAmount, sellFee) - : calculateFromCredit(inputAmount, sellFee)); - setCalculation(resp); - } else { - setCalculation(zeroCalc); - } - }); + if (notZero && higerThanMin) { + await conversionCalculator(form.isDebit ?? false, inputAmount, sellFee); + } else { + setCalculation(zeroCalc); + } } doAsync(); - }, [form.amount, form.isDebit]); + }, [form.amount, form.isDebit, notZero, higerThanMin, rate.cashout_fee]); const calc = calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult; @@ -287,111 +296,65 @@ export function CreateCashout({ }); const trimmedAmountStr = form.amount?.trim(); - async function createCashout() { - const request_uid = encodeCrock(getRandomBytes(32)); - await handleError(async () => { - if (!creds || !form.subject) return; - const request = { - request_uid, - amount_credit: Amounts.stringify(calc.credit), - amount_debit: Amounts.stringify(calc.debit), - subject: form.subject, - }; - const resp = await api.createCashout(creds, request); - if (resp.type === "ok") { - notifyInfo(i18n.str`Cashout created`); - onCashout(); - } else { - switch (resp.case) { - case HttpStatusCode.Accepted: { - updateBankState("currentChallenge", { - operation: "create-cashout", - id: String(resp.body.challenge_id), - sent: AbsoluteTime.never(), - location: routeHere.url({}), - request, - }); - return onAuthorizationRequired(); - } - case HttpStatusCode.NotFound: - return notify({ - type: "error", - title: i18n.str`Account not found`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: - return notify({ - type: "error", - title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_BAD_CONVERSION: - return notify({ - type: "error", - title: i18n.str`The conversion rate was applied incorrectly`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_UNALLOWED_DEBIT: - return notify({ - type: "error", - title: i18n.str`The account does not have sufficient funds`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case HttpStatusCode.NotImplemented: - return notify({ - type: "error", - title: i18n.str`Cashout is disabled`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_CONFIRM_INCOMPLETE: - return notify({ - type: "error", - title: i18n.str`Missing cashout URI in the profile`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL: - return notify({ - type: "error", - title: i18n.str`The amount is below the minimum amount permitted.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - - case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: - return notify({ - type: "error", - title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); - case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { - return notify({ - type: "error", - title: i18n.str`The server doesn't support the current TAN channel.`, - description: resp.detail?.hint as TranslatedString, - debug: resp.detail, - when: AbsoluteTime.now(), - }); + const [createCashout, repeatCashout] = mfa.withMfaHandler( + ({ challengeIds, onChallengeRequired }) => + withErrorHandler( + (creds: LoggedIn, calc: TransCalc, subject: string) => + api.createCashout( + creds, + { + request_uid: encodeCrock(getRandomBytes(32)), + 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.`; + } } - } - assertUnreachable(resp); - } - }); - } + }, + ), + ); + + const subject = form.subject; + const cashoutHandler = + !!errors || !creds || !subject + ? undefined + : () => createCashout(creds, calc, subject); const cashoutDisabled = !resultAccount.body.cashout_payto_uri; @@ -406,6 +369,16 @@ export function CreateCashout({ ? undefined : cashoutAccount.params["receiver-name"]; + if (mfa.pendingChallenge && repeatCashout) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCancel={mfa.doCancelChallenge} + onCompleted={repeatCashout} + /> + ); + } + return ( <div> <LocalNotificationBanner notification={notification} /> @@ -741,18 +714,14 @@ export function CreateCashout({ > <i18n.Translate>Cancel</i18n.Translate> </a> - <button + <ButtonBetter type="submit" name="cashout" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors} - onClick={(e) => { - e.preventDefault(); - createCashout(); - }} + onClick={cashoutHandler} > <i18n.Translate>Cashout</i18n.Translate> - </button> + </ButtonBetter> </div> </form> </div> diff --git a/packages/bank-ui/src/settings.json b/packages/bank-ui/src/settings.json @@ -1,13 +1,13 @@ { - "backendBaseURL": "http://bank.taler.test:1180/", + "backendBaseURL": "http://bank.taler.test/", "simplePasswordForRandomAccounts": true, "allowRandomAccountCreation": true, "fastWithdrawalForm": true, "defaultSuggestedAmount": 11, "bankName": "Taler DEVELOPMENT Bank", "topNavSites": { - "Exchange": "http://Exchnage.taler.test:1180/", - "Bank": "http://bank-ui.taler.test:1180/", - "Merchant": "http://merchant.taler.test:1180/" + "Exchange": "http://Exchnage.taler.test/", + "Bank": "http://bank-ui.taler.test/", + "Merchant": "http://merchant.taler.test/" } } diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx @@ -1,14 +1,4 @@ import { - undefinedIfEmpty, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { h, VNode, Fragment } from "preact"; -import { useState } from "preact/hooks"; -import { AsyncButton } from "./exception/AsyncButton.js"; -import { NotificationCard } from "./menu/index.js"; -import { useSessionContext } from "../context/session.js"; -import { Notification } from "../utils/types.js"; -import { assertUnreachable, Challenge, ChallengeResponse, @@ -16,8 +6,18 @@ import { TalerErrorCode, TanChannel, } from "@gnu-taler/taler-util"; +import { + undefinedIfEmpty, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useSessionContext } from "../context/session.js"; +import { Notification } from "../utils/types.js"; +import { AsyncButton } from "./exception/AsyncButton.js"; import { FormErrors, FormProvider } from "./form/FormProvider.js"; import { Input } from "./form/Input.js"; +import { NotificationCard } from "./menu/index.js"; export interface Props { onCompleted(challenges: string[]): void; @@ -126,24 +126,22 @@ function SolveChallenge({ class="modal-card-body" style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} > - {(function () { - switch (challenge.challenge_type) { + {(function (): VNode { + switch (challenge.tan_channel) { case TanChannel.SMS: return ( <i18n.Translate> The verification code sent to the phone number starting - with "<b>{challenge.address_hint}</b>" + with "<b>{challenge.tan_info}</b>" </i18n.Translate> ); case TanChannel.EMAIL: return ( <i18n.Translate> The verification code sent to the email address starting - with "<b>{challenge.address_hint}</b>" + with "<b>{challenge.tan_info}</b>" </i18n.Translate> ); - default: - assertUnreachable(challenge.challenge_type); } })()} <FormProvider<Form> @@ -265,7 +263,7 @@ export function SolveMFAChallenges({ return; } default: { - assertUnreachable(resp) + assertUnreachable(resp); } } } catch (error) { @@ -314,24 +312,21 @@ export function SolveMFAChallenges({ class="modal-card-body" style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} > - {(function () { - switch (d.challenge_type) { + {(function (): VNode { + switch (d.tan_channel) { case TanChannel.SMS: return ( <i18n.Translate> An SMS to the phone number starting with{" "} - {d.address_hint} + {d.tan_info} </i18n.Translate> ); case TanChannel.EMAIL: return ( <i18n.Translate> - An email to the address starting with{" "} - {d.address_hint} + An email to the address starting with {d.tan_info} </i18n.Translate> ); - default: - assertUnreachable(d.challenge_type); } })()} diff --git a/packages/merchant-backoffice-ui/src/hooks/challenge.ts b/packages/merchant-backoffice-ui/src/hooks/challenge.ts @@ -1,144 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { ChallengeResponse } from "@gnu-taler/taler-util"; -import { useState } from "preact/hooks"; - -/** - * State of the current MFA operation and handler to manage - * the state and retry. - * - */ -interface MfaState<Type extends Array<any>> { - /** - * If a mfa has been started this will contain - * the challenge response. - */ - pendingChallenge: ChallengeResponse | undefined; - - /** - * All required challenges has been completed. - * The operation should be retried with this - * challenges ids. - * - * @param solvedChallengeIds - * @returns - */ - doRetryWithConfimation: (solvedChallengeIds: string[]) => Promise<void>; - - /** - * Cancel the current pending challenge. - * - * @returns - */ - doCancelChallenge: () => void; - - /** - * Perform the operation guarded by the MFA handler which - * it may start a challenge to be completed. - * - * It intentionally has the same signature that the original function. - * @param arg - * @returns - */ - doFirstCall: (...arg: Type) => Promise<void>; - - /** - * Perform the operation guarded by the MFA handler which - * in the same say that `doFirstCall` but this time for - * situation when there are challanges that in the session. - * - * @param arg - * @returns - */ - tryOperation: (challengeIds: string[], ...arg: Type) => Promise<void>; -} - -/** - * Handler to be used by the function performing the MFA - * guarded operation - */ -interface MfaHandler { - /** - * Callback handler to use when the operation fails with MFA required - * @param challenge - * @param params - * @returns - */ - onChallengeRequired: (challenge: ChallengeResponse, ...params: any[]) => void; - /** - * 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; -} - -/** - * asd - */ -type CallbackFactory<T extends any[]> = ( - h: MfaHandler, -) => (...args: T) => Promise<void>; - -/** - * 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<T extends any[]>( - cf: CallbackFactory<T>, -): MfaState<T> { - const [params, saveParams] = useState<T>(); - const [current, saveMfaResponse] = useState<ChallengeResponse>(); - - async function start(...args: T): Promise<void> { - saveParams(args); - return run(undefined, ...args) - } - - async function run(challengeIds: string[] | undefined, ...args: T): Promise<void> { - const guardedOperation = cf({ - onChallengeRequired: saveMfaResponse, - challengeIds, - }); - return guardedOperation(...args); - } - - function reset() { - saveMfaResponse(undefined); - saveParams(undefined); - } - - async function retry(newChallengesSolved: string[]): Promise<void> { - if (!params) throw Error('calling retry mfa but without starting a challenge') - return run(newChallengesSolved, ...params) - } - - return { - doFirstCall: start, - tryOperation: run, - doCancelChallenge: reset, - doRetryWithConfimation: retry, - pendingChallenge: current, - }; -} diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx @@ -20,9 +20,12 @@ import { HttpStatusCode, InstanceConfigurationMessage, - TalerMerchantApi + TalerMerchantApi, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { NotificationCard } from "../../../components/menu/index.js"; @@ -31,7 +34,6 @@ import { useSessionContext } from "../../../context/session.js"; import { Notification } from "../../../utils/types.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../../login/index.js"; import { CreatePage } from "./CreatePage.js"; -import { useChallengeHandler } from "../../../hooks/challenge.js"; interface Props { onBack?: () => void; @@ -45,13 +47,16 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const { i18n } = useTranslationContext(); const { lib, state, logIn } = useSessionContext(); - const mfa = useChallengeHandler<[InstanceConfigurationMessage]>( + const { doCancelChallenge, pendingChallenge, withMfaHandler } = + useChallengeHandler(); + + const [onFirstCall, repeatCall] = withMfaHandler( ({ challengeIds, onChallengeRequired }) => - async (d) => { + async function createInstaceImpl(d: InstanceConfigurationMessage) { if (state.status !== "loggedIn") return; try { const resp = await lib.instance.createInstance(state.token, d, { - challengeIds, + challengeIds: challengeIds, }); if (resp.type === "fail") { if (resp.case === HttpStatusCode.Accepted) { @@ -88,12 +93,12 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { }, ); - if (mfa.pendingChallenge) { + if (pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} - onCancel={mfa.doCancelChallenge} + currentChallenge={pendingChallenge} + onCompleted={repeatCall} + onCancel={doCancelChallenge} /> ); } @@ -102,11 +107,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { <Fragment> <NotificationCard notification={notif} /> - <CreatePage - onBack={onBack} - forceId={forceId} - onCreate={mfa.doFirstCall} - /> + <CreatePage onBack={onBack} forceId={forceId} onCreate={onFirstCall} /> </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx @@ -26,7 +26,10 @@ import { TalerMerchantApi, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js"; @@ -39,7 +42,6 @@ import { Notification } from "../../../utils/types.js"; import { LoginPage } from "../../login/index.js"; import { View } from "./View.js"; import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; -import { useChallengeHandler } from "../../../hooks/challenge.js"; interface Props { onCreate: () => void; @@ -57,6 +59,7 @@ export default function Instances({ const [deleting, setDeleting] = useState<TalerMerchantApi.Instance>(); const [purging, setPurging] = useState<boolean>(); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const mfa = useChallengeHandler(); const { i18n } = useTranslationContext(); const { state, lib } = useSessionContext(); @@ -75,7 +78,7 @@ export default function Instances({ } } - const mfa = useChallengeHandler<[]>( + const [doDelete, repeatDelete] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async function doDeleteImpl(): Promise<void> { if (state.status !== "loggedIn") { @@ -121,7 +124,7 @@ export default function Instances({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatDelete} onCancel={mfa.doCancelChallenge} /> ); @@ -150,13 +153,13 @@ export default function Instances({ <PurgeModal element={deleting} onCancel={() => setDeleting(undefined)} - onConfirm={mfa.doFirstCall} + onConfirm={doDelete} /> ) : ( <DeleteModal element={deleting} onCancel={() => setDeleting(undefined)} - onConfirm={mfa.doFirstCall} + onConfirm={doDelete} /> ))} </Fragment> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx @@ -25,7 +25,11 @@ import { HttpStatusCode, TalerMerchantApi, } from "@gnu-taler/taler-util"; -import { Time, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + Time, + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { NotificationCard } from "../../../../components/menu/index.js"; @@ -34,7 +38,6 @@ import { Notification } from "../../../../utils/types.js"; import { CreatePage } from "./CreatePage.js"; import { ConfirmModal, Row } from "../../../../components/modal/index.js"; import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; -import { useChallengeHandler } from "../../../../hooks/challenge.js"; export type Entity = TalerMerchantApi.LoginTokenRequest; interface Props { @@ -51,9 +54,11 @@ export default function AccessTokenCreatePage({ const { i18n } = useTranslationContext(); const [ok, setOk] = useState<{ token: string; expiration: AbsoluteTime }>(); - const mfa = useChallengeHandler<[string, Entity]>( + const mfa = useChallengeHandler(); + + const [doCreate, repeatCreate] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - async function doCreateImpl(pwd, request) { + async function doCreateImpl(pwd:string, request: Entity) { try { const resp = await lib.instance.createAccessToken( state.instance, @@ -93,7 +98,7 @@ export default function AccessTokenCreatePage({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatCreate} onCancel={mfa.doCancelChallenge} /> ); @@ -146,7 +151,7 @@ export default function AccessTokenCreatePage({ </div> </ConfirmModal> )} - <CreatePage onBack={onBack} onCreate={mfa.doFirstCall} /> + <CreatePage onBack={onBack} onCreate={doCreate} /> </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -34,6 +34,7 @@ import { } from "@gnu-taler/taler-util"; import { BrowserFetchHttpLib, + useChallengeHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -48,7 +49,6 @@ import type { HeadersImpl, } from "@gnu-taler/taler-util/http"; import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; -import { useChallengeHandler } from "../../../../hooks/challenge.js"; export type Entity = TalerMerchantApi.AccountAddDetails; interface Props { @@ -62,9 +62,10 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { const { i18n } = useTranslationContext(); - const mfa = useChallengeHandler<[Entity]>( + const mfa = useChallengeHandler(); + const [doCreate, repeatCreate] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - async function doCreateImpl(request) { + async function doCreateImpl(request: Entity) { try { const resp = await lib.instance.addBankAccount(state.token, request, { challengeIds, @@ -96,7 +97,7 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatCreate} onCancel={mfa.doCancelChallenge} /> ); @@ -105,7 +106,7 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { return ( <> <NotificationCard notification={notif} /> - <CreatePage onBack={onBack} onCreate={mfa.doFirstCall} /> + <CreatePage onBack={onBack} onCreate={doCreate} /> </> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -28,7 +28,10 @@ import { TalerMerchantApi, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; @@ -42,7 +45,6 @@ import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { UpdatePage } from "./UpdatePage.js"; import { WithId } from "../../../../declaration.js"; import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; -import { useChallengeHandler } from "../../../../hooks/challenge.js"; export type Entity = TalerMerchantApi.AccountPatchDetails & WithId; @@ -59,6 +61,7 @@ export default function UpdateValidator({ const { state, lib } = useSessionContext(); const result = useBankAccountDetails(bid); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const mfa = useChallengeHandler(); const { i18n } = useTranslationContext(); @@ -103,9 +106,12 @@ export default function UpdateValidator({ }); } - const mfa = useChallengeHandler<[BankAccountDetail, AccountAddDetails]>( + const [doReplace, repeatReplace] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - async function doReplaceImpl(prev, next) { + async function doReplaceImpl( + prev: BankAccountDetail, + next: AccountAddDetails, + ) { try { const resp = await lib.instance.addBankAccount(state.token, next, { challengeIds, @@ -159,7 +165,7 @@ export default function UpdateValidator({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatReplace} onCancel={mfa.doCancelChallenge} /> ); @@ -172,7 +178,7 @@ export default function UpdateValidator({ account={{ ...result.body, id: bid }} onBack={onBack} onUpdate={doUpdateImpl} - onReplace={mfa.doFirstCall} + onReplace={doReplace} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx @@ -21,7 +21,10 @@ import { TalerMerchantManagementResultByMethod, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js"; @@ -42,7 +45,6 @@ import { import { NotFoundPageOrAdminCreate } from "../../notfound/index.js"; import { DetailPage } from "./DetailPage.js"; import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; -import { useChallengeHandler } from "../../../hooks/challenge.js"; export interface Props { onChange: () => void; @@ -54,7 +56,8 @@ export default function PasswordPage(props: Props): VNode { const result = useInstanceDetails(); const instanceId = state.instance; - const mfa = useChallengeHandler<[string]>( + const mfa = useChallengeHandler(); + const [doChangePassword, repeatChangePassword] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async function changePassword( // currentPassword: string | undefined, @@ -112,13 +115,13 @@ export default function PasswordPage(props: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatChangePassword} onCancel={mfa.doCancelChallenge} /> ); } - return CommonPassword({ ...props, instanceId }, result, mfa.doFirstCall); + return CommonPassword({ ...props, instanceId }, result, doChangePassword); } export function AdminPassword(props: Props & { instanceId: string }): VNode { @@ -129,7 +132,8 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { const instanceId = props.instanceId; - const mfa = useChallengeHandler<[string]>( + const mfa = useChallengeHandler(); + const [doChangePassword, repeatChangePassword] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async function changePassword( // currentPassword: string | undefined, @@ -189,13 +193,13 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatChangePassword} onCancel={mfa.doCancelChallenge} /> ); } - return CommonPassword(props, result, mfa.doFirstCall); + return CommonPassword(props, result, doChangePassword); } function CommonPassword( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx @@ -24,7 +24,10 @@ import { ChallengeResponse, HttpStatusCode, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../components/exception/AsyncButton.js"; @@ -35,7 +38,6 @@ import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; import { useSessionContext } from "../../../context/session.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; import { Notification } from "../../../utils/types.js"; -import { useChallengeHandler } from "../../../hooks/challenge.js"; interface Props { instanceId: string; @@ -69,7 +71,8 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { const text = i18n.str`You are deleting the instance with ID "${instanceId}"`; - const mfa = useChallengeHandler<[]>( + const mfa = useChallengeHandler(); + const [doDelete, repeatDelete] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async function doDeleteImpl() { if (hasErrors) return; @@ -79,7 +82,7 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { challengeIds, }); if (resp.type === "ok") { - logOut() + logOut(); return onDeleted(); } switch (resp.case) { @@ -122,13 +125,13 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { description: error instanceof Error ? error.message : undefined, }); } - } + }, ); if (mfa.pendingChallenge) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatDelete} onCancel={mfa.doCancelChallenge} /> ); @@ -181,7 +184,7 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { ? i18n.str`Please complete the marked fields` : i18n.str`Confirm operation` } - onClick={mfa.doFirstCall} + onClick={doDelete} > <i18n.Translate>DELETE</i18n.Translate> </button> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx @@ -22,7 +22,10 @@ import { TalerMerchantManagementResultByMethod, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js"; @@ -39,7 +42,6 @@ import { NotFoundPageOrAdminCreate } from "../../notfound/index.js"; import { UpdatePage } from "./UpdatePage.js"; import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; import { DeletePage } from "./DeletePage.js"; -import { useChallengeHandler } from "../../../hooks/challenge.js"; export interface Props { onBack: () => void; @@ -91,11 +93,13 @@ function CommonUpdate( } } - const mfa = useChallengeHandler< - [TalerMerchantApi.InstanceReconfigurationMessage] - >( + const mfa = useChallengeHandler(); + + const [doUpdate, repeatUpdate] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => - async function doUpdateImpl(d) { + async function doUpdateImpl( + d: TalerMerchantApi.InstanceReconfigurationMessage, + ) { if (state.status !== "loggedIn") { return; } @@ -133,7 +137,7 @@ function CommonUpdate( return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatUpdate} onCancel={mfa.doCancelChallenge} /> ); @@ -146,7 +150,7 @@ function CommonUpdate( onBack={onBack} isLoading={false} selected={result.body} - onUpdate={mfa.doFirstCall} + onUpdate={doUpdate} /> <div class="columns"> <div class="column" /> diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -25,9 +25,12 @@ import { HttpStatusCode, LoginTokenRequest, LoginTokenScope, - TranslatedString + TranslatedString, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../components/exception/AsyncButton.js"; @@ -35,7 +38,6 @@ import { NotificationCard } from "../../components/menu/index.js"; import { SolveMFAChallenges } from "../../components/SolveMFA.js"; import { useSessionContext } from "../../context/session.js"; import { Notification } from "../../utils/types.js"; -import { useChallengeHandler } from "../../hooks/challenge.js"; interface Props {} @@ -62,7 +64,8 @@ export function LoginPage(_p: Props): VNode { const { i18n } = useTranslationContext(); - const mfa = useChallengeHandler( + const mfa = useChallengeHandler(); + const [doLogin, repeatLogin] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async () => { const api = getInstanceForUsername(username); @@ -119,7 +122,7 @@ export function LoginPage(_p: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatLogin} onCancel={mfa.doCancelChallenge} /> ); @@ -162,7 +165,7 @@ export function LoginPage(_p: Props): VNode { placeholder={"instance name"} name="username" onKeyPress={(e) => - e.keyCode === 13 ? mfa.doFirstCall() : null + e.keyCode === 13 ? doLogin() : null } value={username} onInput={(e): void => @@ -188,7 +191,7 @@ export function LoginPage(_p: Props): VNode { placeholder={"current password"} name="token" onKeyPress={(e) => - e.keyCode === 13 ? mfa.doFirstCall() : null + e.keyCode === 13 ? doLogin() : null } value={password} onInput={(e): void => @@ -223,10 +226,7 @@ export function LoginPage(_p: Props): VNode { <i18n.Translate>Forgot password</i18n.Translate> </a> )} - <AsyncButton - disabled={!username || !password} - onClick={mfa.doFirstCall} - > + <AsyncButton disabled={!username || !password} onClick={doLogin}> <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> </footer> diff --git a/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx @@ -14,15 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util"; import { - ChallengeResponse, - HttpStatusCode, - MerchantAuthMethod, -} from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { Notification } from "../../utils/types.js"; import { AsyncButton } from "../../components/exception/AsyncButton.js"; import { FormErrors, @@ -30,16 +28,15 @@ import { } from "../../components/form/FormProvider.js"; import { Input } from "../../components/form/Input.js"; import { InputWithAddon } from "../../components/form/InputWithAddon.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { SolveMFAChallenges } from "../../components/SolveMFA.js"; import { useSessionContext } from "../../context/session.js"; import { EMAIL_REGEX, INSTANCE_ID_REGEX, PHONE_JUST_NUMBERS_REGEX, } from "../../utils/constants.js"; -import { NotificationCard } from "../../components/menu/index.js"; -import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; -import { SolveMFAChallenges } from "../../components/SolveMFA.js"; -import { useChallengeHandler } from "../../hooks/challenge.js"; +import { Notification } from "../../utils/types.js"; export interface Account { id: string; @@ -99,7 +96,8 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { setValue(v); } - const mfa = useChallengeHandler<[]>( + const mfa = useChallengeHandler(); + const [doCreate, repeatCreate] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async function doCreateImpl() { try { @@ -153,7 +151,7 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatCreate} onCancel={mfa.doCancelChallenge} /> ); @@ -233,11 +231,7 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { <button class="button" onClick={onCancel}> <i18n.Translate>Cancel</i18n.Translate> </button> - <AsyncButton - type="is-info" - disabled={!errors} - onClick={mfa.doFirstCall} - > + <AsyncButton type="is-info" disabled={!errors} onClick={doCreate}> <i18n.Translate>Create</i18n.Translate> </AsyncButton> </footer> diff --git a/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx @@ -19,7 +19,10 @@ import { HttpStatusCode, MerchantAuthMethod, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + useChallengeHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../components/exception/AsyncButton.js"; @@ -33,7 +36,6 @@ import { SolveMFAChallenges } from "../../components/SolveMFA.js"; import { useSessionContext } from "../../context/session.js"; import { Notification } from "../../utils/types.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; -import { useChallengeHandler } from "../../hooks/challenge.js"; interface Form { password: string; @@ -75,7 +77,9 @@ export function ResetAccount({ }; setValue(v); } - const mfa = useChallengeHandler<[]>( + const mfa = useChallengeHandler(); + + const [doReset, repeatReset] = mfa.withMfaHandler( ({ challengeIds, onChallengeRequired }) => async function doResetImpl() { try { @@ -127,7 +131,7 @@ export function ResetAccount({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={mfa.doRetryWithConfimation} + onCompleted={repeatReset} onCancel={mfa.doCancelChallenge} /> ); @@ -183,10 +187,7 @@ export function ResetAccount({ <button class="button" onClick={onCancel}> <i18n.Translate>Cancel</i18n.Translate> </button> - <AsyncButton - disabled={!errors} - onClick={mfa.doFirstCall} - > + <AsyncButton disabled={!errors} onClick={doReset}> <i18n.Translate>Reset</i18n.Translate> </AsyncButton> </footer> diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-activation.ts @@ -21,18 +21,17 @@ import { alternativeOrThrow, HttpStatusCode, LoginTokenScope, + TanChannel, MerchantAuthMethod, - setPrintHttpRequestAsCurl, succeedOrThrow, TalerMerchantInstanceHttpClient, - TalerMerchantManagementHttpClient, + TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util"; import { createSimpleTestkudosEnvironmentV3 } from "harness/environments.js"; -import { TanChannel } from "../../../taler-util/src/types-taler-corebank.js"; -import { GlobalTestState } from "../harness/harness.js"; import { startTanHelper } from "harness/tan-helper.js"; -import { chmodSync, fstat, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; +import { chmodSync, writeFileSync } from "node:fs"; +import { GlobalTestState } from "../harness/harness.js"; import { solveMFA } from "./test-merchant-self-provision-inactive-account-permissions.js"; /** diff --git a/packages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts b/packages/taler-harness/src/integrationtests/test-merchant-self-provision-inactive-account-permissions.ts @@ -225,7 +225,7 @@ export async function solveMFA( ) { for (const { challenge_id: challenge, - challenge_type: type, + tan_channel: type, } of resp.challenges) { const addrUsed = addrs[type]; if (!addrUsed) { diff --git a/packages/taler-util/src/http-client/bank-core.ts b/packages/taler-util/src/http-client/bank-core.ts @@ -58,8 +58,6 @@ import { BankAccountConfirmWithdrawalRequest, BankAccountCreateWithdrawalRequest, CashoutRequest, - Challenge, - ChallengeSolve, ConversionRateClassInput, CreateTransactionRequest, CreateTransactionResponse, @@ -73,7 +71,6 @@ import { codecForCashoutPending, codecForCashoutStatusResponse, codecForCashouts, - codecForChallenge, codecForConversionRateClass, codecForConversionRateClassResponse, codecForConversionRateClasses, @@ -84,10 +81,14 @@ import { codecForMonitorResponse, codecForPublicAccountsResponse, codecForRegisterAccountResponse, - codecForTanTransmission, codecForWithdrawalPublicInfo, } from "../types-taler-corebank.js"; import { + ChallengeResponse, + ChallengeSolveRequest, + codecForChallengeResponse, +} from "../types-taler-merchant.js"; +import { CacheEvictor, addLongPollingParam, addPaginationParams, @@ -137,7 +138,7 @@ export type BearerCredentials = { * Uses libtool's current:revision:age versioning. */ export class TalerCoreBankHttpClient { - public readonly PROTOCOL_VERSION = "9:0:1"; + public readonly PROTOCOL_VERSION = "10:0:2"; httpLib: HttpRequestLibrary; cacheEvictor: CacheEvictor<TalerCoreBankCacheEviction>; @@ -163,20 +164,30 @@ export class TalerCoreBankHttpClient { username: string, cred: Credentials, body: TokenRequest, - cid?: string, + params: { challengeIds?: string[] } = {}, ) { const url = new URL(`accounts/${username}/token`, this.baseUrl); + + const headers: Record<string, string> = {}; + switch (cred.type) { + case "basic": { + headers.Authorization = makeBasicAuthHeader(username, cred.password); + break; + } + case "bearer": { + headers.Authorization = makeBearerTokenAuthHeader(cred.accessToken); + break; + } + default: { + assertUnreachable(cred); + } + } + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { - Authorization: - cred.type === "basic" - ? makeBasicAuthHeader(username, cred.password) - : cred.type === "bearer" - ? makeBearerTokenAuthHeader(cred.accessToken) - : assertUnreachable(cred), - "X-Challenge-Id": cid, - }, + headers, body, }); switch (resp.status) { @@ -186,7 +197,7 @@ export class TalerCoreBankHttpClient { return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.Unauthorized: return opKnownHttpFailure(resp.status, resp); @@ -216,14 +227,8 @@ export class TalerCoreBankHttpClient { username: string, password: string, body: TokenRequest, - cid?: string, ) { - return this.createAccessToken( - username, - { type: "basic", password }, - body, - cid, - ); + return this.createAccessToken(username, { type: "basic", password }, body); } /** @@ -395,14 +400,20 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#delete--accounts-$USERNAME * */ - async deleteAccount(auth: UserAndToken, cid?: string) { + async deleteAccount( + auth: UserAndToken, + params: { challengeIds?: string[] } = {}, + ) { const url = new URL(`accounts/${auth.username}`, this.baseUrl); + + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(auth.token); + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "DELETE", - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - "X-Challenge-Id": cid, - }, + headers, }); switch (resp.status) { case HttpStatusCode.Accepted: @@ -412,7 +423,7 @@ export class TalerCoreBankHttpClient { return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.NoContent: return opEmptySuccess(); @@ -443,23 +454,27 @@ export class TalerCoreBankHttpClient { async updateAccount( auth: UserAndToken, body: AccountReconfiguration, - cid?: string, + params: { challengeIds?: string[] } = {}, ) { const url = new URL(`accounts/${auth.username}`, this.baseUrl); + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(auth.token); + + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } + const resp = await this.httpLib.fetch(url.href, { method: "PATCH", body, - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - "X-Challenge-Id": cid, - }, + headers, }); switch (resp.status) { case HttpStatusCode.Accepted: return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.NoContent: await this.cacheEvictor.notifySuccess( @@ -507,23 +522,26 @@ export class TalerCoreBankHttpClient { async updatePassword( auth: UserAndToken, body: AccountPasswordChange, - cid?: string, + params: { challengeIds?: string[] } = {}, ) { const url = new URL(`accounts/${auth.username}/auth`, this.baseUrl); + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(auth.token); + + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "PATCH", body, - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - "X-Challenge-Id": cid, - }, + headers, }); switch (resp.status) { case HttpStatusCode.Accepted: return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.NoContent: return opEmptySuccess(); @@ -714,27 +732,17 @@ export class TalerCoreBankHttpClient { async createTransaction( auth: UserAndToken, body: CreateTransactionRequest, - cid?: string, - ): Promise< - //manually definition all return types because of recursion - | OperationOk<CreateTransactionResponse> - | OperationAlternative<HttpStatusCode.Accepted, Challenge> - | OperationFail<HttpStatusCode.NotFound> - | OperationFail<HttpStatusCode.BadRequest> - | OperationFail<HttpStatusCode.Unauthorized> - | OperationFail<TalerErrorCode.BANK_UNALLOWED_DEBIT> - | OperationFail<TalerErrorCode.BANK_ADMIN_CREDITOR> - | OperationFail<TalerErrorCode.BANK_SAME_ACCOUNT> - | OperationFail<TalerErrorCode.BANK_UNKNOWN_CREDITOR> - | OperationFail<TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED> - > { + params: { challengeIds?: string[] } = {}, + ) { const url = new URL(`accounts/${auth.username}/transactions`, this.baseUrl); + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(auth.token); + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - "X-Challenge-Id": cid, - }, + headers, body, }); switch (resp.status) { @@ -747,7 +755,7 @@ export class TalerCoreBankHttpClient { return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.BadRequest: return opKnownHttpFailure(resp.status, resp); @@ -826,18 +834,20 @@ export class TalerCoreBankHttpClient { auth: UserAndToken, body: BankAccountConfirmWithdrawalRequest, wid: string, - cid?: string, + params: { challengeIds?: string[] } = {}, ) { const url = new URL( `accounts/${auth.username}/withdrawals/${wid}/confirm`, this.baseUrl, ); + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(auth.token); + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - "X-Challenge-Id": cid, - }, + headers, body, }); switch (resp.status) { @@ -845,7 +855,7 @@ export class TalerCoreBankHttpClient { return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.NoContent: await this.cacheEvictor.notifySuccess( @@ -954,14 +964,20 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-cashouts * */ - async createCashout(auth: UserAndToken, body: CashoutRequest, cid?: string) { + async createCashout( + auth: UserAndToken, + body: CashoutRequest, + params: { challengeIds?: string[] } = {}, + ) { const url = new URL(`accounts/${auth.username}/cashouts`, this.baseUrl); + const headers: Record<string, string> = {}; + headers.Authorization = makeBearerTokenAuthHeader(auth.token); + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { - Authorization: makeBearerTokenAuthHeader(auth.token), - "X-Challenge-Id": cid, - }, + headers, body, }); switch (resp.status) { @@ -974,7 +990,7 @@ export class TalerCoreBankHttpClient { return opKnownAlternativeHttpFailure( resp, resp.status, - codecForChallenge(), + codecForChallengeResponse(), ); case HttpStatusCode.NotFound: return opKnownHttpFailure(resp.status, resp); @@ -1302,43 +1318,15 @@ export class TalerCoreBankHttpClient { * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID * */ - async sendChallenge(auth: UserAndToken, cid: string) { - // Should have the same argument and response type than sendLoginChallenge - // but it uses a different auth header - return this.__interal_sendChallenge( - auth.username, - makeBearerTokenAuthHeader(auth.token), - cid, - ); - } - - /** - * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID - * - */ - async sendLoginChallenge(auth: UserAndPassword, cid: string) { - // Should have the same argument and response type than sendChallenge - // but it uses a different auth header - return this.__interal_sendChallenge( - auth.username, - makeBasicAuthHeader(auth.username, auth.password), - cid, - ); - } - private async __interal_sendChallenge( - username: string, - Authorization: string | undefined, - cid: string, - ) { + async sendChallenge(username: string, cid: string) { const url = new URL(`accounts/${username}/challenge/${cid}`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { Authorization }, }); switch (resp.status) { - case HttpStatusCode.Ok: - return opSuccessFromHttp(resp, codecForTanTransmission()); + case HttpStatusCode.NoContent: + return opEmptySuccess(); case HttpStatusCode.Unauthorized: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Forbidden: @@ -1362,44 +1350,13 @@ export class TalerCoreBankHttpClient { } /** - * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID-confirm + * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-challenge-$CHALLENGE_ID-confirm * */ async confirmChallenge( - auth: UserAndToken, - cid: string, - body: ChallengeSolve, - ) { - return this.__interal_confirmChallenge( - auth.username, - makeBearerTokenAuthHeader(auth.token), - cid, - body, - ); - } - - /** - * https://docs.taler.net/core/api-corebank.html#post--accounts-$USERNAME-challenge-$CHALLENGE_ID-confirm - * - */ - async confirmLoginChallenge( - auth: UserAndPassword, - cid: string, - body: ChallengeSolve, - ) { - return this.__interal_confirmChallenge( - auth.username, - makeBasicAuthHeader(auth.username, auth.password), - cid, - body, - ); - } - - private async __interal_confirmChallenge( username: string, - Authorization: string | undefined, cid: string, - body: ChallengeSolve, + body: ChallengeSolveRequest, ) { const url = new URL( `accounts/${username}/challenge/${cid}/confirm`, @@ -1407,7 +1364,6 @@ export class TalerCoreBankHttpClient { ); const resp = await this.httpLib.fetch(url.href, { method: "POST", - headers: { Authorization }, body, }); switch (resp.status) { @@ -1415,21 +1371,29 @@ export class TalerCoreBankHttpClient { return opEmptySuccess(); case HttpStatusCode.Unauthorized: return opKnownHttpFailure(resp.status, resp); - case HttpStatusCode.NotFound: - return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Conflict: { const details = await readTalerErrorResponse(resp); switch (details.code) { - case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: return opKnownTalerFailure(details.code, details); + default: + return opUnknownHttpFailure(resp, details); + } + } + case HttpStatusCode.NotFound: { + const details = await readTalerErrorResponse(resp); + switch (details.code) { case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: return opKnownTalerFailure(details.code, details); + case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + return opKnownTalerFailure(details.code, details); default: return opUnknownHttpFailure(resp, details); } } - case HttpStatusCode.TooManyRequests: + case HttpStatusCode.TooManyRequests: { return opKnownHttpFailure(resp.status, resp); + } default: return opUnknownHttpFailure(resp); } diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -21,8 +21,7 @@ import { HttpStatusCode, LibtoolVersion, LoginTokenRequest, - MerchantChallengeSolveRequest, - MerchantTanChannel, + ChallengeSolveRequest, OperationAlternative, OperationFail, OperationOk, @@ -2599,7 +2598,7 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-challenge-$CHALLENGE_ID-confirm * */ - async confirmChallenge(cid: string, body: MerchantChallengeSolveRequest) { + async confirmChallenge(cid: string, body: ChallengeSolveRequest) { const url = new URL(`challenge/${cid}/confirm`, this.baseUrl); const resp = await this.httpLib.fetch(url.href, { method: "POST", @@ -2847,7 +2846,11 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp switch (resp.status) { case HttpStatusCode.Accepted: - return opEmptySuccess(); + return opKnownAlternativeHttpFailure( + resp, + resp.status, + codecForChallengeResponse(), + ); case HttpStatusCode.NoContent: return opEmptySuccess(); case HttpStatusCode.Unauthorized: // FIXME: missing in docs diff --git a/packages/taler-util/src/types-taler-corebank.ts b/packages/taler-util/src/types-taler-corebank.ts @@ -36,10 +36,12 @@ import { codecOptionalDefault, ConversionRate, RoundingMode, + TanChannel, } from "./index.js"; import { PaytoString, codecForPaytoString } from "./payto.js"; import { TalerUriString } from "./taleruri.js"; import { WithdrawalOperationStatusFlag } from "./types-taler-bank-integration.js"; +import { ChallengeResponse } from "./types-taler-challenger.js"; import { AmountString, CurrencySpecification, @@ -796,29 +798,7 @@ export interface MonitorWithConversion { talerOutVolume: AmountString; } -export interface TanTransmission { - // Channel of the last successful transmission of the TAN challenge. - tan_channel: TanChannel; - // Info of the last successful transmission of the TAN challenge. - tan_info: string; -} - -export interface Challenge { - // Unique identifier of the challenge to solve to run this protected - // operation. - challenge_id: number; -} - -export interface ChallengeSolve { - // The TAN code that solves $CHALLENGE_ID - tan: string; -} - -export enum TanChannel { - SMS = "sms", - EMAIL = "email", -} export const codecForIntegrationBankConfig = (): Codec<IntegrationConfig> => buildCodecForObject<IntegrationConfig>() @@ -1143,19 +1123,3 @@ export const codecForMonitorWithCashout = (): Codec<MonitorWithConversion> => .property("talerOutVolume", codecForAmountString()) .build("TalerCorebankApi.MonitorWithCashout"); -export const codecForChallenge = (): Codec<Challenge> => - buildCodecForObject<Challenge>() - .property("challenge_id", codecForNumber()) - .build("TalerCorebankApi.Challenge"); - -export const codecForTanTransmission = (): Codec<TanTransmission> => - buildCodecForObject<TanTransmission>() - .property( - "tan_channel", - codecForEither( - codecForConstString(TanChannel.SMS), - codecForConstString(TanChannel.EMAIL), - ), - ) - .property("tan_info", codecForString()) - .build("TalerCorebankApi.TanTransmission"); diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -4392,13 +4392,13 @@ export interface Challenge { challenge_id: string; // Channel of the last successful transmission of the TAN challenge. - challenge_type: TanChannel; + tan_channel: TanChannel; // Info of the last successful transmission of the TAN challenge. // Hint to show to the user as to where the challenge was // sent or what to use to solve the challenge. May not // contain the full address for privacy. - address_hint: string; + tan_info: string; } export enum TanChannel { @@ -4410,22 +4410,22 @@ export const codecForChallenge = (): Codec<Challenge> => buildCodecForObject<Challenge>() .property("challenge_id", codecForString()) .property( - "challenge_type", + "tan_channel", codecForEither( codecForConstString(TanChannel.SMS), codecForConstString(TanChannel.EMAIL), ), ) - .property("address_hint", codecForString()) - .build("TalerCorebankApi.Challenge"); + .property("tan_info", codecForString()) + .build("MFA.Challenge"); export const codecForChallengeResponse = (): Codec<ChallengeResponse> => buildCodecForObject<ChallengeResponse>() .property("challenges", codecForList(codecForChallenge())) .property("combi_and", codecForBoolean()) - .build("TalerCorebankApi.ChallengeResponse"); + .build("MFA.ChallengeResponse"); -export interface MerchantChallengeSolveRequest { +export interface ChallengeSolveRequest { // The TAN code that solves $CHALLENGE_ID. tan: string; } diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx @@ -28,22 +28,24 @@ import { useTranslationContext } from "../index.browser.js"; // function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void { -export type OnOperationSuccesReturnType<T> = ( +export type OnOperationSuccesReturnType<T, K extends any[]> = ( result: T extends OperationOk<any> ? T : never, + ...args: K ) => TranslatedString | void; -export type OnOperationFailReturnType<T> = ( + +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<T extends OperationResult<A, B>, A, B> { - onClick: () => Promise<T | undefined>; +export interface ButtonHandler { + onClick: (() => Promise<void>) | undefined; } -interface Props<T extends OperationResult<A, B>, A, B> - extends HTMLAttributes<HTMLButtonElement> { - handler: ButtonHandler<T, A, B> | undefined; +interface Props extends HTMLAttributes<HTMLButtonElement> { + handler: ButtonHandler | undefined; } /** @@ -52,17 +54,19 @@ interface Props<T extends OperationResult<A, B>, A, B> * * When the async function is running the inner text will change into * a "loading" animation. - * + * + * @deprecated use ButtonBetter + * * @param param0 * @returns */ -export function Button<T extends OperationResult<A, B>, A, B>({ +export function Button({ handler, children, disabled, onClick: clickEvent, ...rest -}: Props<T, A, B>): VNode { +}: Props): VNode { const { i18n } = useTranslationContext(); const [running, setRunning] = useState(false); return ( @@ -71,7 +75,7 @@ export function Button<T extends OperationResult<A, B>, A, B>({ disabled={disabled || running} onClick={(e) => { e.preventDefault(); - if (!handler) { + if (!handler || !handler.onClick) { return; } setRunning(true); @@ -85,21 +89,47 @@ export function Button<T extends OperationResult<A, B>, A, B>({ ); } + +type PropsBetter = Omit<HTMLAttributes<HTMLButtonElement>, "onClick"> & { + onClick: (() => Promise<void>) | undefined +} +/** + * FIXME: removed deprecated and change for this one + * @param param0 + * @returns + */ +export function ButtonBetter({ + children, + disabled, + onClick, + ...rest +}: PropsBetter): VNode { + const { i18n } = useTranslationContext(); + const [running, setRunning] = useState(false); + return ( + <button + {...rest} + disabled={disabled || running || !onClick} + onClick={(e) => { + e.preventDefault(); + if (!onClick) { + return; + } + setRunning(true); + onClick().finally(() => { + setRunning(false); + }); + }} + > + {running ? <Wait /> : children} + </button> + ); +} + + function Wait(): VNode { return ( <Fragment> - {/* <style> - {` - #l1 { width: 120px; - height: 20px; - -webkit-mask: radial-gradient(circle closest-side, currentColor 90%, #0000) left/20% 100%; - background: linear-gradient(currentColor 0 0) left/0% 100% no-repeat #ddd; - animation: l17 2s infinite steps(6); - } - @keyframes l17 { - 100% {background-size:120% 100%} -`} - </style> */} <div id="l1" /> </Fragment> ); diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts @@ -17,6 +17,7 @@ export { useFormMeta, } from "./useForm.js"; export { useLang } from "./useLang.js"; +export { useChallengeHandler } from "./useChallenge.js"; export { buildStorageKey, StorageKey, diff --git a/packages/web-util/src/hooks/useChallenge.ts b/packages/web-util/src/hooks/useChallenge.ts @@ -0,0 +1,134 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { ChallengeResponse } from "@gnu-taler/taler-util"; +import { useCallback, useRef, useState } from "preact/hooks"; + +/** + * State of the current MFA operation and handler to manage + * the state and retry. + * + */ +interface MfaState { + /** + * If a mfa has been started this will contain + * the challenge response. + */ + pendingChallenge: ChallengeResponse | undefined; + + /** + * Cancel the current pending challenge. + * + * @returns + */ + doCancelChallenge: () => void; + + withMfaHandler: <Type extends Array<any>>( + builder: CallbackFactory<Type>, + ) => [ + (...args: Type) => Promise<void>, // function for the first call + (newChallenges: string[]) => Promise<void>, // function to repeat with new chIds + ]; +} + +/** + * Handler to be used by the function performing the MFA + * guarded operation + */ +interface MfaHandler { + /** + * Callback handler to use when the operation fails with MFA required + * @param challenge + * @param params + * @returns + */ + onChallengeRequired: (challenge: ChallengeResponse, ...params: any[]) => void; + /** + * 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; +} + +/** + * asd + */ +type CallbackFactory<T extends any[]> = ( + h: MfaHandler, +) => (...args: T) => Promise<void>; + +/** + * 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 asd = useRef<any[]>([]); + let exeOrder = 0; + + /** + * This have the same machanism that useEffect, needs to be called always on order + * @param builder + * @returns + */ + function withMfaHandler<T extends any[]>( + builder: CallbackFactory<T>, + ): [ + ReturnType<CallbackFactory<T>>, + (newChallenges: string[]) => Promise<void>, + ] { + const thisIdx = exeOrder; + exeOrder = exeOrder + 1; + + async function saveArgsAndProceed(...currentArgs: T): Promise<void> { + asd.current[thisIdx] = currentArgs; + return builder({ + challengeIds: undefined, + onChallengeRequired, + })(...currentArgs); + } + + async function repeatCall(challengeIds: string[]): Promise<void> { + if (!asd.current[thisIdx]) + throw Error("calling repeat function without doing the first call"); + + return builder({ + challengeIds, + onChallengeRequired, + })(...asd.current[thisIdx]); + } + + return [saveArgsAndProceed, repeatCall]; + } + + function reset() { + onChallengeRequired(undefined); + } + + return { + withMfaHandler, + doCancelChallenge: reset, + pendingChallenge: current, + }; +} diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts @@ -210,13 +210,17 @@ export function useLocalNotification(): [ return [notif, setter, errorHandling]; } -type HandlerMaker = <T extends OperationResult<A, B>, A, B>( - onClick: () => Promise<T | undefined>, - onOperationSuccess: OnOperationSuccesReturnType<T>, - onOperationFail?: OnOperationFailReturnType<T>, +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<T, A, B>; +) => ButtonHandler; +/** + * @deprecated use useLocalNotificationBetter + * @returns + */ export function useLocalNotificationHandler(): [ Notification | undefined, HandlerMaker, @@ -233,22 +237,22 @@ export function useLocalNotificationHandler(): [ }, }; - function makeHandler<T extends OperationResult<A, B>, A, B>( - doAction: () => Promise<T | undefined>, - onOperationSuccess: OnOperationSuccesReturnType<T>, - onOperationFail?: OnOperationFailReturnType<T>, + 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<T, A, B> { + ): ButtonHandler { const onNotification = setter; return { - onClick: async (): Promise<T | undefined> => { + onClick: async (...args: K): Promise<void> => { try { - const resp = await doAction(); + 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); + const msg = onOperationSuccess(result, ...args); if (msg) { notifyInfo(msg); } @@ -258,7 +262,7 @@ export function useLocalNotificationHandler(): [ const title = !onOperationFail ? i18n.str`Unexpected error` - : onOperationFail(resp as any); + : onOperationFail(resp as any, ...args); onNotification({ title, type: "error", @@ -272,7 +276,7 @@ export function useLocalNotificationHandler(): [ if (onOperationComplete) { onOperationComplete(); } - return resp; + return; } catch (error: unknown) { console.error(error); @@ -293,7 +297,7 @@ export function useLocalNotificationHandler(): [ if (onOperationComplete) { onOperationComplete(); } - return undefined; + return; } // setRunning(false); }, @@ -303,7 +307,103 @@ export function useLocalNotificationHandler(): [ return [notif, makeHandler, setter]; } -export function buildUnifiedRequestErrorMessage( +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, cause: TalerError, ): ErrorNotification {