taler-typescript-core

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

commit 0060f260e31d5fb6c35b3d403ae550c32dbd0838
parent 078e80e311bfe21dca5e66e7ed2e8ecd594f9b5d
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Wed, 11 Mar 2026 16:12:08 -0300

fix #11193

Diffstat:
Mpackages/merchant-backoffice-ui/src/components/SolveMFA.tsx | 364+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx | 3++-
Mpackages/merchant-backoffice-ui/src/paths/newAccount/index.tsx | 5+++--
3 files changed, 240 insertions(+), 132 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx @@ -5,15 +5,15 @@ import { ChallengeRequestResponse, ChallengeResponse, HttpStatusCode, - opEmptySuccess, TalerErrorCode, - TanChannel, + TanChannel } from "@gnu-taler/taler-util"; import { ButtonBetterBulma, LocalNotificationBannerBulma, SafeHandlerTemplate, undefinedIfEmpty, + useCommonPreferences, useLocalNotificationBetter, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -47,9 +47,83 @@ interface Tries { solvable: boolean; } +function RetransmissionCodeLimitExpiration({ + expiration, +}: { + expiration: AbsoluteTime; +}): VNode { + const { i18n } = useTranslationContext(); + const remain = AbsoluteTime.remaining(expiration); + const [id, rerender] = useState(0); + const [preferences] = usePreference(); + + const timeToHalfMinute = remain.d_ms === "forever" ? -1 : remain.d_ms - 30000; + const timeToMinute = remain.d_ms === "forever" ? -1 : remain.d_ms - 60000; + const timeToFiveMinutes = + remain.d_ms === "forever" ? -1 : remain.d_ms - 60000 * 5; + const reRenderInMs = + timeToFiveMinutes > 0 + ? timeToFiveMinutes + : timeToMinute > 0 + ? timeToMinute + : timeToHalfMinute > 0 + ? timeToHalfMinute + : 1000; + useEffect(() => { + setTimeout(() => { + rerender(Date.now()); + }, reRenderInMs); + }, [id]); + if ( + remain.d_ms === "forever" || + expiration.t_ms === "never" || + remain.d_ms < 1001 + ) + return <Fragment />; + if (remain.d_ms < 30000) { + return ( + <p class="help"> + <i18n.Translate> + Resend code possible in {Math.floor(remain.d_ms / 1000)} seconds. + </i18n.Translate> + </p> + ); + } + if (remain.d_ms < 60000) { + return ( + <p class="help"> + <i18n.Translate> + Resend code possible in less than one minute. + </i18n.Translate> + </p> + ); + } + if (remain.d_ms < 1000 * 60 * 5) { + return ( + <p class="help"> + <i18n.Translate> + Resend code possible in less than 5 minutes. + </i18n.Translate> + </p> + ); + } + return ( + <p class="help"> + <i18n.Translate> + Resend will be possible after{" "} + <span> + {format(expiration.t_ms, datetimeFormatForPreferences(preferences))} + </span> + </i18n.Translate> + </p> + ); +} + + function SolveChallenge({ challenge, expiration, + retransmission, onCancel, onSolved, focus, @@ -58,6 +132,7 @@ function SolveChallenge({ onCancel: () => void; challenge: Challenge; expiration: AbsoluteTime; + retransmission: AbsoluteTime; onSolved: () => void; focus?: boolean; showFull: Partial<Record<TanChannel, string>>; @@ -70,19 +145,36 @@ function SolveChallenge({ numTries: 0, solvable: true, }); + const [{ showDebugInfo }] = useCommonPreferences(); + const [currentExpiration, setCurrentExpiration] = useState(expiration); const [showExpired, setExpired] = useState( - expiration !== undefined && AbsoluteTime.isExpired(expiration), + AbsoluteTime.isExpired(currentExpiration), ); - const [preferences] = usePreference(); - + const remainExpiration = AbsoluteTime.remaining(currentExpiration).d_ms; useEffect(() => { if (showExpired) return; - const remain = AbsoluteTime.remaining(expiration).d_ms; - if (remain === "forever") return; + if (remainExpiration === "forever") return; const handler = setTimeout(() => { setExpired(true); - }, remain); + }, remainExpiration); + return () => { + clearTimeout(handler); + }; + }, []); + + const [currentRetransmission, setCurrentRetransmission] = + useState(retransmission); + const [showResend, setResend] = useState( + AbsoluteTime.isExpired(currentRetransmission), + ); + const remainResend = AbsoluteTime.remaining(currentRetransmission).d_ms; + useEffect(() => { + if (showResend) return; + if (remainResend === "forever") return; + const handler = setTimeout(() => { + setResend(true); + }, remainResend); return () => { clearTimeout(handler); }; @@ -104,6 +196,7 @@ function SolveChallenge({ } const tan = !value.code || !!errors ? undefined : value.code; const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const verify = safeFunctionHandler( i18n.str`verify code`, lib.instance.confirmChallenge.bind(lib.instance), @@ -129,6 +222,45 @@ function SolveChallenge({ assertUnreachable(fail); } }; + + const resend = safeFunctionHandler( + i18n.str`send challenge`, + () => lib.instance.sendChallenge(challenge.challenge_id), + !showResend ? undefined : [], + ); + + resend.onSuccess = (success) => { + setCurrentRetransmission( + !success.earliest_retransmission + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(success.earliest_retransmission), + ); + setCurrentExpiration( + !success.solve_expiration + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(success.solve_expiration), + ); + }; + + resend.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Failed to send the verification code.`; + case HttpStatusCode.Forbidden: + return i18n.str`The request was valid, but the server is refusing action.`; + case TalerErrorCode.MERCHANT_TAN_CHALLENGE_UNKNOWN: + return i18n.str`The backend is not aware of the specified MFA challenge.`; + case TalerErrorCode.MERCHANT_TAN_MFA_HELPER_EXEC_FAILED: + return i18n.str`Code transmission failed.`; + case TalerErrorCode.MERCHANT_TAN_CHALLENGE_SOLVED: + return i18n.str`Already solved.`; + case TalerErrorCode.MERCHANT_TAN_TOO_EARLY: + return i18n.str`It is too early to request another transmission of the challenge.`; + default: + assertUnreachable(fail); + } + }; + useEffect(() => { if (!tan || tries.numTries > 0) { return; @@ -147,6 +279,25 @@ function SolveChallenge({ <Fragment> <LocalNotificationBannerBulma notification={notification} /> + {showDebugInfo ? ( + <pre> + {JSON.stringify( + { + retransmission: + currentRetransmission.t_ms === "never" + ? "never" + : new Date(currentRetransmission.t_ms).toISOString(), + expiration: + currentExpiration.t_ms === "never" + ? "never" + : new Date(currentExpiration.t_ms).toISOString(), + }, + undefined, + 2, + )} + </pre> + ) : undefined} + <div class="columns is-centered" style={{ margin: "auto" }}> <div class="column is-two-thirds "> <div class="modal-card" style={{ width: "100%", margin: 0 }}> @@ -168,77 +319,85 @@ function SolveChallenge({ class="modal-card-body" style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} > - {(function (): VNode { - switch (challenge.tan_channel) { - case TanChannel.SMS: - if (showFull[TanChannel.SMS]) { + <p> + {(function (): VNode { + switch (challenge.tan_channel) { + case TanChannel.SMS: + if (showFull[TanChannel.SMS]) { + return ( + <i18n.Translate> + The verification code sent to the phone " + <b>{showFull[TanChannel.SMS]}</b>" + </i18n.Translate> + ); + } return ( <i18n.Translate> - The verification code sent to the phone " - <b>{showFull[TanChannel.SMS]}</b>" + The verification code sent to the phone number + ending with "<b>{challenge.tan_info}</b>" </i18n.Translate> ); - } - return ( - <i18n.Translate> - The verification code sent to the phone number ending - with "<b>{challenge.tan_info}</b>" - </i18n.Translate> - ); - case TanChannel.EMAIL: - if (showFull[TanChannel.EMAIL]) { + case TanChannel.EMAIL: + if (showFull[TanChannel.EMAIL]) { + return ( + <i18n.Translate> + The verification code sent to the email address " + <b>{showFull[TanChannel.EMAIL]}</b>" + </i18n.Translate> + ); + } return ( <i18n.Translate> - The verification code sent to the email address " - <b>{showFull[TanChannel.EMAIL]}</b>" + The verification code sent to the email address + starting with "<b>{challenge.tan_info}</b>" </i18n.Translate> ); - } - return ( - <i18n.Translate> - The verification code sent to the email address - starting with "<b>{challenge.tan_info}</b>" - </i18n.Translate> - ); - } - })()} + } + })()} + </p> <InputCode<Form> name="code" key={codeKey} label={i18n.str`Verification code`} + dashesIndex={[3]} size={8} - focus - readonly={!tries.solvable} + focus={focus} + readonly={!tries.solvable || showExpired} filter={(c) => { const v = Number.parseInt(c, 10); if (Number.isNaN(v) || v > 9 || v < 0) return undefined; return String(v); }} - dashesIndex={[3]} /> - {expiration.t_ms === "never" ? undefined : ( - <p> - <i18n.Translate> - It will expire at{" "} - <span> - {format( - expiration.t_ms, - datetimeFormatForPreferences(preferences), - )} - </span> - </i18n.Translate> - </p> - )} {showExpired ? ( - <p> - <i18n.Translate> - The challenge is expired and can't be solved but you can - go back and create a new challenge. - </i18n.Translate> - </p> + <div style={{ display: "flex" }}> + <p + class="has-text-danger" + style={{ alignContent: "center", marginRight: 8 }} + > + <i18n.Translate>Code expired.</i18n.Translate> + </p> + </div> ) : undefined} + <div> + <div style={{ display: "flex" }}> + <p style={{ alignContent: "center", marginRight: 8 }}> + <i18n.Translate>Didn't received the code?</i18n.Translate> + </p> + <ButtonBetterBulma + class="button" + type="button" + onClick={resend} + > + <i18n.Translate>Resend</i18n.Translate> + </ButtonBetterBulma> + </div> + </div> + <RetransmissionCodeLimitExpiration + expiration={currentRetransmission} + /> </section> <footer class="modal-card-foot " @@ -249,7 +408,7 @@ function SolveChallenge({ }} > <button class="button" type="button" onClick={onCancel}> - <i18n.Translate>Back</i18n.Translate> + <i18n.Translate>Cancel</i18n.Translate> </button> <ButtonBetterBulma type="submit" @@ -276,29 +435,16 @@ export function SolveMFAChallenges({ showFull, }: Props): VNode { const { i18n } = useTranslationContext(); - const { state: session, lib, logIn } = useSessionContext(); + const { lib } = useSessionContext(); 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 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, - ); - } - } - type Selected = { ch: Challenge; expiration: AbsoluteTime; + retransmission: AbsoluteTime; }; const initialSelected: Selected | undefined = initial ? ({ @@ -308,17 +454,21 @@ export function SolveMFAChallenges({ : AbsoluteTime.fromProtocolTimestamp( initial.response.solve_expiration, ), + retransmission: !initial.response.earliest_retransmission + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp( + initial.response.earliest_retransmission, + ), } as Selected) : undefined; - const [retransmission, setRetransmission] = useState(initialRetrans); + // const [retransmission, setRetransmission] = useState(initialRetrans); const [selected, setSelected] = useState<Selected | undefined>( initialSelected, ); const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const [preferences] = usePreference(); const sendMessage = safeFunctionHandler( i18n.str`send challenge`, @@ -326,16 +476,11 @@ export function SolveMFAChallenges({ ); sendMessage.onSuccess = (success, ch) => { - if (success.earliest_retransmission) { - setRetransmission({ - ...retransmission, - [ch.tan_channel]: AbsoluteTime.fromProtocolTimestamp( - success.earliest_retransmission, - ), - }); - } setSelected({ ch, + retransmission: !success.earliest_retransmission + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(success.earliest_retransmission), expiration: !success.solve_expiration ? AbsoluteTime.never() : AbsoluteTime.fromProtocolTimestamp(success.solve_expiration), @@ -361,23 +506,13 @@ export function SolveMFAChallenges({ } }; - const selectChallenge = safeFunctionHandler( - i18n.str`select challenge`, - async (ch: Challenge) => { - setSelected({ - ch, - expiration: AbsoluteTime.never(), - }); - return opEmptySuccess(); - }, - ); - if (selected) { return ( <SolveChallenge - onCancel={() => setSelected(undefined)} + onCancel={onCancel} challenge={selected.ch} expiration={selected.expiration} + retransmission={selected.retransmission} focus={focus} showFull={showFull ?? {}} onSolved={async () => { @@ -391,10 +526,8 @@ export function SolveMFAChallenges({ } else { setSolved(total); const nextPending = currentChallenge.challenges.find((c) => { - const time = retransmission[c.tan_channel]; - const expired = AbsoluteTime.isExpired(time); const pending = solved.indexOf(c.challenge_id) === -1; - return pending && expired; + return pending; }); if (nextPending) { sendMessage.withArgs(nextPending).call(); @@ -441,20 +574,13 @@ export function SolveMFAChallenges({ )} </section> {currentChallenge.challenges.map((challenge, idx) => { - const time = retransmission[challenge.tan_channel]; - const alreadySent = !AbsoluteTime.isExpired(time); const noNeedToComplete = hasSolvedEnough || solved.indexOf(challenge.challenge_id) !== -1; - const doSelect = noNeedToComplete - ? selectChallenge - : selectChallenge.withArgs(challenge); - - const doSend = - alreadySent || noNeedToComplete - ? sendMessage - : sendMessage.withArgs(challenge); + const doSend = noNeedToComplete + ? sendMessage + : sendMessage.withArgs(challenge); return ( <section @@ -480,39 +606,19 @@ export function SolveMFAChallenges({ } })(challenge.tan_channel)} - {alreadySent && time.t_ms !== "never" ? ( - <p> - <i18n.Translate> - You have to wait until{" "} - <span> - {format( - time.t_ms, - datetimeFormatForPreferences(preferences), - )} - </span>{" "} - to receive a new code. - </i18n.Translate> - </p> - ) : undefined} <div style={{ justifyContent: "space-between", display: "flex", }} > - <ButtonBetterBulma - type="button" - class="button" - onClick={doSelect} - > - <i18n.Translate>I have a code</i18n.Translate> - </ButtonBetterBulma> + <div /> <ButtonBetterBulma type="button" onClick={doSend} focus={idx === 0 && focus} > - <i18n.Translate>Send me a message</i18n.Translate> + <i18n.Translate>Continue</i18n.Translate> </ButtonBetterBulma> </div> </section> 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 @@ -403,7 +403,8 @@ export async function maybeTryFirstMFA( b: TalerMerchantApi.ChallengeResponse, repeat?: SafeHandlerTemplate<[ids: string[]], any>, ) { - if (b.challenges.length < 1) { + const letUserDecide = b.combi_and === false && b.challenges.length > 1 + if (b.challenges.length === 0 || letUserDecide) { mfa.onChallengeRequired(b, repeat); return; } diff --git a/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx @@ -15,6 +15,7 @@ */ import { + AbsoluteTime, assertUnreachable, buildCodecForObject, Codec, @@ -205,11 +206,11 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { const resp = await lib.instance.createInstanceSelfProvision(req, { challengeIds, tokenValidity: Duration.fromSpec({ months: 6 }), - }) + }); if (resp.type === "fail" && resp.case === HttpStatusCode.Accepted) { await maybeTryFirstMFA(lib.instance, mfa, resp.body); } - return resp + return resp; }, !!errors ? undefined : [request, []], );