taler-typescript-core

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

commit aa90a7dedc07ea72817555a9cd461853671f9306
parent 57ab7532fc2d8320f69331563dd4a2e6568ebf72
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu,  2 Oct 2025 15:46:22 -0300

fixes #10427

Diffstat:
Mpackages/merchant-backoffice-ui/src/components/SolveMFA.tsx | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx | 4++--
Mpackages/taler-harness/src/integrationtests/test-donau-minus-t.ts | 2++
Mpackages/taler-harness/src/integrationtests/test-donau-multi.ts | 2++
Mpackages/taler-harness/src/integrationtests/test-donau.ts | 2++
Mpackages/taler-util/src/http-client/merchant.ts | 5++++-
Mpackages/taler-util/src/types-taler-merchant.ts | 24++++++++++++++++++++++++
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;