commit 7fd25c6cb00f106e8487283dbeee59b562c382e9
parent 2221a25a7e0ab3c48e79a8d65dc981da5a3ecbb2
Author: Sebastian <sebasjm@taler-systems.com>
Date: Thu, 5 Mar 2026 14:11:59 -0300
fix #11191
Diffstat:
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) {