commit 0060f260e31d5fb6c35b3d403ae550c32dbd0838
parent 078e80e311bfe21dca5e66e7ed2e8ecd594f9b5d
Author: Sebastian <sebasjm@taler-systems.com>
Date: Wed, 11 Mar 2026 16:12:08 -0300
fix #11193
Diffstat:
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, []],
);