taler-typescript-core

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

commit 1dd74e2e3ed5296822bb12cfb508f05d288a1b78
parent a09dde7fc87e000a341d3255401e12e9d5e0038a
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:29:11 +0200

bank web UI: handle pending and failed MFA challenges

Diffstat:
Mpackages/libeufin-bank-webui/src/context/challenge.ts | 70+++++++++++++++++++++++++++++++---------------------------------------
Mpackages/libeufin-bank-webui/src/pages/SolveMFA.tsx | 120++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
2 files changed, 128 insertions(+), 62 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/context/challenge.ts b/packages/libeufin-bank-webui/src/context/challenge.ts @@ -14,18 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - Challenge, - ChallengeRequestResponse, - ChallengeResponse, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { - AsyncAction, - useBankCoreApiContext, -} from "@gnu-taler/web-util/browser"; +import { ChallengeResponse, TranslatedString } from "@gnu-taler/taler-util"; +import { AsyncAction } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, h, VNode } from "preact"; -import { useContext, useState } from "preact/hooks"; +import { useContext, useRef, useState } from "preact/hooks"; /** * @@ -35,6 +27,7 @@ import { useContext, useState } from "preact/hooks"; export type ContextType = { pending?: MfaState; cancel(): void; + regenerate(manual?: boolean): Promise<boolean>; onNewChallenge( operation: TranslatedString, username: string, @@ -50,6 +43,7 @@ const initial: ContextType = { cancel: () => { throw Error("BankChallengeHandlerProvider not initialized"); }, + regenerate: async () => false, }; const Context = createContext<ContextType>(initial); @@ -58,11 +52,10 @@ export const useBankChallengeHandlerContext = (): ContextType => type MfaState = { requirement: ChallengeResponse; - loadingFirstChallenge: boolean; username: string; title: TranslatedString; retry: AsyncAction<[string[]]>; - initial?: { request: Challenge; response?: ChallengeRequestResponse }; + regenerationCount: number; }; export const BankChallengeHandlerProvider = ({ @@ -71,7 +64,8 @@ export const BankChallengeHandlerProvider = ({ children: ComponentChildren; }): VNode => { const [state, setState] = useState<MfaState>(); - const { lib } = useBankCoreApiContext(); + const regenerationInFlight = useRef<number>(); + const challengeGeneration = useRef(0); /** * Check the response of the handler and @@ -90,47 +84,45 @@ export const BankChallengeHandlerProvider = ({ requirement: ChallengeResponse, handler: AsyncAction<[string[]]>, ) { - const loadingFirstChallenge = - requirement.combi_and === true || requirement.challenges.length === 1; - - // Set the sate now, if "LFC" is true "initial" is undefined it means "loading" + challengeGeneration.current++; setState({ username, title: operation, requirement, retry: handler, - loadingFirstChallenge, + regenerationCount: regenerationInFlight.current ?? 0, }); - - if (loadingFirstChallenge) { - const challenge = requirement.challenges[0]; - const result = await lib.bank.sendChallenge( - username, - challenge.challenge_id, - ); - - setState({ - username, - title: operation, - retry: handler, - requirement, - loadingFirstChallenge, - initial: { - request: challenge, - response: result.type === "ok" ? result.body : undefined, - }, - }); - } } function cancel() { + state?.retry.cancel(); setState(undefined); } + async function regenerate(manual = false): Promise<boolean> { + if (!state || (!manual && state.regenerationCount >= 1)) return false; + const previous = state; + const previousGeneration = challengeGeneration.current; + const regenerationCount = state.regenerationCount + 1; + regenerationInFlight.current = regenerationCount; + setState(undefined); + try { + await previous.retry.run([]); + const restarted = challengeGeneration.current > previousGeneration; + if (!restarted) { + setState({ ...previous, regenerationCount }); + } + return restarted; + } finally { + regenerationInFlight.current = undefined; + } + } + return h(Context.Provider, { value: { pending: state, cancel, + regenerate, onNewChallenge, }, children, diff --git a/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx b/packages/libeufin-bank-webui/src/pages/SolveMFA.tsx @@ -22,7 +22,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { useBankChallengeHandlerContext } from "../context/challenge.js"; import { doAutoFocus } from "./PaytoWireTransferForm.js"; @@ -42,12 +42,14 @@ function SolveChallenge({ onSolved, username, expiration, + onTerminal, }: { onCancel: () => void; challenge: Challenge; expiration: AbsoluteTime; onSolved: () => void; username: string; + onTerminal: (manual?: boolean) => Promise<boolean>; }): VNode { const { i18n } = useTranslationContext(); const [tanCode, setTanCode] = useState<string>(); @@ -59,22 +61,35 @@ function SolveChallenge({ const [showExpired, setExpired] = useState( expiration !== undefined && AbsoluteTime.isExpired(expiration), ); + const [manualRestart, setManualRestart] = useState(false); + const terminalHandled = useRef(false); + + async function handleTerminal(manual = false): Promise<void> { + if (!manual && terminalHandled.current) return; + if (!manual) terminalHandled.current = true; + const restarted = await onTerminal(manual); + if (!restarted) setManualRestart(true); + } const errors = undefinedIfEmpty({ code: !tanCode ? i18n.str`Required` : undefined, }); useEffect(() => { - if (showExpired) return; + if (showExpired) { + void handleTerminal(); + return; + } const remain = AbsoluteTime.remaining(expiration).d_ms; if (remain === "forever") return; const handler = setTimeout(() => { setExpired(true); + void handleTerminal(); }, remain); return () => { clearTimeout(handler); }; - }, []); + }, [expiration.t_ms]); // i18n.str`confirm MFA challenge`, const doVerification = useNotifiedOperation< @@ -85,7 +100,7 @@ function SolveChallenge({ api.confirmChallenge(username, challenge.challenge_id, { tan }), { onSuccess: onSolved, - onFail: showError(i18n.str`Faild to verify the code.`, (fail) => { + onFail: showError(i18n.str`Failed to verify the code.`, (fail) => { switch (fail.case) { case TalerErrorCode.BANK_TRANSACTION_NOT_FOUND: return i18n.str`Unknown challenge.`; @@ -96,6 +111,8 @@ function SolveChallenge({ case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: return i18n.str`Wrong authentication number.`; case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: + setExpired(true); + void handleTerminal(); return i18n.str`Expired challenge.`; default: assertUnreachable(fail); @@ -111,7 +128,7 @@ function SolveChallenge({ <h2 class="text-base font-semibold leading-7 text-gray-900"> <span class="text-sm text-black font-semibold leading-6 " - id="availability-label" + id="dialog-title" > <i18n.Translate> Submit the transmitted code number. @@ -153,7 +170,7 @@ function SolveChallenge({ > <div> <label - for="username" + for={`tan-${challenge.challenge_id}`} class="block text-sm font-medium leading-6 text-gray-900" > <i18n.Translate>Code</i18n.Translate> @@ -162,14 +179,14 @@ function SolveChallenge({ <input ref={doAutoFocus} type="text" - name="username" - id="username" + name="authentication-code" + id={`tan-${challenge.challenge_id}`} 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 ?? ""} enterkeyhint="next" placeholder="T-12345678" - autocomplete="username" - title={i18n.str`Username of the account`} + autocomplete="one-time-code" + title={i18n.str`Authentication code`} required onInput={(e): void => { setTanCode(e.currentTarget.value); @@ -193,12 +210,21 @@ function SolveChallenge({ {showExpired ? ( <p class="text-sm"> <i18n.Translate> - The challenge is expired and can't be solved but you can go - back and create a new challenge. + This challenge is terminal and cannot be retransmitted. </i18n.Translate> </p> ) : undefined} + {manualRestart && ( + <button + type="button" + class="mt-3 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white" + onClick={() => void handleTerminal(true)} + > + <i18n.Translate>Restart authentication</i18n.Translate> + </button> + )} + <div class="mt-6 mb-4 flex justify-between"> <button type="button" @@ -213,7 +239,7 @@ function SolveChallenge({ 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" - disabled={!!errors} + disabled={!!errors || showExpired} onClick={() => doVerification.run(tanCode!)} > <i18n.Translate>Verify</i18n.Translate> @@ -250,6 +276,7 @@ export function SolveChallengeDialog({ <div class="z-40 max-w-7xl text-left"> {!mfa.pending ? undefined : ( <SolveMFAChallenges + key={`${mfa.pending.regenerationCount}:${mfa.pending.requirement.challenges.map((challenge) => challenge.challenge_id).join(":")}`} currentChallenge={mfa.pending.requirement} description={mfa.pending.title} onCancel={mfa.cancel} @@ -275,6 +302,7 @@ function SolveMFAChallenges({ onCancel, }: Props): VNode { const { i18n } = useTranslationContext(); + const mfa = useBankChallengeHandlerContext(); const [solved, setSolved] = useState<string[]>([]); const [selected, setSelected] = useState<{ @@ -291,6 +319,8 @@ function SolveMFAChallenges({ const [retransmission, setRetransmission] = useState< Record<string, AbsoluteTime | undefined> >({}); + const autoSent = useRef(new Set<string>()); + const [clock, setClock] = useState(0); // i18n.str`send MFA challenge`, const sendMessage = useNotifiedOperation< @@ -298,13 +328,14 @@ function SolveMFAChallenges({ [Challenge] >((ct, ch: Challenge) => api.sendChallenge(username, ch.challenge_id), { onSuccess: (success, ch) => { - if (success.earliest_retransmission) { - setRetransmission({ - ...retransmission, + const earliestRetransmission = success.earliest_retransmission; + if (earliestRetransmission) { + setRetransmission((previous) => ({ + ...previous, [ch.challenge_id]: AbsoluteTime.fromProtocolTimestamp( - success.earliest_retransmission, + earliestRetransmission, ), - }); + })); } setSelected({ ch, @@ -340,6 +371,42 @@ function SolveMFAChallenges({ return opEmptySuccess(dummyHttpResponse); }); + const nextAndChallenge = currentChallenge.combi_and + ? currentChallenge.challenges.find( + ({ challenge_id }) => !solved.includes(challenge_id), + ) + : undefined; + + useEffect(() => { + if ( + !nextAndChallenge || + selected || + sendMessage.running || + autoSent.current.has(nextAndChallenge.challenge_id) + ) { + return; + } + autoSent.current.add(nextAndChallenge.challenge_id); + void sendMessage.run(nextAndChallenge); + }, [ + currentChallenge.combi_and, + nextAndChallenge?.challenge_id, + selected?.ch.challenge_id, + sendMessage.running, + ]); + + useEffect(() => { + const waits = Object.values(retransmission) + .map((time) => (time ? AbsoluteTime.remaining(time).d_ms : "forever")) + .filter((time): time is number => time !== "forever" && time > 0); + if (waits.length === 0) return; + const timer = setTimeout( + () => setClock((value) => value + 1), + Math.min(...waits), + ); + return () => clearTimeout(timer); + }, [retransmission, clock]); + if (selected) { return ( <SolveChallenge @@ -347,6 +414,7 @@ function SolveMFAChallenges({ challenge={selected.ch} expiration={selected.expiration} username={username} + onTerminal={(manual) => mfa.regenerate(manual)} onSolved={async () => { setSelected(undefined); const total = [...solved, selected.ch.challenge_id]; @@ -379,7 +447,7 @@ function SolveMFAChallenges({ <h2 class="text-base font-semibold leading-7 text-gray-900"> <span class="text-sm text-black font-semibold leading-6 " - id="availability-label" + id="dialog-title" > <i18n.Translate> Multi-factor authentication required @@ -408,7 +476,7 @@ function SolveMFAChallenges({ </div> <h2 class="text-base leading-7 text-gray-900 "> - <span class="text-sm leading-6 " id="availability-label"> + <span class="text-sm leading-6"> {currentChallenge.challenges.length === 1 ? ( <i18n.Translate> The next challenge needs to be completed to confirm the @@ -427,16 +495,21 @@ function SolveMFAChallenges({ )} </span> </h2> - {currentChallenge.challenges.map((challenge, idx) => { + {currentChallenge.challenges.map((challenge) => { const time = retransmission[challenge.challenge_id] ?? AbsoluteTime.now(); const alreadySent = !AbsoluteTime.isExpired(time); const noNeedToComplete = hasSolvedEnough || - solved.indexOf(challenge.challenge_id) !== -1; + solved.indexOf(challenge.challenge_id) !== -1 || + (currentChallenge.combi_and && + nextAndChallenge?.challenge_id !== challenge.challenge_id); return ( - <div key={idx} class="rounded-xl border px-2 my-2"> + <div + key={challenge.challenge_id} + 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"> @@ -509,6 +582,7 @@ function SolveMFAChallenges({ 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" + disabled={!hasSolvedEnough} onClick={() => onCompleted.run(solved)} > <i18n.Translate>Complete</i18n.Translate>