taler-typescript-core

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

commit 4359c2a9da120afca3901e04baa27403bdc7cafe
parent c405f5e36043699d20f3c45680e0e589a7622bc0
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri, 19 Sep 2025 12:31:32 -0300

mfa handler as hook

Diffstat:
Apackages/merchant-backoffice-ui/src/hooks/challenge.ts | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/admin/create/index.tsx | 107+++++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/admin/list/index.tsx | 105++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx | 93++++++++++++++++++++++++++++++++++++-------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx | 77+++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx | 117+++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/password/index.tsx | 237++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx | 111+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/update/index.tsx | 77+++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/login/index.tsx | 118++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/newAccount/index.tsx | 102+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx | 96++++++++++++++++++++++++++++++++++++++++----------------------------------------
12 files changed, 741 insertions(+), 643 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/hooks/challenge.ts b/packages/merchant-backoffice-ui/src/hooks/challenge.ts @@ -0,0 +1,144 @@ +/* + 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 @@ -18,20 +18,20 @@ * @author Sebastian Javier Marchano (sebasjm) */ import { - ChallengeResponse, HttpStatusCode, - TalerMerchantApi, + InstanceConfigurationMessage, + TalerMerchantApi } from "@gnu-taler/taler-util"; import { 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"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; import { useSessionContext } from "../../../context/session.js"; -import { usePreference } from "../../../hooks/preference.js"; import { Notification } from "../../../utils/types.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../../login/index.js"; import { CreatePage } from "./CreatePage.js"; -import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; +import { useChallengeHandler } from "../../../hooks/challenge.js"; interface Props { onBack?: () => void; @@ -44,63 +44,56 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { lib, state, logIn } = useSessionContext(); - const [currentChallenge, setCurrentChallenge] = useState< - | [ChallengeResponse, TalerMerchantApi.InstanceConfigurationMessage] - | undefined - >(); - - async function doCreateImpl( - d: TalerMerchantApi.InstanceConfigurationMessage, - challengeIds: string[] | undefined, - ) { - if (state.status !== "loggedIn") return; - try { - const resp = await lib.instance.createInstance(state.token, d, { - challengeIds, - }); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge([resp.body, d]); - return; - } - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - if (d.auth.password) { - //if auth has been updated, request a new access token - const result = await lib.instance.createAccessToken( - d.id, - d.auth.password, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Instance created`), - ); - if (result.type === "ok") { - const { access_token: token } = result.body; - logIn(state.instance, token); + const mfa = useChallengeHandler<[InstanceConfigurationMessage]>( + ({ challengeIds, onChallengeRequired }) => + async (d) => { + if (state.status !== "loggedIn") return; + try { + const resp = await lib.instance.createInstance(state.token, d, { + challengeIds, + }); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + setNotif({ + message: i18n.str`Failed to create instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + if (d.auth.password) { + //if auth has been updated, request a new access token + const result = await lib.instance.createAccessToken( + d.id, + d.auth.password, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Instance created`), + ); + if (result.type === "ok") { + const { access_token: token } = result.body; + logIn(state.instance, token); + } + } + onConfirm(); + } catch (error) { + setNotif({ + message: i18n.str`Failed to create instance`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); } - } - onConfirm(); - } catch (error) { - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => doCreateImpl(currentChallenge[1], ids)} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -112,7 +105,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { <CreatePage onBack={onBack} forceId={forceId} - onCreate={(d) => doCreateImpl(d, undefined)} + onCreate={mfa.doFirstCall} /> </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 @@ -39,6 +39,7 @@ 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; @@ -58,9 +59,6 @@ export default function Instances({ const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { state, lib } = useSessionContext(); - const [currentChallenge, setCurrentChallenge] = useState< - ChallengeResponse | undefined - >(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -77,59 +75,58 @@ export default function Instances({ } } - async function doDeleteImpl( - challengeIds: undefined | string[], - ): Promise<void> { - if (state.status !== "loggedIn") { - return; - } - try { - const resp = await lib.instance.deleteInstance( - state.token, - deleting!.id, - { challengeIds }, - ); - if (resp.type === "ok") { - setNotif({ - message: i18n.str`Instance "${deleting!.name}" (ID: ${ - deleting!.id - }) has been deleted`, - type: "SUCCESS", - }); - } else { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge(resp.body); + const mfa = useChallengeHandler<[]>( + ({ challengeIds, onChallengeRequired }) => + async function doDeleteImpl(): Promise<void> { + if (state.status !== "loggedIn") { return; } - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - } - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - // pushNotification({message: 'delete_error', type: 'ERROR' }) - } - setDeleting(undefined); + try { + const resp = await lib.instance.deleteInstance( + state.token, + deleting!.id, + { challengeIds }, + ); + if (resp.type === "ok") { + setNotif({ + message: i18n.str`Instance "${deleting!.name}" (ID: ${ + deleting!.id + }) has been deleted`, + type: "SUCCESS", + }); + } else { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + setNotif({ + message: i18n.str`Failed to delete instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete instance`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + // pushNotification({message: 'delete_error', type: 'ERROR' }) + } + setDeleting(undefined); + }, + ); + + if (mfa.pendingChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} + /> + ); } - if (currentChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={currentChallenge} - onCompleted={doDeleteImpl} - onCancel={() => { - setCurrentChallenge(undefined); - }} - /> - ); - } - return ( <Fragment> <NotificationCard notification={notif} /> @@ -153,13 +150,13 @@ export default function Instances({ <PurgeModal element={deleting} onCancel={() => setDeleting(undefined)} - onConfirm={() => doDeleteImpl(undefined)} + onConfirm={mfa.doFirstCall} /> ) : ( <DeleteModal element={deleting} onCancel={() => setDeleting(undefined)} - onConfirm={() => doDeleteImpl(undefined)} + onConfirm={mfa.doFirstCall} /> ))} </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 @@ -34,6 +34,7 @@ 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 { @@ -50,57 +51,50 @@ export default function AccessTokenCreatePage({ const { i18n } = useTranslationContext(); const [ok, setOk] = useState<{ token: string; expiration: AbsoluteTime }>(); - const [currentChallenge, setCurrentChallenge] = useState< - [ChallengeResponse, pwd: string, req: Entity] | undefined - >(); - - async function doCreateImpl( - pwd: string, - request: Entity, - challengeIds: string[] | undefined, - ) { - try { - const resp = await lib.instance.createAccessToken( - state.instance, - pwd, - request, - { challengeIds }, - ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge([resp.body, pwd, request]); - return; + const mfa = useChallengeHandler<[string, Entity]>( + ({ challengeIds, onChallengeRequired }) => + async function doCreateImpl(pwd, request) { + try { + const resp = await lib.instance.createAccessToken( + state.instance, + pwd, + request, + { challengeIds }, + ); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + setNotif({ + message: i18n.str`Could not create access token`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + setOk({ + expiration: AbsoluteTime.fromProtocolTimestamp( + resp.body.expiration, + ), + token: resp.body.access_token, + }); + } catch (error) { + setNotif({ + message: i18n.str`Could not create access token`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); } - setNotif({ - message: i18n.str`Could not create access token`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - setOk({ - expiration: AbsoluteTime.fromProtocolTimestamp(resp.body.expiration), - token: resp.body.access_token, - }); - } catch (error) { - setNotif({ - message: i18n.str`Could not create access token`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => - doCreateImpl(currentChallenge[1], currentChallenge[2], ids) - } - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -152,10 +146,7 @@ export default function AccessTokenCreatePage({ </div> </ConfirmModal> )} - <CreatePage - onBack={onBack} - onCreate={(a, b) => doCreateImpl(a, b, undefined)} - /> + <CreatePage onBack={onBack} onCreate={mfa.doFirstCall} /> </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 @@ -48,6 +48,7 @@ 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 { @@ -60,48 +61,43 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const [currentChallenge, setCurrentChallenge] = useState< - [ChallengeResponse, Entity] | undefined - >(); - - async function doCreateImpl( - request: Entity, - challengeIds: string[] | undefined, - ) { - try { - const resp = await lib.instance.addBankAccount(state.token, request, { - challengeIds, - }); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge([resp.body, request]); - return; + + const mfa = useChallengeHandler<[Entity]>( + ({ challengeIds, onChallengeRequired }) => + async function doCreateImpl(request) { + try { + const resp = await lib.instance.addBankAccount(state.token, request, { + challengeIds, + }); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + onConfirm(); + } catch (error) { + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); } - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - onConfirm(); - } catch (error) { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => doCreateImpl(currentChallenge[1], ids)} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -109,10 +105,7 @@ export default function CreateValidator({ onConfirm, onBack }: Props): VNode { return ( <> <NotificationCard notification={notif} /> - <CreatePage - onBack={onBack} - onCreate={(e) => doCreateImpl(e, undefined)} - /> + <CreatePage onBack={onBack} onCreate={mfa.doFirstCall} /> </> ); } 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 @@ -20,6 +20,8 @@ */ import { + AccountAddDetails, + BankAccountDetail, ChallengeResponse, HttpStatusCode, TalerError, @@ -40,6 +42,7 @@ 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; @@ -58,9 +61,6 @@ export default function UpdateValidator({ const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const [currentChallenge, setCurrentChallenge] = useState< - [ChallengeResponse, TalerMerchantApi.BankAccountDetail, TalerMerchantApi.AccountAddDetails] | undefined - >(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -103,67 +103,64 @@ export default function UpdateValidator({ }); } - async function doReplaceImpl( - prev: TalerMerchantApi.BankAccountDetail, - next: TalerMerchantApi.AccountAddDetails, - challengeIds: undefined | string[], - ) { - try { - const resp = await lib.instance.addBankAccount(state.token, next, { - challengeIds, - }); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge([resp.body, prev, next]); + const mfa = useChallengeHandler<[BankAccountDetail, AccountAddDetails]>( + ({ challengeIds, onChallengeRequired }) => + async function doReplaceImpl(prev, next) { + try { + const resp = await lib.instance.addBankAccount(state.token, next, { + challengeIds, + }); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + } catch (error) { + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); return; } - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - } catch (error) { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - return; - } - try { - const resp = await lib.instance.deleteBankAccount( - state.token, - prev.h_wire, - ); - if (resp.type === "fail") { - setNotif({ - message: i18n.str`Could not delete account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - } catch (error) { - setNotif({ - message: i18n.str`Could not delete account`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - return; - } - onConfirm(); - } + try { + const resp = await lib.instance.deleteBankAccount( + state.token, + prev.h_wire, + ); + if (resp.type === "fail") { + setNotif({ + message: i18n.str`Could not delete account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + } catch (error) { + setNotif({ + message: i18n.str`Could not delete account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + return; + } + onConfirm(); + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => doReplaceImpl(currentChallenge[1], currentChallenge[2], ids)} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -175,7 +172,7 @@ export default function UpdateValidator({ account={{ ...result.body, id: bid }} onBack={onBack} onUpdate={doUpdateImpl} - onReplace={(prev, next) => doReplaceImpl(prev, next, undefined)} + onReplace={mfa.doFirstCall} /> </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 @@ -42,6 +42,7 @@ 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; @@ -49,160 +50,152 @@ export interface Props { } export default function PasswordPage(props: Props): VNode { - const { lib, state, logIn } = useSessionContext(); + const { lib, state } = useSessionContext(); const result = useInstanceDetails(); const instanceId = state.instance; - const { i18n } = useTranslationContext(); - const [settings] = usePreference(); - const [currentChallenge, setCurrentChallenge] = useState< - [ChallengeResponse, string] | undefined - >(); + const mfa = useChallengeHandler<[string]>( + ({ challengeIds, onChallengeRequired }) => + async function changePassword( + // currentPassword: string | undefined, + newPassword: string, + ) { + // if (currentPassword) { + // const resp = await lib.instance.createAccessToken( + // instanceId, + // currentPassword, + // TEMP_TEST_TOKEN(i18n.str`Testing password`), + // ); + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // if (resp.type !== "ok") { + // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // } + // } - async function changePassword( - // currentPassword: string | undefined, - newPassword: string, - challengeIds: undefined | string[], - ) { - // if (currentPassword) { - // const resp = await lib.instance.createAccessToken( - // instanceId, - // currentPassword, - // TEMP_TEST_TOKEN(i18n.str`Testing password`), - // ); - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // if (resp.type !== "ok") { - // throw Error(resp.detail?.hint ?? "The current password is wrong"); - // } - // } - - { - const resp = await lib.instance.updateCurrentInstanceAuthentication( - state.token, { - password: newPassword, - method: MerchantAuthMethod.TOKEN, - }, - { challengeIds }, - ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge([resp.body, newPassword]); - return; + const resp = await lib.instance.updateCurrentInstanceAuthentication( + state.token, + { + password: newPassword, + method: MerchantAuthMethod.TOKEN, + }, + { challengeIds }, + ); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + throw Error(resp.detail?.hint ?? "The request failed"); + } } - throw Error(resp.detail?.hint ?? "The request failed"); - } - } - // const resp = await lib.instance.createAccessToken( - // instanceId, - // newPassword, - // FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), - // ); - // if (resp.type === "ok") { - // logIn(state.instance, resp.body.access_token); - // return; - // } else { - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // throw Error(resp.detail?.hint ?? "The new login failed"); - // } - } - if (currentChallenge) { + // const resp = await lib.instance.createAccessToken( + // instanceId, + // newPassword, + // FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), + // ); + // if (resp.type === "ok") { + // logIn(state.instance, resp.body.access_token); + // return; + // } else { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The new login failed"); + // } + }, + ); + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => changePassword(currentChallenge[1], ids)} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } - return CommonPassword({ ...props, instanceId }, result, changePassword); + return CommonPassword({ ...props, instanceId }, result, mfa.doFirstCall); } export function AdminPassword(props: Props & { instanceId: string }): VNode { const { lib, state } = useSessionContext(); - const { i18n } = useTranslationContext(); - const [settings] = usePreference(); const subInstanceLib = lib.subInstanceApi(props.instanceId).instance; const result = useManagedInstanceDetails(props.instanceId); const instanceId = props.instanceId; - const [currentChallenge, setCurrentChallenge] = useState< - [ChallengeResponse, string] | undefined - >(); - - async function changePassword( - // currentPassword: string | undefined, - newPassword: string, - challengeIds: undefined | string[], - ) { - // if (currentPassword) { - // const resp = await lib.instance.createAccessToken( - // instanceId, - // currentPassword, - // TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), - // ); - // if (resp.type !== "ok") { - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // throw Error(resp.detail?.hint ?? "The current password is wrong"); - // } - // } + const mfa = useChallengeHandler<[string]>( + ({ challengeIds, onChallengeRequired }) => + async function changePassword( + // currentPassword: string | undefined, + newPassword: string, + ) { + // if (currentPassword) { + // const resp = await lib.instance.createAccessToken( + // instanceId, + // currentPassword, + // TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), + // ); + // if (resp.type !== "ok") { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // } + // } - { - const resp = await lib.instance.updateInstanceAuthentication( - state.token, - props.instanceId, { - password: newPassword, - method: MerchantAuthMethod.TOKEN, - }, - { challengeIds }, - ); - if (resp.type === "fail") { - throw Error(resp.detail?.hint ?? "The request failed"); - } - } - // const resp = await subInstanceLib.createAccessToken( - // instanceId, - // newPassword, - // FOREVER_REFRESHABLE_TOKEN( - // i18n.str`Password changed for instance ${instanceId}`, - // ), - // ); - // if (resp.type === "ok") { - // return; - // } else { - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // throw Error(resp.detail?.hint ?? "The new login failed"); - // } - } + const resp = await lib.instance.updateInstanceAuthentication( + state.token, + props.instanceId, + { + password: newPassword, + method: MerchantAuthMethod.TOKEN, + }, + { challengeIds }, + ); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + return; + } + throw Error(resp.detail?.hint ?? "The request failed"); + } + } + // const resp = await subInstanceLib.createAccessToken( + // instanceId, + // newPassword, + // FOREVER_REFRESHABLE_TOKEN( + // i18n.str`Password changed for instance ${instanceId}`, + // ), + // ); + // if (resp.type === "ok") { + // return; + // } else { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The new login failed"); + // } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => changePassword(currentChallenge[1], ids)} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } - return CommonPassword(props, result, changePassword); + return CommonPassword(props, result, mfa.doFirstCall); } 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 @@ -35,6 +35,7 @@ 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; @@ -53,9 +54,6 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { purge: false, }); const { i18n } = useTranslationContext(); - const [currentChallenge, setCurrentChallenge] = useState< - ChallengeResponse | undefined - >(); const { state: session, lib, logOut } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); @@ -71,66 +69,67 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { const text = i18n.str`You are deleting the instance with ID "${instanceId}"`; - async function doDeleteImpl(challengeIds: undefined | string[]) { - if (hasErrors) return; - try { - const resp = await lib.instance.deleteCurrentInstance(session.token, { - purge: form.purge, - challengeIds, - }); - if (resp.type === "ok") { - logOut() - return onDeleted(); - } - switch (resp.case) { - case HttpStatusCode.Accepted: { - setCurrentChallenge(resp.body); - return; - } - case HttpStatusCode.Unauthorized: { - setNotif({ - message: i18n.str`Failed to delete the instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - case HttpStatusCode.NotFound: { - setNotif({ - message: i18n.str`Failed to delete the instance`, - type: "ERROR", - description: resp.detail?.hint, + const mfa = useChallengeHandler<[]>( + ({ challengeIds, onChallengeRequired }) => + async function doDeleteImpl() { + if (hasErrors) return; + try { + const resp = await lib.instance.deleteCurrentInstance(session.token, { + purge: form.purge, + challengeIds, }); - return; - } - case HttpStatusCode.Conflict: { + if (resp.type === "ok") { + logOut() + return onDeleted(); + } + switch (resp.case) { + case HttpStatusCode.Accepted: { + onChallengeRequired(resp.body); + return; + } + case HttpStatusCode.Unauthorized: { + setNotif({ + message: i18n.str`Failed to delete the instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Failed to delete the instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case HttpStatusCode.Conflict: { + setNotif({ + message: i18n.str`Failed to delete the instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + default: { + assertUnreachable(resp); + } + } + } catch (error) { setNotif({ - message: i18n.str`Failed to delete the instance`, + message: i18n.str`Failed to delete the instance.`, type: "ERROR", - description: resp.detail?.hint, + description: error instanceof Error ? error.message : undefined, }); - return; - } - default: { - assertUnreachable(resp); } } - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete the instance.`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - } - } - if (currentChallenge) { + ); + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge} - onCompleted={doDeleteImpl} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -182,7 +181,7 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { ? i18n.str`Please complete the marked fields` : i18n.str`Confirm operation` } - onClick={() => doDeleteImpl(undefined)} + onClick={mfa.doFirstCall} > <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 @@ -39,6 +39,7 @@ 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; @@ -71,10 +72,6 @@ function CommonUpdate( const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { state } = useSessionContext(); - const [currentChallenge, setCurrentChallenge] = useState< - | [ChallengeResponse, TalerMerchantApi.InstanceReconfigurationMessage] - | undefined - >(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -94,53 +91,53 @@ function CommonUpdate( } } - async function doUpdateImpl( - d: TalerMerchantApi.InstanceReconfigurationMessage, - challengeIds: undefined | string[], - ) { - if (state.status !== "loggedIn") { - return; - } - try { - const resp = await updateInstance(state.token, d, { challengeIds }); - if (resp.type === "ok") { - return onConfirm(); - } - switch (resp.case) { - case HttpStatusCode.Accepted: { - setCurrentChallenge([resp.body, d]); + const mfa = useChallengeHandler< + [TalerMerchantApi.InstanceReconfigurationMessage] + >( + ({ challengeIds, onChallengeRequired }) => + async function doUpdateImpl(d) { + if (state.status !== "loggedIn") { return; } - case HttpStatusCode.Unauthorized: - case HttpStatusCode.NotFound: { - setNotif({ + try { + const resp = await updateInstance(state.token, d, { challengeIds }); + if (resp.type === "ok") { + return onConfirm(); + } + switch (resp.case) { + case HttpStatusCode.Accepted: { + onChallengeRequired(resp.body); + return; + } + case HttpStatusCode.Unauthorized: + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Failed to update instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + } + } catch (error) { + return setNotif({ message: i18n.str`Failed to update instance`, type: "ERROR", - description: resp.detail?.hint, + description: error instanceof Error ? error.message : String(error), }); } - } - } catch (error) { - return setNotif({ - message: i18n.str`Failed to update instance`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - } + }, + ); + const [deleting, setDeleting] = useState<boolean>(); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge[0]} - onCompleted={(ids) => doUpdateImpl(currentChallenge[1], ids)} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } - const [deleting, setDeleting] = useState<boolean>(); return ( <Fragment> @@ -149,7 +146,7 @@ function CommonUpdate( onBack={onBack} isLoading={false} selected={result.body} - onUpdate={async (d) => doUpdateImpl(d, undefined)} + onUpdate={mfa.doFirstCall} /> <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 @@ -21,12 +21,11 @@ import { assertUnreachable, - ChallengeResponse, Duration, HttpStatusCode, LoginTokenRequest, LoginTokenScope, - TranslatedString, + TranslatedString } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -35,8 +34,8 @@ import { AsyncButton } from "../../components/exception/AsyncButton.js"; import { NotificationCard } from "../../components/menu/index.js"; import { SolveMFAChallenges } from "../../components/SolveMFA.js"; import { useSessionContext } from "../../context/session.js"; -import { usePreference } from "../../hooks/preference.js"; import { Notification } from "../../utils/types.js"; +import { useChallengeHandler } from "../../hooks/challenge.js"; interface Props {} @@ -60,71 +59,68 @@ export function LoginPage(_p: Props): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { state, logIn, getInstanceForUsername, config } = useSessionContext(); const [username, setUsername] = useState(state.instance); - const [settings] = usePreference(); - const [currentChallenge, setCurrentChallenge] = useState< - ChallengeResponse | undefined - >(); const { i18n } = useTranslationContext(); - async function doLoginImpl(challengeIds: string[] | undefined) { - const api = getInstanceForUsername(username); + const mfa = useChallengeHandler( + ({ challengeIds, onChallengeRequired }) => + async () => { + const api = getInstanceForUsername(username); - try { - const result = await api.createAccessToken( - username, - password, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Logged in`), - { - challengeIds, - }, - ); - if (result.type === "ok") { - const { access_token: token } = result.body; - logIn(username, token); - return; - } else { - switch (result.case) { - case HttpStatusCode.Unauthorized: { - setNotif({ - message: i18n.str`Your password is incorrect`, - type: "ERROR", - }); + try { + const result = await api.createAccessToken( + username, + password, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Logged in`), + { + challengeIds, + }, + ); + if (result.type === "ok") { + const { access_token: token } = result.body; + logIn(username, token); return; + } else { + switch (result.case) { + case HttpStatusCode.Unauthorized: { + setNotif({ + message: i18n.str`Your password is incorrect`, + type: "ERROR", + }); + return; + } + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Your instance cannot be found`, + type: "ERROR", + }); + return; + } + case HttpStatusCode.Accepted: { + onChallengeRequired(result.body); + return; + } + default: { + assertUnreachable(result); + } + } } - case HttpStatusCode.NotFound: { - setNotif({ - message: i18n.str`Your instance cannot be found`, - type: "ERROR", - }); - return; - } - case HttpStatusCode.Accepted: { - setCurrentChallenge(result.body); - return; - } - default: { - assertUnreachable(result); - } + } catch (error) { + setNotif({ + message: i18n.str`Failed to login.`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); } - } - } catch (error) { - setNotif({ - message: i18n.str`Failed to login.`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - } - } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge} - onCompleted={doLoginImpl} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -166,7 +162,7 @@ export function LoginPage(_p: Props): VNode { placeholder={"instance name"} name="username" onKeyPress={(e) => - e.keyCode === 13 ? doLoginImpl(undefined) : null + e.keyCode === 13 ? mfa.doFirstCall() : null } value={username} onInput={(e): void => @@ -192,7 +188,7 @@ export function LoginPage(_p: Props): VNode { placeholder={"current password"} name="token" onKeyPress={(e) => - e.keyCode === 13 ? doLoginImpl(undefined) : null + e.keyCode === 13 ? mfa.doFirstCall() : null } value={password} onInput={(e): void => @@ -229,7 +225,7 @@ export function LoginPage(_p: Props): VNode { )} <AsyncButton disabled={!username || !password} - onClick={() => doLoginImpl(undefined)} + onClick={mfa.doFirstCall} > <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> diff --git a/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx @@ -39,6 +39,7 @@ import { 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"; export interface Account { id: string; @@ -56,12 +57,8 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { const { i18n } = useTranslationContext(); const { state: session, lib, logIn } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const [currentChallenge, setCurrentChallenge] = useState< - ChallengeResponse | undefined - >(); - const [value, setValue] = useState<Partial<Account>>({ - }); + const [value, setValue] = useState<Partial<Account>>({}); const errors: FormErrors<Account> = { id: !value.id ? i18n.str`Required` @@ -102,61 +99,62 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { setValue(v); } - async function doCreateImpl(challengeIds: string[] | undefined) { - try { - const resp = await lib.instance.createInstanceSelfProvision( - { - address: {}, - auth: { - method: MerchantAuthMethod.TOKEN, - password: value.password!, - }, - default_pay_delay: { - d_us: 1000, - }, - default_wire_transfer_delay: { - d_us: 1000, - }, - id: value.id!, - jurisdiction: {}, - name: value.name!, - use_stefan: true, - email: value.email!, - phone_number: value.phone, - }, - { challengeIds }, - ); + const mfa = useChallengeHandler<[]>( + ({ challengeIds, onChallengeRequired }) => + async function doCreateImpl() { + try { + const resp = await lib.instance.createInstanceSelfProvision( + { + address: {}, + auth: { + method: MerchantAuthMethod.TOKEN, + password: value.password!, + }, + default_pay_delay: { + d_us: 1000, + }, + default_wire_transfer_delay: { + d_us: 1000, + }, + id: value.id!, + jurisdiction: {}, + name: value.name!, + use_stefan: true, + email: value.email!, + phone_number: value.phone, + }, + { challengeIds }, + ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge(resp.body); - } else { + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + } else { + setNotif({ + message: i18n.str`Failed to create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + return; + } + onCreated(); + } catch (error) { setNotif({ message: i18n.str`Failed to create account`, type: "ERROR", - description: resp.detail?.hint, + description: error instanceof Error ? error.message : String(error), }); } - return; - } - onCreated(); - } catch (error) { - setNotif({ - message: i18n.str`Failed to create account`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge} - onCompleted={doCreateImpl} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -238,7 +236,7 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { <AsyncButton type="is-info" disabled={!errors} - onClick={() => doCreateImpl(undefined)} + onClick={mfa.doFirstCall} > <i18n.Translate>Create</i18n.Translate> </AsyncButton> diff --git a/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx @@ -33,6 +33,7 @@ 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; @@ -56,9 +57,6 @@ export function ResetAccount({ // password: "asd", // repeat: "asd", }); - const [currentChallenge, setCurrentChallenge] = useState< - ChallengeResponse | undefined - >(); const errors: FormErrors<Form> = { password: !value.password ? i18n.str`Required` : undefined, @@ -77,58 +75,60 @@ export function ResetAccount({ }; setValue(v); } - - async function doResetImpl(challengeIds: string[] | undefined) { - try { - const resp = await lib.subInstanceApi(instanceId).instance.forgotPasswordSelfProvision( - { - method: MerchantAuthMethod.TOKEN, - password: value.password!, - }, - { - challengeIds, - }, - ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - setCurrentChallenge(resp.body); - } else { + const mfa = useChallengeHandler<[]>( + ({ challengeIds, onChallengeRequired }) => + async function doResetImpl() { + try { + const resp = await lib + .subInstanceApi(instanceId) + .instance.forgotPasswordSelfProvision( + { + method: MerchantAuthMethod.TOKEN, + password: value.password!, + }, + { + challengeIds, + }, + ); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + onChallengeRequired(resp.body); + } else { + setNotif({ + message: i18n.str`Failed to create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + return; + } + //if auth has been updated, request a new access token + const result = await lib.instance.createAccessToken( + instanceId, + value.password!, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Password reset`), + ); + if (result.type === "ok") { + const { access_token: token } = result.body; + logIn(instanceId, token); + } + onReseted(); + } catch (error) { setNotif({ message: i18n.str`Failed to create account`, type: "ERROR", - description: resp.detail?.hint, + description: error instanceof Error ? error.message : String(error), }); } - return; - } - //if auth has been updated, request a new access token - const result = await lib.instance.createAccessToken( - instanceId, - value.password!, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Password reset`), - ); - if (result.type === "ok") { - const { access_token: token } = result.body; - logIn(instanceId, token); - } - onReseted(); - } catch (error) { - setNotif({ - message: i18n.str`Failed to create account`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - } + }, + ); - if (currentChallenge) { + if (mfa.pendingChallenge) { return ( <SolveMFAChallenges - currentChallenge={currentChallenge} - onCompleted={doResetImpl} - onCancel={() => { - setCurrentChallenge(undefined); - }} + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.doRetryWithConfimation} + onCancel={mfa.doCancelChallenge} /> ); } @@ -185,7 +185,7 @@ export function ResetAccount({ </button> <AsyncButton disabled={!errors} - onClick={() => doResetImpl(undefined)} + onClick={mfa.doFirstCall} > <i18n.Translate>Reset</i18n.Translate> </AsyncButton>