commit aa90a7dedc07ea72817555a9cd461853671f9306
parent 57ab7532fc2d8320f69331563dd4a2e6568ebf72
Author: Sebastian <sebasjm@gmail.com>
Date: Thu, 2 Oct 2025 15:46:22 -0300
fixes #10427
Diffstat:
7 files changed, 145 insertions(+), 12 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx
@@ -1,23 +1,32 @@
import {
+ AbsoluteTime,
assertUnreachable,
Challenge,
+ ChallengeRequestResponse,
ChallengeResponse,
+ Duration,
HttpStatusCode,
TalerErrorCode,
TanChannel,
} from "@gnu-taler/taler-util";
import {
+ Time,
undefinedIfEmpty,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
-import { useState } from "preact/hooks";
+import { useEffect, useState } from "preact/hooks";
import { useSessionContext } from "../context/session.js";
import { Notification } from "../utils/types.js";
import { AsyncButton } from "./exception/AsyncButton.js";
import { FormErrors, FormProvider } from "./form/FormProvider.js";
import { Input } from "./form/Input.js";
import { NotificationCard } from "./menu/index.js";
+import {
+ datetimeFormatForSettings,
+ usePreference,
+} from "../hooks/preference.js";
+import { format } from "date-fns";
export interface Props {
onCompleted(challenges: string[]): void;
@@ -31,19 +40,43 @@ interface Form {
function SolveChallenge({
challenge,
+ expiration,
onCancel,
onSolved,
}: {
onCancel: () => void;
challenge: Challenge;
+ expiration: AbsoluteTime;
onSolved: () => void;
}): VNode {
const { i18n } = useTranslationContext();
const { state: session, lib, logIn } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const [value, setValue] = useState<Partial<Form>>({});
+
+ const [showExpired, setExpired] = useState(
+ expiration !== undefined && AbsoluteTime.isExpired(expiration),
+ );
+ const [settings] = usePreference();
+
+ useEffect(() => {
+ if (showExpired) return;
+ const remain = AbsoluteTime.remaining(expiration).d_ms;
+ if (remain === "forever") return;
+ const handler = setTimeout(() => {
+ setExpired(true);
+ }, remain);
+ return () => {
+ clearTimeout(handler);
+ };
+ }, []);
+
const errors = undefinedIfEmpty<FormErrors<Form>>({
- code: !value.code ? i18n.str`Required` : undefined,
+ code: showExpired
+ ? i18n.str`Expired`
+ : !value.code
+ ? i18n.str`Required`
+ : undefined,
});
function valueHandler(s: (d: Partial<Form>) => Partial<Form>): void {
const next = s(value);
@@ -150,8 +183,31 @@ function SolveChallenge({
object={value}
valueHandler={valueHandler}
>
- <Input<Form> label={i18n.str`Verification code`} name="code" />
+ <Input<Form>
+ label={i18n.str`Verification code`}
+ name="code"
+ readonly={showExpired}
+ />
</FormProvider>
+ {expiration.t_ms === "never" ? undefined : (
+ <p>
+ <i18n.Translate>
+ It will expired at{" "}
+ {format(
+ expiration.t_ms,
+ datetimeFormatForSettings(settings),
+ )}
+ </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>
+ ) : undefined}
</section>
<footer
class="modal-card-foot "
@@ -188,16 +244,30 @@ export function SolveMFAChallenges({
const { state: session, lib, logIn } = useSessionContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const [solved, setSolved] = useState<string[]>([]);
- const [selected, setSelected] = useState<Challenge>();
+ // FIXME: we should save here also the expiration of the
+ // tan channel to be used when the user press "i have the code"
+ const [retransmission, setRetransmission] = useState<
+ Record<TanChannel, AbsoluteTime>
+ >({
+ email: AbsoluteTime.now(),
+ sms: AbsoluteTime.now(),
+ });
+
+ const [selected, setSelected] = useState<{
+ ch: Challenge;
+ expiration: AbsoluteTime;
+ }>();
+ const [settings] = usePreference();
if (selected) {
return (
<SolveChallenge
onCancel={() => setSelected(undefined)}
- challenge={selected}
+ challenge={selected.ch}
+ expiration={selected.expiration}
onSolved={() => {
setSelected(undefined);
- setSolved([...solved, selected.challenge_id]);
+ setSolved([...solved, selected.ch.challenge_id]);
}}
/>
);
@@ -211,7 +281,20 @@ export function SolveMFAChallenges({
try {
const resp = await lib.instance.sendChallenge(ch.challenge_id);
if (resp.case === "ok") {
- return setSelected(ch);
+ if (resp.body.earliest_retransmission) {
+ setRetransmission({
+ ...retransmission,
+ [ch.tan_channel]: AbsoluteTime.fromProtocolTimestamp(
+ resp.body.earliest_retransmission,
+ ),
+ });
+ }
+ return setSelected({
+ ch,
+ expiration: !resp.body.solve_expiration
+ ? AbsoluteTime.never()
+ : AbsoluteTime.fromProtocolTimestamp(resp.body.solve_expiration),
+ });
}
switch (resp.case) {
case HttpStatusCode.Unauthorized: {
@@ -307,6 +390,9 @@ export function SolveMFAChallenges({
)}
</section>
{currentChallenge.challenges.map((d) => {
+ const time = retransmission[d.tan_channel];
+ const alreadySent = !AbsoluteTime.isExpired(time);
+
return (
<section
class="modal-card-body"
@@ -330,6 +416,15 @@ export function SolveMFAChallenges({
}
})()}
+ {alreadySent && time.t_ms !== "never" ? (
+ <p>
+ <i18n.Translate>
+ You have to wait until{" "}
+ {format(time.t_ms, datetimeFormatForSettings(settings))}
+ to send a new code.
+ </i18n.Translate>
+ </p>
+ ) : undefined}
<div
style={{
justifyContent: "space-between",
@@ -342,14 +437,19 @@ export function SolveMFAChallenges({
}
class="button"
onClick={() => {
- setSelected(d);
+ setSelected({
+ ch: d,
+ expiration: AbsoluteTime.never(),
+ });
}}
>
<i18n.Translate>I have a code</i18n.Translate>
</button>
<AsyncButton
disabled={
- hasSolvedEnough || solved.indexOf(d.challenge_id) !== -1
+ hasSolvedEnough ||
+ solved.indexOf(d.challenge_id) !== -1 ||
+ alreadySent
}
onClick={() => doSendCodeImpl(d)}
>
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx
@@ -90,12 +90,12 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode {
// }),
// ),
email: !value.email
- ? i18n.str`Required`
+ ? undefined
: !EMAIL_REGEX.test(value.email)
? i18n.str`Doesn't have the pattern of an email`
: undefined,
phone_number: !value.phone_number
- ? i18n.str`Required`
+ ? undefined
: !value.phone_number.startsWith("+")
? i18n.str`Should start with +`
: !PHONE_JUST_NUMBERS_REGEX.test(value.phone_number)
diff --git a/packages/taler-harness/src/integrationtests/test-donau-minus-t.ts b/packages/taler-harness/src/integrationtests/test-donau-minus-t.ts
@@ -205,3 +205,4 @@ export async function runDonauMinusTTest(t: GlobalTestState) {
}
runDonauMinusTTest.suites = ["donau"];
+runDonauMinusTTest.experimental = true
+\ No newline at end of file
diff --git a/packages/taler-harness/src/integrationtests/test-donau-multi.ts b/packages/taler-harness/src/integrationtests/test-donau-multi.ts
@@ -275,3 +275,4 @@ export async function runDonauMultiTest(t: GlobalTestState) {
}
runDonauMultiTest.suites = ["donau"];
+runDonauMultiTest.experimental = true
+\ No newline at end of file
diff --git a/packages/taler-harness/src/integrationtests/test-donau.ts b/packages/taler-harness/src/integrationtests/test-donau.ts
@@ -240,3 +240,4 @@ export async function runDonauTest(t: GlobalTestState) {
}
runDonauTest.suites = ["donau"];
+runDonauTest.experimental = true
+\ No newline at end of file
diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts
@@ -38,6 +38,7 @@ import {
codecForBankAccountDetail,
codecForCategoryListResponse,
codecForCategoryProductList,
+ codecForChallengeRequestResponse,
codecForChallengeResponse,
codecForClaimResponse,
codecForInstancesResponse,
@@ -2529,7 +2530,9 @@ export class TalerMerchantInstanceHttpClient {
});
switch (resp.status) {
case HttpStatusCode.NoContent:
- return opEmptySuccess();
+ return opSuccessFromHttp(resp, codecForChallengeRequestResponse());
+ case HttpStatusCode.Ok:
+ return opSuccessFromHttp(resp, codecForChallengeRequestResponse());
case HttpStatusCode.Unauthorized:
return opKnownHttpFailure(resp.status, resp);
case HttpStatusCode.Forbidden:
diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts
@@ -4425,6 +4425,30 @@ export const codecForChallengeResponse = (): Codec<ChallengeResponse> =>
.property("combi_and", codecForBoolean())
.build("MFA.ChallengeResponse");
+export interface ChallengeRequestResponse {
+ // FIXME: this response fields are mandatory but
+ // put it optional from the client side to
+ // handle server that doesn't support this response.
+ // Remove it when all server has been upgraded
+
+ /**
+ * How long does the client have to solve the challenge.
+ */
+ solve_expiration?: Timestamp;
+
+ /**
+ * What is the earlist time at which the client may request a new challenge to be transmitted?
+ */
+ earliest_retransmission?: Timestamp;
+}
+
+export const codecForChallengeRequestResponse =
+ (): Codec<ChallengeRequestResponse> =>
+ buildCodecForObject<ChallengeRequestResponse>()
+ .property("solve_expiration", codecOptional(codecForTimestamp))
+ .property("earliest_retransmission", codecOptional(codecForTimestamp))
+ .build("MFA.ChallengeRequestResponse");
+
export interface ChallengeSolveRequest {
// The TAN code that solves $CHALLENGE_ID.
tan: string;