commit d1a5c5fd433b10536c466509571c4ceb5a560c62 parent 56031c345a5fe5d1acae032115ded6e5599fb64e Author: Sebastian <sebasjm@taler-systems.com> Date: Mon, 2 Mar 2026 16:43:38 -0300 fix #11157 Diffstat:
13 files changed, 150 insertions(+), 27 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx @@ -2,6 +2,7 @@ import { AbsoluteTime, assertUnreachable, Challenge, + ChallengeRequestResponse, ChallengeResponse, HttpStatusCode, opEmptySuccess, @@ -34,6 +35,7 @@ export interface Props { onCancel(): void; currentChallenge: ChallengeResponse; focus?: boolean; + initial?: { request: Challenge; response: ChallengeRequestResponse }; } interface Form { @@ -212,6 +214,7 @@ export function SolveMFAChallenges({ currentChallenge, onCompleted, onCancel, + initial, focus, }: Props): VNode { const { i18n } = useTranslationContext(); @@ -220,17 +223,42 @@ export function SolveMFAChallenges({ const [solved, setSolved] = useState<string[]>([]); // FIXME: we should save here also the expiration of the // tan channel to be used when the user press "i have the code" - const [retransmission, setRetransmission] = useState< - Record<TanChannel, AbsoluteTime> - >({ + + const initialRetrans: Record<TanChannel, AbsoluteTime> = { email: AbsoluteTime.now(), sms: AbsoluteTime.now(), - }); + }; + + if (initial) { + if (initial.response.earliest_retransmission) { + initialRetrans[initial.request.tan_channel] = + AbsoluteTime.fromProtocolTimestamp( + initial.response.earliest_retransmission, + ); + } + } - const [selected, setSelected] = useState<{ + type Selected = { ch: Challenge; expiration: AbsoluteTime; - }>(); + }; + const initialSelected: Selected | undefined = initial + ? ({ + ch: initial.request, + expiration: !initial.response.solve_expiration + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp( + initial.response.solve_expiration, + ), + } as Selected) + : undefined; + + const [retransmission, setRetransmission] = useState(initialRetrans); + + const [selected, setSelected] = useState<Selected | undefined>( + initialSelected, + ); + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const [preferences] = usePreference(); @@ -274,7 +302,6 @@ export function SolveMFAChallenges({ assertUnreachable(fail); } }; - const doComplete = onCompleted.withArgs(solved); const selectChallenge = safeFunctionHandler( i18n.str`select challenge`, diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -55,6 +55,7 @@ import { undefinedIfEmpty } from "../../../utils/table.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../../login/index.js"; import { InputPassword } from "../../../components/form/InputPassword.js"; import { Tooltip } from "../../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../../instance/accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 25; @@ -202,7 +203,7 @@ export function CreatePage({ onConfirm, onBack, forceId }: Props): VNode { create.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized.`; @@ -224,8 +225,10 @@ export function CreatePage({ onConfirm, onBack, forceId }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} onCompleted={retry} onCancel={mfa.doCancelChallenge} + focus /> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx @@ -39,6 +39,7 @@ import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; import { useSessionContext } from "../../../context/session.js"; import { ConfirmModal } from "../../../components/modal/index.js"; import { Tooltip } from "../../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../../instance/accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 28; @@ -72,6 +73,7 @@ export function View({ const { state: session, lib } = useSessionContext(); const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); const deleteAction = safeFunctionHandler( i18n.str`delete instance`, @@ -95,9 +97,11 @@ export function View({ deleteAction.onFail = (fail, t, i, p) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired( + maybeTryFirstMFA( + lib.instance, + mfa, fail.body, - deleteAction.lambda((ids: string[]) => [t, i, p, ids]), + deleteAction.lambda((ids) => [t, i, p, ids]), ); return undefined; case HttpStatusCode.Unauthorized: @@ -113,6 +117,7 @@ export function View({ !session.token || !deleting ? deleteAction : deleteAction.withArgs(session.token, deleting, false, []); + const purge = !session.token || !deleting ? deleteAction @@ -122,6 +127,8 @@ export function View({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={mfa.repeatCall} onCancel={mfa.doCancelChallenge} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/CreatePage.tsx @@ -50,6 +50,7 @@ import { undefinedIfEmpty } from "../../../../utils/table.js"; import { getAvailableForPersona } from "../../../../components/menu/SideBar.js"; import { UIElement, usePreference } from "../../../../hooks/preference.js"; import { Tooltip } from "../../../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../../accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 29; @@ -137,7 +138,7 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { create.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Check the password.`; @@ -159,6 +160,8 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={retry} onCancel={mfa.doCancelChallenge} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx @@ -25,12 +25,15 @@ import { Paytos, Result, TalerMerchantApi, + TalerMerchantManagementHttpClient, assertUnreachable, opEmptySuccess, } from "@gnu-taler/taler-util"; import { ButtonBetterBulma, LocalNotificationBannerBulma, + MfaState, + SafeHandlerTemplate, useChallengeHandler, useLocalNotificationBetter, useTranslationContext, @@ -50,12 +53,12 @@ import { InputToggle } from "../../../../components/form/InputToggle.js"; import { FragmentPersonaFlag } from "../../../../components/menu/SideBar.js"; import { CompareAccountsModal } from "../../../../components/modal/index.js"; import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; +import { Tooltip } from "../../../../components/Tooltip.js"; import { useSessionContext } from "../../../../context/session.js"; import { UIElement, usePreference } from "../../../../hooks/preference.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { safeConvertURL } from "../update/UpdatePage.js"; import { TestRevenueErrorType, testRevenueAPI } from "./index.js"; -import { Tooltip } from "../../../../components/Tooltip.js"; const TALER_SCREEN_ID = 33; @@ -173,7 +176,7 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { add.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized.`; @@ -236,6 +239,8 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={repeat} onCancel={mfa.doCancelChallenge} /> @@ -387,3 +392,25 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { </Fragment> ); } + +export async function maybeTryFirstMFA( + api: TalerMerchantManagementHttpClient, + mfa: MfaState, + b: TalerMerchantApi.ChallengeResponse, + repeat?: SafeHandlerTemplate<[ids: string[]], any>, +) { + if (b.challenges.length < 1) { + mfa.onChallengeRequired(b, repeat); + return; + } + const challenge = b.challenges[0]; + const result = await api.sendChallenge(challenge.challenge_id); + if (result.type === "fail") { + mfa.onChallengeRequired(b, repeat); + } else { + mfa.onChallengeRequiredWithInitial(b, { + request: challenge, + response: result.body, + }, repeat); + } +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx @@ -56,6 +56,7 @@ import { UIElement, usePreference } from "../../../../hooks/preference.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { TestRevenueErrorType, testRevenueAPI } from "../create/index.js"; import { Tooltip } from "../../../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../create/CreatePage.js"; const TALER_SCREEN_ID = 36; @@ -237,7 +238,7 @@ export function UpdatePage({ account, onUpdated, onBack }: Props): VNode { update.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized`; @@ -299,6 +300,8 @@ export function UpdatePage({ account, onUpdated, onBack }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={repeat} onCancel={mfa.doCancelChallenge} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx @@ -39,6 +39,7 @@ import { import { LoginPage, TEMP_TEST_TOKEN } from "../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../notfound/index.js"; import { DetailPage } from "./DetailPage.js"; +import { maybeTryFirstMFA } from "../accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 54; @@ -114,7 +115,7 @@ export default function PasswordPage({ onCancel, onChange }: Props): VNode { changePassword.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized.`; @@ -137,6 +138,8 @@ export default function PasswordPage({ onCancel, onChange }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={retry} onCancel={mfa.doCancelChallenge} /> @@ -235,7 +238,7 @@ export function AdminPassword({ changePassword.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`No enough rights to change the password.`; @@ -255,6 +258,8 @@ export function AdminPassword({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={retry} onCancel={mfa.doCancelChallenge} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx @@ -39,6 +39,7 @@ import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; import { useSessionContext } from "../../../context/session.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; import { Tooltip } from "../../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 73; @@ -90,7 +91,9 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { remove.onFail = (fail, t, p) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired( + maybeTryFirstMFA( + lib.instance, + mfa, fail.body, remove.lambda((ids: string[]) => [t, p, ids]), ); @@ -107,6 +110,8 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={mfa.repeatCall} onCancel={mfa.doCancelChallenge} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx @@ -46,6 +46,7 @@ import { useSessionContext } from "../../../context/session.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; import { CopyButton } from "../../../components/modal/index.js"; import { Tooltip } from "../../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 75; @@ -147,7 +148,7 @@ export function UpdatePage({ update.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized.`; @@ -167,6 +168,8 @@ export function UpdatePage({ return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus onCompleted={retry} onCancel={mfa.doCancelChallenge} /> diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -41,6 +41,7 @@ import { useSessionContext } from "../../context/session.js"; import { FormProvider } from "../../components/form/FormProvider.js"; import { doAutoFocus } from "../../components/form/Input.js"; import { Tooltip } from "../../components/Tooltip.js"; +import { maybeTryFirstMFA } from "../instance/accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 79; @@ -66,7 +67,8 @@ export const FOREVER_REFRESHABLE_TOKEN = (description: TranslatedString) => export function LoginPage({ showCreateAccount, focus }: Props): VNode { const [password, setPassword] = useState(""); - const { state, logIn, getInstanceForUsername, config } = useSessionContext(); + const { state, lib, logIn, getInstanceForUsername, config } = + useSessionContext(); const [username, setUsername] = useState( showCreateAccount ? "" : state.instance, ); @@ -96,7 +98,7 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode { login.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Wrong password.`; @@ -116,6 +118,7 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} onCompleted={retry} onCancel={mfa.doCancelChallenge} focus diff --git a/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx @@ -54,6 +54,7 @@ import { PHONE_JUST_NUMBERS_REGEX, } from "../../utils/constants.js"; import { InputPassword } from "../../components/form/InputPassword.js"; +import { maybeTryFirstMFA } from "../instance/accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 80; @@ -216,7 +217,7 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { create.onFail = (fail) => { switch (fail.case) { case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized`; @@ -233,6 +234,8 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} onCompleted={retry} + initial={mfa.initial} + focus onCancel={mfa.doCancelChallenge} /> ); diff --git a/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx @@ -40,6 +40,7 @@ import { useSessionContext } from "../../context/session.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; import { undefinedIfEmpty } from "../../utils/table.js"; import { InputPassword } from "../../components/form/InputPassword.js"; +import { maybeTryFirstMFA } from "../instance/accounts/create/CreatePage.js"; const TALER_SCREEN_ID = 82; @@ -110,7 +111,7 @@ export function ResetAccount({ case TalerErrorCode.MERCHANT_GENERIC_MFA_MISSING: return i18n.str`The instance is not properly configured to allow MFA.`; case HttpStatusCode.Accepted: - mfa.onChallengeRequired(fail.body); + maybeTryFirstMFA(lib.instance, mfa, fail.body); return undefined; // case HttpStatusCode.Unauthorized: // return i18n.str`Unauthorized.`; @@ -129,6 +130,8 @@ export function ResetAccount({ <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} onCompleted={retry} + initial={mfa.initial} + focus onCancel={mfa.doCancelChallenge} /> ); diff --git a/packages/web-util/src/hooks/useChallenge.ts b/packages/web-util/src/hooks/useChallenge.ts @@ -18,7 +18,11 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { ChallengeResponse } from "@gnu-taler/taler-util"; +import { + Challenge, + ChallengeRequestResponse, + ChallengeResponse, +} from "@gnu-taler/taler-util"; import { useState } from "preact/hooks"; import { SafeHandlerTemplate } from "./useNotifications.js"; @@ -34,7 +38,16 @@ export interface MfaState { */ pendingChallenge: ChallengeResponse | undefined; - onChallengeRequired: (c: ChallengeResponse, repeat?: SafeHandlerTemplate<[ids:string[]], any>) => void; + onChallengeRequired: ( + c: ChallengeResponse, + repeat?: SafeHandlerTemplate<[ids: string[]], any>, + ) => void; + + onChallengeRequiredWithInitial: ( + c: ChallengeResponse, + initial: { request: Challenge; response: ChallengeRequestResponse }, + repeat?: SafeHandlerTemplate<[ids: string[]], any>, + ) => void; /** * Cancel the current pending challenge. * @@ -43,6 +56,8 @@ export interface MfaState { doCancelChallenge: () => void; repeatCall?: SafeHandlerTemplate<[string[]], any>; + + initial?: { request: Challenge; response: ChallengeRequestResponse }; } /** @@ -75,20 +90,36 @@ type CallbackFactory<T extends any[], R> = ( * @returns */ export function useChallengeHandler(): MfaState { - const [state, setState] = useState<{challenge: ChallengeResponse, repeat?: SafeHandlerTemplate<[string[]], any>}>(); + const [state, setState] = useState<{ + challenge: ChallengeResponse; + initial?: { request: Challenge; response: ChallengeRequestResponse }; + repeat?: SafeHandlerTemplate<[string[]], any>; + }>(); function reset() { setState(undefined); } - function onChallengeRequired(challenge: ChallengeResponse, repeat?: SafeHandlerTemplate<[string[]], any>) { - setState({challenge, repeat}) + function onChallengeRequired( + challenge: ChallengeResponse, + repeat?: SafeHandlerTemplate<[string[]], any>, + ) { + setState({ challenge, initial: undefined, repeat }); + } + function onChallengeRequiredWithInitial( + challenge: ChallengeResponse, + initial: { request: Challenge; response: ChallengeRequestResponse }, + repeat?: SafeHandlerTemplate<[string[]], any>, + ) { + setState({ challenge, initial, repeat }); } return { doCancelChallenge: reset, onChallengeRequired, + onChallengeRequiredWithInitial, pendingChallenge: state?.challenge, repeatCall: state?.repeat, + initial: state?.initial, }; }