taler-typescript-core

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

commit f045d1efc5fff9fca3480841764e85eee8ac768a
parent cb1beccfe021351323ce032257941972638820e3
Author: Sebastian <sebasjm@gmail.com>
Date:   Wed, 22 Oct 2025 17:27:44 -0300

fix #10524

Diffstat:
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx | 65++++++++++++++++++++++++++++++-----------------------------------
Mpackages/merchant-backoffice-ui/src/components/menu/index.tsx | 14++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx | 156+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/login/index.tsx | 37++++++++++++++++++++++---------------
Mpackages/merchant-backoffice-ui/src/paths/newAccount/index.tsx | 64+++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/merchant-backoffice-ui/src/settings.json | 3++-
Mpackages/merchant-backoffice-ui/src/settings.ts | 4++++
8 files changed, 193 insertions(+), 152 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -220,7 +220,7 @@ export function Routing(_p: Props): VNode { onCancel={() => route(InstancePaths.order_list)} onReseted={() => route(InstancePaths.order_list)} /> - <Route default component={() => <LoginPage />} /> + <Route default component={() => <LoginPage showCreateAccount />} /> </Router> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -34,6 +34,7 @@ import { Input } from "./Input.js"; import { InputGroup } from "./InputGroup.js"; import { InputSelector } from "./InputSelector.js"; import { InputProps, useField } from "./useField.js"; +import { useSettingsContext } from "../../context/settings.js"; export interface Props<T> extends InputProps<T> {} @@ -41,7 +42,7 @@ export interface Props<T> extends InputProps<T> {} // https://datatracker.ietf.org/doc/html/rfc8905 type Entity = { // iban, bitcoin, x-taler-bank. it defined the format - target: string; + target: string | undefined; // path1 if the first field to be used path1?: string; // path2 if the second field to be used, optional @@ -148,8 +149,7 @@ function validateIBAN_path1( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString | undefined { // Check total length - if (iban.length < 4) - return i18n.str`IBANs usually have more than 4 digits.`; + if (iban.length < 4) return i18n.str`IBANs usually have more than 4 digits.`; if (iban.length > 34) return i18n.str`IBANs usually have fewer than 34 digits.`; @@ -196,22 +196,25 @@ export function InputPaytoForm<T>({ }: Props<keyof T>): VNode { const { value: initialValueStr, onChange } = useField<T>(name); + const { supportedWireMethods } = useSettingsContext(); + const initialPayto = parsePaytoUri(initialValueStr ?? ""); const { i18n } = useTranslationContext(); - const targets = [ - i18n.str`Select a wire method...`, - "iban", - "bitcoin", - "ethereum", - "x-taler-bank", - ]; - const noTargetValue = targets[0]; + const allTargets = ["iban", "bitcoin", "ethereum", "x-taler-bank"]; + + // only the one supported by the server + const targets = + !supportedWireMethods || !supportedWireMethods.length + ? allTargets + : allTargets.filter((t) => supportedWireMethods.indexOf(t) !== -1); + const defaultTarget: Entity = { - target: noTargetValue, + target: targets.length ? targets[0] : "", params: {}, }; + // FIXME: use new paytos API and EBICS is not supported const paths = !initialPayto ? [] : initialPayto.targetPath.split("/"); const initialPath1 = paths.length >= 1 ? paths[0] : undefined; const initialPath2 = paths.length >= 2 ? paths[1] : undefined; @@ -224,27 +227,11 @@ export function InputPaytoForm<T>({ path1: initialPath1, path2: initialPath2, }; + const [value, setValue] = useState<Partial<Entity>>(initial); - useEffect(() => { - const nv = parsePaytoUri(initialValueStr ?? ""); - const paths = !initialPayto ? [] : initialPayto.targetPath.split("/"); - if (nv !== undefined && nv.isKnown) { - if (nv.targetType === "iban" && paths.length >= 2) { - //FIXME: workaround EBIC not supported - paths[0] = paths[1]; - delete paths[1]; - } - setValue({ - target: nv.targetType, - params: nv.params, - path1: paths.length >= 1 ? paths[0] : undefined, - path2: paths.length >= 2 ? paths[1] : undefined, - }); - } - }, [initialValueStr]); const errors = undefinedIfEmpty<FormErrors<Entity>>({ - target: value.target === noTargetValue ? i18n.str`Required` : undefined, + target: value.target === undefined ? i18n.str`Required` : undefined, path1: !value.path1 ? i18n.str`Required` : value.target === "iban" @@ -298,6 +285,9 @@ export function InputPaytoForm<T>({ onChange(str as T[keyof T]); }, [str]); + if (!targets.length) { + return <i18n.Translate>None of the supported wire method of the server are currently supported by this app. Server settings: {supportedWireMethods?.join(",") ?? "-"}</i18n.Translate> + } return ( <InputGroup name="payto" label={label} fixed tooltip={tooltip}> <FormProvider<Entity> @@ -306,14 +296,18 @@ export function InputPaytoForm<T>({ object={value} valueHandler={setValue} > + {targets.length === 1 ? undefined: <InputSelector<Entity> name="target" label={i18n.str`Wire method`} tooltip={i18n.str`Select the method you want to use to transfer your earnings to your business account.`} values={targets} readonly={readonly} - toStr={(v) => (v === noTargetValue ? i18n.str`Select a wire method...` : v)} - /> + toStr={(v) => + v === undefined ? i18n.str`Select a wire method...` : v + } + /> + } {value.target === "ach" && ( <Fragment> @@ -416,7 +410,8 @@ export function InputPaytoForm<T>({ <Fragment> <div> <i18n.Translate> - Enter the data without a scheme. A subpath may be included: + Enter the data without a scheme. A subpath may be + included: </i18n.Translate> </div> <div>bank.com/</div> @@ -436,7 +431,7 @@ export function InputPaytoForm<T>({ {/** * Show additional fields apart from the payto */} - {value.target !== noTargetValue && ( + {value.target !== undefined && ( <Fragment> <Input name="params.receiver-name" @@ -456,7 +451,7 @@ function cleanupPath1(str: string, type?: string) { if (type === "iban") { // we tolerate country in any case // and don't care about any space - return str.replace(/\s/g, '').toUpperCase(); + return str.replace(/\s/g, "").toUpperCase(); } if (type === "x-taler-bank") { return !str.endsWith("/") ? str + "/" : str; diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -23,6 +23,7 @@ import { NavigationBar } from "./NavigationBar.js"; import { Sidebar } from "./SideBar.js"; import { useSessionContext } from "../../context/session.js"; import { useNavigationContext } from "@gnu-taler/web-util/browser"; +import { LangSelector } from "./LangSelector.js"; function getInstanceTitle(path: string, id: string): string { switch (path) { @@ -233,6 +234,19 @@ export function NotConnectedAppMenu({ onMobileMenu={() => setMobileOpen(!mobileOpen)} title={title} /> + <div + style={{ + width: "100%", + display: "flex", + justifyContent: "space-between", + paddingTop:8, + paddingRight: 8, + + }} + > + <div /> + <LangSelector /> + </div> </div> ); } 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 @@ -46,6 +46,7 @@ import { undefinedIfEmpty } from "../../../../utils/table.js"; import { safeConvertURL } from "../update/UpdatePage.js"; import { TestRevenueErrorType, testRevenueAPI } from "./index.js"; import { usePreference } from "../../../../hooks/preference.js"; +import { useSettingsContext } from "../../../../context/settings.js"; type Entity = TalerMerchantApi.AccountAddDetails & { verified?: boolean; @@ -74,7 +75,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const facadeURL = safeConvertURL(state.credit_facade_url); const [revenuePayto, setRevenuePayto] = useState<PaytoUri | undefined>( - // parsePaytoUri("payto://x-taler-bank/asd.com:1010/asd/pepe"), undefined, ); const [testError, setTestError] = useState<TranslatedString | undefined>( @@ -230,85 +230,91 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { name="payto_uri" label={i18n.str`Account details`} /> - <div class="message-body" style={{ marginBottom: 10 }}> - <p> - <i18n.Translate> - If the bank supports Taler Revenue API then you can add the - endpoint URL below to keep the revenue information in sync. - </i18n.Translate> - </p> - </div> - <Input<Entity> - name="credit_facade_url" - label={i18n.str`Endpoint URL`} - help="https://bank.demo.taler.net/accounts/${USERNAME}/taler-revenue/" - expand - tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`} - /> - <InputSelector - name="credit_facade_credentials.type" - label={i18n.str`Auth type`} - tooltip={i18n.str`Choose the authentication type for the account info URL`} - values={accountAuthType} - toStr={(str) => { - if (str === "none") return i18n.str`Without authentication`; - if (str === "bearer") return i18n.str`With token`; - return "With username and password"; - }} - /> - {state.credit_facade_credentials?.type === "basic" ? ( + {!developerMode ? undefined : ( <Fragment> - <Input - name="credit_facade_credentials.username" - label={i18n.str`Username`} - tooltip={i18n.str`Username to access the account information.`} + <div class="message-body" style={{ marginBottom: 10 }}> + <p> + <i18n.Translate> + If the bank supports Taler Revenue API then you can add + the endpoint URL below to keep the revenue information + in sync. + </i18n.Translate> + </p> + </div> + <Input<Entity> + name="credit_facade_url" + label={i18n.str`Endpoint URL`} + help="https://bank.demo.taler.net/accounts/${USERNAME}/taler-revenue/" + expand + tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`} /> - <Input - name="credit_facade_credentials.password" - inputType="password" - label={i18n.str`Password`} - tooltip={i18n.str`Password to access the account information.`} + <InputSelector + name="credit_facade_credentials.type" + label={i18n.str`Auth type`} + tooltip={i18n.str`Choose the authentication type for the account info URL`} + values={accountAuthType} + toStr={(str) => { + if (str === "none") + return i18n.str`Without authentication`; + if (str === "bearer") return i18n.str`With token`; + return "With username and password"; + }} /> - </Fragment> - ) : undefined} - {state.credit_facade_credentials?.type === "bearer" ? ( - <Fragment> - <Input - name="credit_facade_credentials.token" - label={i18n.str`Token`} - inputType="password" - tooltip={i18n.str`Access token to access the account information.`} + {state.credit_facade_credentials?.type === "basic" ? ( + <Fragment> + <Input + name="credit_facade_credentials.username" + label={i18n.str`Username`} + tooltip={i18n.str`Username to access the account information.`} + /> + <Input + name="credit_facade_credentials.password" + inputType="password" + label={i18n.str`Password`} + tooltip={i18n.str`Password to access the account information.`} + /> + </Fragment> + ) : undefined} + {state.credit_facade_credentials?.type === "bearer" ? ( + <Fragment> + <Input + name="credit_facade_credentials.token" + label={i18n.str`Token`} + inputType="password" + tooltip={i18n.str`Access token to access the account information.`} + /> + </Fragment> + ) : undefined} + <InputToggle<Entity> + label={i18n.str`Match`} + tooltip={i18n.str`Check if the information matches the server info.`} + name="verified" + readonly + threeState + help={ + testError !== undefined + ? testError + : state.verified === undefined + ? i18n.str`Not verified` + : state.verified + ? i18n.str`Last test was successful.` + : i18n.str`Last test failed.` + } + side={ + <button + class="button is-info" + data-tooltip={i18n.str`Compare info from server with account form`} + disabled={!state.credit_facade_url} + onClick={async () => { + await testAccountInfo(); + }} + > + <i18n.Translate>Test</i18n.Translate> + </button> + } /> </Fragment> - ) : undefined} - <InputToggle<Entity> - label={i18n.str`Match`} - tooltip={i18n.str`Check if the information matches the server info.`} - name="verified" - readonly - threeState - help={ - testError !== undefined - ? testError - : state.verified === undefined - ? i18n.str`Not verified` - : state.verified - ? i18n.str`Last test was successful.` - : i18n.str`Last test failed.` - } - side={ - <button - class="button is-info" - data-tooltip={i18n.str`Compare info from server with account form`} - disabled={!state.credit_facade_url} - onClick={async () => { - await testAccountInfo(); - }} - > - <i18n.Translate>Test</i18n.Translate> - </button> - } - /> + )} </FormProvider> <div class="buttons is-right mt-5"> diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -40,7 +40,9 @@ import { SolveMFAChallenges } from "../../components/SolveMFA.js"; import { useSessionContext } from "../../context/session.js"; import { Notification } from "../../utils/types.js"; -interface Props {} +interface Props { + showCreateAccount?: boolean; +} export const TEMP_TEST_TOKEN = (description: TranslatedString) => ({ @@ -57,11 +59,11 @@ export const FOREVER_REFRESHABLE_TOKEN = (description: TranslatedString) => }) as LoginTokenRequest; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; -export function LoginPage(_p: Props): VNode { +export function LoginPage({ showCreateAccount }: Props): VNode { const [password, setPassword] = useState(""); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { state, logIn, getInstanceForUsername, config } = useSessionContext(); - const [username, setUsername] = useState(state.instance); + const [username, setUsername] = useState(showCreateAccount? "" : state.instance); const { i18n } = useTranslationContext(); @@ -110,12 +112,15 @@ export function LoginPage(_p: Props): VNode { } } } catch (error) { - const details = error instanceof TalerError ? JSON.stringify(error.errorDetail) : undefined + const details = + error instanceof TalerError + ? JSON.stringify(error.errorDetail) + : undefined; setNotif({ message: i18n.str`Failed to login.`, type: "ERROR", description: error instanceof Error ? error.message : undefined, - details + details, }); } }, @@ -234,16 +239,18 @@ export function LoginPage(_p: Props): VNode { </AsyncButton> </footer> </div> - <div> - <a href={"#/account/new"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-account-plus" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Create new account</i18n.Translate> - </span> - </a> - </div> + {!showCreateAccount ? undefined : ( + <div style={{ marginTop: 8 }}> + <a href={"#/account/new"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-account-plus" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Create new account</i18n.Translate> + </span> + </a> + </div> + )} </div> </div> <div diff --git a/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx @@ -18,6 +18,7 @@ import { Duration, HttpStatusCode, MerchantAuthMethod, + MerchantTanChannel, } from "@gnu-taler/taler-util"; import { undefinedIfEmpty, @@ -42,7 +43,6 @@ import { PHONE_JUST_NUMBERS_REGEX, } from "../../utils/constants.js"; import { Notification } from "../../utils/types.js"; -import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; export interface Account { id: string; @@ -62,10 +62,16 @@ interface Props { } export function NewAccount({ onCancel, onCreated }: Props): VNode { const { i18n } = useTranslationContext(); - const { state: session, lib, logIn } = useSessionContext(); + const { state: session, lib, logIn, config } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const [value, setValue] = useState<Partial<Account>>({ }); + const [value, setValue] = useState<Partial<Account>>({}); + + const serverRequiresEmail = + config.mandatory_tan_channels?.indexOf(MerchantTanChannel.EMAIL) !== -1; + const serverRequiresSms = + config.mandatory_tan_channels?.indexOf(MerchantTanChannel.SMS) !== -1; + const errors = undefinedIfEmpty<FormErrors<Account>>({ id: !value.id ? i18n.str`Required` @@ -79,18 +85,22 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { : value.password !== value.repeat ? i18n.str`Doesn't match` : undefined, - email: !value.email - ? i18n.str`Required` - : !EMAIL_REGEX.test(value.email) - ? i18n.str`Doesn't have the pattern of an email` - : undefined, - phone: !value.phone - ? i18n.str`Required` - : !value.phone.startsWith("+") - ? i18n.str`Should start with +` - : !PHONE_JUST_NUMBERS_REGEX.test(value.phone) - ? i18n.str`A phone number consists of numbers only` + email: !serverRequiresEmail + ? undefined + : !value.email + ? i18n.str`Required` + : !EMAIL_REGEX.test(value.email) + ? i18n.str`Doesn't have the pattern of an email` : undefined, + phone: !serverRequiresSms + ? undefined + : !value.phone + ? i18n.str`Required` + : !value.phone.startsWith("+") + ? i18n.str`Should start with +` + : !PHONE_JUST_NUMBERS_REGEX.test(value.phone) + ? i18n.str`A phone number consists of numbers only` + : undefined, }); function valueHandler(s: (d: Partial<Account>) => Partial<Account>): void { @@ -126,7 +136,7 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { jurisdiction: {}, name: value.name!, use_stefan: true, - email: value.email!, + email: value.email, phone_number: value.phone, }, { @@ -222,16 +232,20 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { inputType="password" name="repeat" /> - <Input<Account> - label={i18n.str`Email`} - tooltip={i18n.str`Contact email`} - name="email" - /> - <Input<Account> - label={i18n.str`Phone`} - tooltip={i18n.str`Contact phone number`} - name="phone" - /> + {serverRequiresEmail ? ( + <Input<Account> + label={i18n.str`Email`} + tooltip={i18n.str`Contact email`} + name="email" + /> + ) : undefined} + {serverRequiresSms ? ( + <Input<Account> + label={i18n.str`Phone`} + tooltip={i18n.str`Contact phone number`} + name="phone" + /> + ) : undefined} </FormProvider> </section> <footer diff --git a/packages/merchant-backoffice-ui/src/settings.json b/packages/merchant-backoffice-ui/src/settings.json @@ -1,3 +1,4 @@ { - "backendBaseURL": "http://merchant.taler.test/" + "backendBaseURL": "http://merchant.taler.test/", + "supportedWireMethods": ["iban"] } diff --git a/packages/merchant-backoffice-ui/src/settings.ts b/packages/merchant-backoffice-ui/src/settings.ts @@ -18,6 +18,7 @@ import { Codec, buildCodecForObject, canonicalizeBaseUrl, + codecForList, codecForString, codecOptional } from "@gnu-taler/taler-util"; @@ -26,6 +27,8 @@ export interface MerchantUiSettings { // Where merchant backend is localted // default: window.origin without "webui/" backendBaseURL?: string; + + supportedWireMethods?: string[]; } /** @@ -38,6 +41,7 @@ const defaultSettings: MerchantUiSettings = { const codecForBankUISettings = (): Codec<MerchantUiSettings> => buildCodecForObject<MerchantUiSettings>() .property("backendBaseURL", codecOptional(codecForString())) + .property("supportedWireMethods", codecOptional(codecForList(codecForString()))) .build("MerchantUiSettings"); function removeUndefineField<T extends object>(obj: T): T {