taler-typescript-core

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

commit 7fd25c6cb00f106e8487283dbeee59b562c382e9
parent 2221a25a7e0ab3c48e79a8d65dc981da5a3ecbb2
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Thu,  5 Mar 2026 14:11:59 -0300

fix #11191

Diffstat:
Mpackages/merchant-backoffice-ui/src/components/SolveMFA.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/merchant-backoffice-ui/src/components/form/InputCode.tsx | 43+++++++++++++++++++++++++++----------------
Mpackages/web-util/src/components/Button.tsx | 10++++++----
3 files changed, 78 insertions(+), 32 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx @@ -28,6 +28,7 @@ import { import { FormErrors, FormProvider } from "./form/FormProvider.js"; import { Input } from "./form/Input.js"; import { InputCode } from "./form/InputCode.js"; +import { time } from "console"; const TALER_SCREEN_ID = 5; @@ -42,6 +43,10 @@ export interface Props { interface Form { code: string; } +interface Tries { + numTries: number; + solvable: boolean; +} function SolveChallenge({ challenge, @@ -57,9 +62,13 @@ function SolveChallenge({ focus?: boolean; }): VNode { const { i18n } = useTranslationContext(); - const { state: session, lib, logIn } = useSessionContext(); + const { lib } = useSessionContext(); const [value, setValue] = useState<Partial<Form>>({}); + const [tries, setTries] = useState<Tries>({ + numTries: 0, + solvable: true, + }); const [showExpired, setExpired] = useState( expiration !== undefined && AbsoluteTime.isExpired(expiration), @@ -92,15 +101,20 @@ function SolveChallenge({ }; setValue(v); } - const data = !value.code || !!errors ? undefined : { tan: value.code }; + 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), - !data ? undefined : [challenge.challenge_id, data], + !tan ? undefined : [challenge.challenge_id, { tan }], ); verify.onSuccess = onSolved; verify.onFail = (fail) => { + setValue({}); + setTries((t) => ({ + numTries: t.numTries + 1, + solvable: fail.case !== TalerErrorCode.MERCHANT_TAN_TOO_MANY_ATTEMPTS, + })); switch (fail.case) { case HttpStatusCode.Unauthorized: return i18n.str`Unauthorized`; @@ -114,6 +128,19 @@ function SolveChallenge({ assertUnreachable(fail); } }; + useEffect(() => { + if (!tan || tries.numTries > 0) { + return; + } + verify.call(); + }, [tan]); + + /** + * We used this computed key so after the code has been tried + * the <InputCode /> is rendered from scratch and the + * uncontrolled input state wipe out + */ + const codeKey = "key" + tries.numTries; return ( <Fragment> @@ -158,12 +185,14 @@ function SolveChallenge({ ); } })()} - + <InputCode<Form> name="code" + key={codeKey} label={i18n.str`Verification code`} size={8} focus + readonly={!tries.solvable} filter={(c) => { const v = Number.parseInt(c, 10); if (Number.isNaN(v) || v > 9 || v < 0) return undefined; @@ -205,7 +234,11 @@ function SolveChallenge({ <button class="button" type="button" onClick={onCancel}> <i18n.Translate>Back</i18n.Translate> </button> - <ButtonBetterBulma type="submit" onClick={verify}> + <ButtonBetterBulma + type="submit" + disabled={!tries.numTries} + onClick={verify} + > <i18n.Translate>Verify</i18n.Translate> </ButtonBetterBulma> </footer> @@ -235,7 +268,7 @@ export function SolveMFAChallenges({ email: AbsoluteTime.now(), sms: AbsoluteTime.now(), }; - + if (initial) { if (initial.response.earliest_retransmission) { initialRetrans[initial.request.tan_channel] = @@ -352,14 +385,14 @@ export function SolveMFAChallenges({ onCompleted.withArgs(total).call(); } else { setSolved(total); - const nextPending = currentChallenge.challenges.find(c => { + 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 - }) + const expired = AbsoluteTime.isExpired(time); + const pending = solved.indexOf(c.challenge_id) === -1; + return pending && expired; + }); if (nextPending) { - sendMessage.withArgs(nextPending).call() + sendMessage.withArgs(nextPending).call(); } } }} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCode.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCode.tsx @@ -23,6 +23,7 @@ import { Tooltip } from "../Tooltip.js"; import { InputProps, useField } from "./useField.js"; import { useRef } from "preact/hooks"; import { doAutoFocus } from "./Input.js"; +import { useEffect } from "preact/hooks"; interface Props<T> extends InputProps<T> { inputExtra?: any; @@ -30,6 +31,7 @@ interface Props<T> extends InputProps<T> { size: number; dashesIndex?: number[]; filter: (c: string) => string | undefined; + disabled?: boolean; } export function InputCode<T>({ @@ -39,6 +41,7 @@ export function InputCode<T>({ label, help, focus, + disabled, inputExtra, size, dashesIndex = [], @@ -51,6 +54,23 @@ export function InputCode<T>({ }).fill(null); const elements = useRef(elementArray); + function result() { + return elements.current.reduce((prev, cur) => { + if (!cur?.value) return prev; + const v = filter(cur.value); + if (!v) return prev; + return prev + v; + }, ""); + } + function checkAutoFocus() { + // wait until all ref loaded + const allRefHasLoaded = elements.current.every((e) => e !== null); + if (!allRefHasLoaded) return; + // only when this field doesn't have values + if (result().length > 0) return; + // focus on the first + doAutoFocus(elements.current[0]); + } return ( <div class="field is-horizontal"> @@ -89,9 +109,7 @@ export function InputCode<T>({ // ref={focus && idx === 0 ? doAutoFocus : undefined} ref={(el) => { elements.current[idx] = el; - if (focus && idx === 0 && !value) { - doAutoFocus(el); - } + if (focus) checkAutoFocus(); }} class={ error ? "input is-danger mfa-code" : "input mfa-code" @@ -124,29 +142,22 @@ export function InputCode<T>({ name={String(name)} onFocus={(e) => { e.preventDefault(); - e.currentTarget.select() + e.currentTarget.select(); }} onChange={(e) => { e.preventDefault(); - 2 const v = filter(e.currentTarget.value); e.currentTarget.value = v ?? ""; - console.log("v", v) if (v === undefined) { - console.log("undef") - onChange(undefined as any) + onChange(undefined as any); return; } - elements.current[idx + 1]?.focus(); - for (const e of elements.current) { - if (!e?.value) break; + + if (idx < elements.current.length) { + elements.current[idx + 1]?.focus(); } - const total = elements.current.reduce((prev, cur) => { - if (!cur?.value) return prev; - return prev + cur?.value; - }, ""); - console.log("total ",total) + const total = result(); if (total.length === size) { onChange(total as any); } diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx @@ -72,8 +72,8 @@ export function Button({ } type PropsBetter = Omit< - Omit<Omit<HTMLAttributes<HTMLButtonElement>, "type">, "onClick">, - "disabled" + Omit<HTMLAttributes<HTMLButtonElement>, "type">, + "onClick" > & { type: "button" | "submit"; onClick: SafeHandlerTemplate<any, any> | undefined; @@ -88,13 +88,14 @@ export function ButtonBetter({ children, focus, onClick, + disabled, ...rest }: PropsBetter): VNode { const [running, setRunning] = useState(false); return ( <button {...rest} - disabled={running || !onClick || !onClick.args} + disabled={running || !onClick || !onClick.args || disabled} ref={focus ? doAutoFocus : undefined} onClick={(e) => { e.preventDefault(); @@ -120,6 +121,7 @@ export function ButtonBetter({ export function ButtonBetterBulma({ children, focus, + disabled, onClick, ...rest }: PropsBetter & { "data-tooltip"?: string }): VNode { @@ -129,7 +131,7 @@ export function ButtonBetterBulma({ class="button is-success" {...rest} ref={focus ? doAutoFocus : undefined} - disabled={running || !onClick || !onClick.args} + disabled={running || !onClick || !onClick.args || disabled} onClick={(e) => { e.preventDefault(); if (!onClick || !onClick.args) {