taler-typescript-core

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

commit fe400a8ea8e6be287cf2654d9c512301770b2f2f
parent 242669f6ae6599dfc8b1dfe07e2354208ee75b0c
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Thu,  4 Jun 2026 13:25:37 -0300

fix #11474

Diffstat:
Mpackages/merchant-backoffice-ui/src/Application.tsx | 28++++++----------------------
Mpackages/merchant-backoffice-ui/src/components/SolveMFA.tsx | 500+++++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/merchant-backoffice-ui/src/components/modal/index.tsx | 112+++++--------------------------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/context/challenge.ts | 12++++++------
4 files changed, 250 insertions(+), 402 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx @@ -34,7 +34,7 @@ import { TalerWalletIntegrationBrowserProvider, ToastBannerBulma, TranslationProvider, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -100,6 +100,7 @@ import { buildDefaultBackendBaseURL, fetchSettings, } from "./settings.js"; +import { SolveChallengeDialog } from "./components/SolveMFA.js"; const TALER_SCREEN_ID = 2; const WITH_LOCAL_STORAGE_CACHE = false; @@ -162,10 +163,9 @@ function SubApp({ baseUrl }: { baseUrl: string }) { <TalerWalletIntegrationBrowserProvider> <BrowserHashNavigationProvider> <CurrenciesProvider> - <ToastBannerBulma/> - {/* <ShowNotificationsDialog> */} - <Routing /> - {/* </ShowNotificationsDialog> */} + <ToastBannerBulma /> + <SolveChallengeDialog /> + <Routing /> </CurrenciesProvider> </BrowserHashNavigationProvider> </TalerWalletIntegrationBrowserProvider> @@ -176,20 +176,6 @@ function SubApp({ baseUrl }: { baseUrl: string }) { ); } -export function ShowNotificationsDialog({ - children, -}: { - children: ComponentChildren; -}): VNode { - console.log("asdasd") - return ( - <Fragment> - <ToastBannerBulma /> - <Fragment key="childs">{children}</Fragment> - </Fragment> - ); -} - function getInitialBackendBaseURL( backendFromSettings: string | undefined, ): string { @@ -242,9 +228,7 @@ function OnConfigError({ children }: { children: ComponentChildren }): VNode { notification={{ message: i18n.str`Contacting the server failed`, type: "ERROR", - description: <Fragment> - {children} - </Fragment> + description: <Fragment>{children}</Fragment>, }} /> </div> diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx @@ -7,9 +7,11 @@ import { HttpStatusCode, TalerErrorCode, TanChannel, + TranslatedString, } from "@gnu-taler/taler-util"; import { Button, + MfaState, SafeHandler, undefinedIfEmpty, useNotificationContext, @@ -18,7 +20,10 @@ import { import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { useMerchantChallengeHandlerContext } from "../context/challenge.js"; +import { + ChallengeState, + useMerchantChallengeHandlerContext, +} from "../context/challenge.js"; import { useSessionContext } from "../context/session.js"; import { datetimeFormatForPreferences, @@ -26,15 +31,15 @@ import { } from "../hooks/preference.js"; import { FormErrors, FormProvider } from "./form/FormProvider.js"; import { InputCode } from "./form/InputCode.js"; +import { ConfirmModal, SimpleModal } from "./modal/index.js"; +import { Loading, Spinner } from "./exception/loading.js"; const TALER_SCREEN_ID = 5; export interface Props { - onCompleted: SafeHandler<[challenges: string[]], any>; onCancel(): void; - currentChallenge: ChallengeResponse; focus?: boolean; - initial?: { request: Challenge; response?: ChallengeRequestResponse }; + state: ChallengeState; showFull?: Partial<Record<TanChannel, string>>; } @@ -195,7 +200,9 @@ function SolveChallenge({ const verify = actionHandler( /*verify code*/ - (ct, id, b) => lib.instance.confirmChallenge(id, b), + async (ct, id, b) => { + return lib.instance.confirmChallenge(id, b); + }, !tan ? undefined : ([challenge.challenge_id, { tan }] as const), ); verify.onSuccess = onSolved; @@ -272,190 +279,166 @@ function SolveChallenge({ const codeKey = "key" + tries.numTries; return ( - <Fragment> - <div class="columns is-centered" style={{ margin: "auto" }}> - <div class="column is-two-thirds "> - <FormProvider<Form> - name="settings" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <header - class="modal-card-head" - style={{ border: "1px solid", borderBottom: 0 }} - > - <p class="modal-card-title"> - <i18n.Translate>Validation code sent.</i18n.Translate> - </p> - </header> - <section - class="modal-card-body" - style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} - > - <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 number ending - with "<b>{challenge.tan_info}</b>" - </i18n.Translate> - ); - 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 - 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={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); - }} - /> - - {showExpired ? ( - <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> - <Button class="button" onClick={resend}> - <i18n.Translate>Resend</i18n.Translate> - </Button> - </div> - </div> - <RetransmissionCodeLimitExpiration - expiration={currentRetransmission} - /> - </section> - <footer - class="modal-card-foot " - style={{ - justifyContent: "space-between", - border: "1px solid", - borderTop: 0, - }} + <ConfirmModal + label={i18n.str`Verify`} + description={i18n.str`Validation code sent.`} + active + onCancel={onCancel} + confirm={!tries.numTries ? undefined : verify} // first try is going to be automatically + > + <FormProvider<Form> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <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 number ending with " + <b>{challenge.tan_info}</b>" + </i18n.Translate> + ); + 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 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={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); + }} + /> + + {showExpired ? ( + <div style={{ display: "flex" }}> + <p + class="has-text-danger" + style={{ alignContent: "center", marginRight: 8 }} > - <button class="button" type="button" onClick={onCancel}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - <Button - class="button is-success" - submit - disabled={!tries.numTries} - onClick={verify} - > - <i18n.Translate>Verify</i18n.Translate> - </Button> - </footer> - </FormProvider> + <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> + <Button class="button" onClick={resend}> + <i18n.Translate>Resend</i18n.Translate> + </Button> + </div> </div> - </div> - </Fragment> + <RetransmissionCodeLimitExpiration expiration={currentRetransmission} /> + </FormProvider> + </ConfirmModal> ); } export function SolveChallengeDialog({}: {}): VNode { const mfa = useMerchantChallengeHandlerContext(); + const { i18n } = useTranslationContext(); if (!mfa.pending) return <Fragment />; - return ( - <Fragment> - <SolveMFAChallenges - currentChallenge={mfa.pending.requirement} - initial={mfa.pending.initial} - focus - onCompleted={mfa.pending.retry} - onCancel={mfa.cancel} - /> - </Fragment> - ); + if (mfa.pending.loadingFirstChallenge) { + return ( + <ConfirmModal + active + noCancelButton + description={i18n.str`Loading first challenge...`} + label={"asd" as TranslatedString} + > + <div class="columns is-centered is-vcentered"> + <Spinner /> + </div> + </ConfirmModal> + ); + } + return <SolveMFAChallenges state={mfa.pending} focus onCancel={mfa.cancel} />; +} + +type Active = { + ch: Challenge; + expiration: AbsoluteTime; + retransmission: AbsoluteTime; +}; + +function getActiveFromInitial( + req: Challenge, + res: ChallengeRequestResponse, +): Active { + return { + ch: req, + expiration: !res.solve_expiration + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(res.solve_expiration), + retransmission: !res.earliest_retransmission + ? AbsoluteTime.never() + : AbsoluteTime.fromProtocolTimestamp(res.earliest_retransmission), + }; } function SolveMFAChallenges({ - currentChallenge, - onCompleted, onCancel, - initial, focus, + state, showFull, }: Props): VNode { const { i18n } = useTranslationContext(); 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 { initial, requirement, retry } = state; - type Active = { - ch: Challenge; - expiration: AbsoluteTime; - retransmission: AbsoluteTime; - }; const initialActive: Active | undefined = initial && initial.response - ? ({ - ch: initial.request, - expiration: !initial.response.solve_expiration - ? AbsoluteTime.never() - : AbsoluteTime.fromProtocolTimestamp( - initial.response.solve_expiration, - ), - retransmission: !initial.response.earliest_retransmission - ? AbsoluteTime.never() - : AbsoluteTime.fromProtocolTimestamp( - initial.response.earliest_retransmission, - ), - } as Active) + ? getActiveFromInitial( + requirement.challenges[initial.currentChallengeIndex], + initial.response, + ) : undefined; - // const [retransmission, setRetransmission] = useState(initialRetrans); - const [active, setActive] = useState<Active | undefined>(initialActive); + const [loadingNext, setLoadingNext] = useState(false); const defaultSelected = - currentChallenge.challenges.length > 0 - ? currentChallenge.challenges[0] - : undefined; + requirement.challenges.length > 0 ? requirement.challenges[0] : undefined; const [selectedChallenge, setSelectedChallenge] = useState(defaultSelected); const { actionHandler, showError } = useNotificationContext(); @@ -496,8 +479,8 @@ function SolveMFAChallenges({ } }); - const hasSolvedEnough = currentChallenge.combi_and - ? solved.length === currentChallenge.challenges.length + const hasSolvedEnough = requirement.combi_and + ? solved.length === requirement.challenges.length : solved.length > 0; const doSend = @@ -505,6 +488,20 @@ function SolveMFAChallenges({ ? sendMessage : sendMessage.withArgs(selectedChallenge); + if (loadingNext) { + return ( + <ConfirmModal + active + noCancelButton + description={i18n.str`Loading next challenge...`} + label={"asd" as TranslatedString} + > + <div class="columns is-centered is-vcentered"> + <Spinner /> + </div> + </ConfirmModal> + ); + } if (active) { return ( <SolveChallenge @@ -517,22 +514,24 @@ function SolveMFAChallenges({ showFull={showFull ?? {}} onSolved={async () => { const total = [...solved, active.ch.challenge_id]; - const enough = currentChallenge.combi_and - ? total.length === currentChallenge.challenges.length + const enough = requirement.combi_and + ? total.length === requirement.challenges.length : total.length > 0; if (enough) { setSolved(total); setActive(undefined); - await onCompleted.withArgs(total).call(); + await retry.withArgs(total).call(); } else { setSolved(total); - const nextPending = currentChallenge.challenges.find((c) => { + const nextPending = requirement.challenges.find((c) => { const pending = total.indexOf(c.challenge_id) === -1; return pending; }); if (nextPending) { + setLoadingNext(true) await sendMessage.withArgs(nextPending).call(); + setLoadingNext(false) } else { setActive(undefined); } @@ -542,112 +541,75 @@ function SolveMFAChallenges({ ); } - const enough = currentChallenge.combi_and - ? solved.length === currentChallenge.challenges.length + const enough = requirement.combi_and + ? solved.length === requirement.challenges.length : solved.length > 0; return ( - <Fragment> - <div class="columns is-centered" style={{ margin: "auto" }}> - <div class="column is-two-thirds "> - <header - class="modal-card-head" - style={{ border: "1px solid", borderBottom: 0 }} - > - <p class="modal-card-title"> - <i18n.Translate> - Multi-factor authentication required. - </i18n.Translate> - </p> - </header> - <section - class="modal-card-body" - style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} - > - <div> - {currentChallenge.combi_and ? ( - <i18n.Translate> - You must complete all of these requirements. - </i18n.Translate> - ) : ( - <i18n.Translate> - You need to complete at least one of this requirements. - </i18n.Translate> - )} - </div> - <div class="control" style={{ margin: 8 }}> - <form> - {currentChallenge.challenges.map((challenge, idx) => { - return ( - <label - class="radio" - style={{ display: "flex", margin: 0, marginTop: 16 }} - > - <div style={{ padding: 4 }}> - <input - type="radio" - name="challenge_id" - checked={ - selectedChallenge?.challenge_id === - challenge.challenge_id - } - onChange={() => { - setSelectedChallenge(challenge); - }} - /> - </div> - <div style={{ alignContent: "center" }}> - {(function (ch: TanChannel): VNode { - switch (ch) { - case TanChannel.SMS: - return ( - <i18n.Translate> - An SMS to the phone number ending with{" "} - <span>{challenge.tan_info}</span> - </i18n.Translate> - ); - case TanChannel.EMAIL: - return ( - <i18n.Translate> - An email to the address starting with{" "} - <span>{challenge.tan_info}</span> - </i18n.Translate> - ); - } - })(challenge.tan_channel)} - </div> - </label> - // </section> - ); - })} - </form> - </div> - </section> - <footer - class="modal-card-foot " - style={{ - justifyContent: "space-between", - border: "1px solid", - borderTop: 0, - }} - > - <button class="button" type="button" onClick={onCancel}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - <Button class="button is-success" onClick={doSend}> - <i18n.Translate>Continue</i18n.Translate> - </Button> - {!enough ? undefined : ( - <Button - class="button is-success" - onClick={onCompleted.withArgs(solved)} + <ConfirmModal + label={!enough ? i18n.str`Continue` : i18n.str`Complete`} + description={i18n.str`Multi-factor authentication required.`} + active + onCancel={onCancel} + confirm={!enough ? doSend : retry.withArgs(solved)} + > + <div> + {requirement.combi_and ? ( + <i18n.Translate> + You must complete all of these requirements. + </i18n.Translate> + ) : ( + <i18n.Translate> + You need to complete at least one of this requirements. + </i18n.Translate> + )} + </div> + <div class="control" style={{ margin: 8 }}> + <form> + {requirement.challenges.map((challenge, idx) => { + return ( + <label + class="radio" + style={{ display: "flex", margin: 0, marginTop: 16 }} > - <i18n.Translate>Complete</i18n.Translate> - </Button> - )} - </footer> - </div> + <div style={{ padding: 4 }}> + <input + type="radio" + name="challenge_id" + checked={ + selectedChallenge?.challenge_id === challenge.challenge_id + } + onChange={() => { + setSelectedChallenge(challenge); + }} + /> + </div> + <div style={{ alignContent: "center" }}> + {(function (ch: TanChannel): VNode { + switch (ch) { + case TanChannel.SMS: + return ( + <i18n.Translate> + An SMS to the phone number ending with{" "} + <span>{challenge.tan_info}</span> + </i18n.Translate> + ); + case TanChannel.EMAIL: + return ( + <i18n.Translate> + An email to the address starting with{" "} + <span>{challenge.tan_info}</span> + </i18n.Translate> + ); + } + })(challenge.tan_channel)} + </div> + </label> + // </section> + ); + })} + </form> </div> - </Fragment> + </ConfirmModal> ); } diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -51,6 +51,7 @@ interface Props { label: TranslatedString; children?: ComponentChildren; danger?: boolean; + focus?: boolean; /** * sometimes we want to prevent the user to close the dialog by error when clicking outside the box * @@ -66,6 +67,7 @@ export function ConfirmModal({ confirm, children, danger, + focus, label, noCancelButton, }: Props): VNode { @@ -89,7 +91,10 @@ export function ConfirmModal({ </header> <section class="modal-card-body">{children}</section> <footer class="modal-card-foot"> - <div class="buttons is-right" style={{ width: "100%" }}> + <div + class="buttons" + style={{ width: "100%", justifyContent: "space-between" }} + > {confirm ? ( <Fragment> {onCancel && !noCancelButton ? ( @@ -110,7 +115,7 @@ export function ConfirmModal({ <button type="button" class="button " - ref={doAutoFocus} + ref={focus ? doAutoFocus : undefined} onClick={onCancel} > <i18n.Translate>Close</i18n.Translate> @@ -131,51 +136,6 @@ export function ConfirmModal({ ); } -export function ContinueModal({ - active, - description, - onCancel, - confirm, - children, -}: Props): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class={active ? "modal is-active" : "modal"}> - <div class="modal-background " onClick={onCancel} /> - <div class="modal-card"> - <header class="modal-card-head has-background-success"> - {!description ? null : <p class="modal-card-title">{description}</p>} - <button - type="button" - class="delete " - aria-label="close" - onClick={onCancel} - /> - </header> - <section class="modal-card-body">{children}</section> - <footer class="modal-card-foot"> - <div class="buttons is-right" style={{ width: "100%" }}> - <Button - class="button is-success" - ref={doAutoFocus} - submit - onClick={confirm} - > - <i18n.Translate>Continue</i18n.Translate> - </Button> - </div> - </footer> - </div> - <button - type="button" - class="modal-close is-large " - aria-label="close" - onClick={onCancel} - /> - </div> - ); -} - export function SimpleModal({ onCancel, children, @@ -200,64 +160,6 @@ export function SimpleModal({ ); } -export function ClearConfirmModal({ - description, - onCancel, - onClear, - confirm, - children, -}: Props & { onClear?: () => void }): VNode { - const { i18n } = useTranslationContext(); - return ( - <div class="modal is-active"> - <div class="modal-background " onClick={onCancel} /> - <div class="modal-card"> - <header class="modal-card-head"> - {!description ? null : <p class="modal-card-title">{description}</p>} - <button - type="button" - class="delete " - aria-label="close" - onClick={onCancel} - /> - </header> - <section class="modal-card-body is-main-section">{children}</section> - <footer class="modal-card-foot"> - {onClear && ( - <button - type="button" - class="button is-danger" - onClick={onClear} - disabled={onClear === undefined} - > - <i18n.Translate>Clear</i18n.Translate> - </button> - )} - <div class="buttons is-right" style={{ width: "100%" }}> - <button class="button " type="button" onClick={onCancel}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - <Button - class="button is-info" - submit - onClick={confirm} - ref={doAutoFocus} - > - <i18n.Translate>Confirm</i18n.Translate> - </Button> - </div> - </footer> - </div> - <button - type="button" - class="modal-close is-large " - aria-label="close" - onClick={onCancel} - /> - </div> - ); -} - interface CompareAccountsModalProps { onCancel: () => void; confirm: SafeHandler<any, any>; diff --git a/packages/merchant-backoffice-ui/src/context/challenge.ts b/packages/merchant-backoffice-ui/src/context/challenge.ts @@ -33,7 +33,7 @@ import { useContext, useState } from "preact/hooks"; */ export type ContextType = { - pending?: MfaState; + pending?: ChallengeState; cancel(): void; onNewChallenge( operation: TranslatedString, @@ -55,12 +55,12 @@ const Context = createContext<ContextType>(initial); export const useMerchantChallengeHandlerContext = (): ContextType => useContext(Context); -type MfaState = { +export type ChallengeState = { requirement: ChallengeResponse; loadingFirstChallenge: boolean; title: TranslatedString; retry: SafeHandler<[string[]], any>; - initial?: { request: Challenge; response?: ChallengeRequestResponse }; + initial?: { currentChallengeIndex: number; response?: ChallengeRequestResponse }; }; // FIXME: practically the same as BankChallengeHandlerProvider but we cant // reuse it because it ask for username @@ -69,7 +69,7 @@ export const MerchantChallengeHandlerProvider = ({ }: { children: ComponentChildren; }): VNode => { - const [state, setState] = useState<MfaState>(); + const [state, setState] = useState<ChallengeState>(); const { lib } = useMerchantApiContext(); /** @@ -113,9 +113,9 @@ export const MerchantChallengeHandlerProvider = ({ title: operation, retry: handler, requirement, - loadingFirstChallenge, + loadingFirstChallenge: false, initial: { - request: challenge, + currentChallengeIndex: 0, response: result.type === "ok" ? result.body : undefined, }, });