commit f791967cca584e472345cf5abf855ca3a2be9bf4 parent 528f7ffca63477d445732ada44d7206be98e947c Author: Sebastian <sebasjm@gmail.com> Date: Mon, 8 Sep 2025 07:24:02 -0300 fix #10271 Diffstat:
12 files changed, 555 insertions(+), 39 deletions(-)
diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -56,8 +56,8 @@ export function RegistrationPage({ // eslint-disable-next-line no-useless-escape export const USERNAME_REGEX = /^[a-zA-Z0-9\-\.\_\~]*$/; -export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/; -export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; +// export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/; +// export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; /** * Collect and submit registration data. diff --git a/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx b/packages/bank-ui/src/pages/admin/ConversionRateClassForm.tsx @@ -37,10 +37,6 @@ import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; const TALER_SCREEN_ID = 120; -const EMAIL_REGEX = - /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; -const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; - export type ConversionRateClassFormData = { name?: string; description?: string; diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -82,8 +82,10 @@ import WebhookCreatePage from "./paths/instance/webhooks/create/index.js"; import WebhookListPage from "./paths/instance/webhooks/list/index.js"; import WebhookUpdatePage from "./paths/instance/webhooks/update/index.js"; import { LoginPage } from "./paths/login/index.js"; +import { NewAccount } from "./paths/newAccount/index.js"; import { Settings } from "./paths/settings/index.js"; import { Notification } from "./utils/types.js"; +import { ResetAccount } from "./paths/resetAccount/index.js"; export enum InstancePaths { error = "/error", @@ -133,6 +135,9 @@ export enum InstancePaths { otp_devices_new = "/otp-devices/new", interface = "/interface", + + newAccount = "/account/new", + resetAccount = "/account/reset", } export enum AdminPaths { @@ -156,6 +161,7 @@ export const publicPages = { const history = createHashHistory(); export function Routing(_p: Props): VNode { const { state } = useSessionContext(); + const { i18n } = useTranslationContext(); type GlobalNotifState = | (Notification & { to: string | undefined }) @@ -173,7 +179,7 @@ export function Routing(_p: Props): VNode { if (!instance) { return ( <Fragment> - <NotConnectedAppMenu title="Welcome!" /> + <NotConnectedAppMenu title={i18n.str`Welcome!`} /> <Loading /> </Fragment> ); @@ -200,8 +206,22 @@ export function Routing(_p: Props): VNode { if (shouldLogin) { return ( <Fragment> - <NotConnectedAppMenu title="Welcome!" /> - <LoginPage /> + <NotConnectedAppMenu title={i18n.str`Welcome!`} /> + <Router history={history}> + <Route + path={InstancePaths.newAccount} + component={NewAccount} + onCancel={() => route(InstancePaths.order_list)} + onCreated={() => route(InstancePaths.order_list)} + /> + <Route + path={InstancePaths.resetAccount} + component={ResetAccount} + onCancel={() => route(InstancePaths.order_list)} + onReseted={() => route(InstancePaths.order_list)} + /> + <Route default component={() => <LoginPage />} /> + </Router> </Fragment> ); } @@ -782,7 +802,9 @@ function KycBanner(): VNode { <div> <p> <i18n.Translate> - Some transfers are on hold until the KYC process is completed. Visit the KYC section in the left-hand menu for more information. + Some transfers are on hold until the KYC process is completed. + Visit the KYC section in the left-hand menu for more + information. </i18n.Translate> </p> <div class="buttons is-right"> diff --git a/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx b/packages/merchant-backoffice-ui/src/components/instance/DefaultInstanceFormFields.tsx @@ -69,6 +69,12 @@ export function DefaultInstanceFormFields({ /> <Input<Entity> + name="phone_number" + label={i18n.str`Phone number`} + tooltip={i18n.str`Contact phone`} + /> + + <Input<Entity> name="website" label={i18n.str`Website URL`} tooltip={i18n.str`URL.`} diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -37,7 +37,7 @@ import { Input } from "../../../components/form/Input.js"; import { InputToggle } from "../../../components/form/InputToggle.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; import { usePreference } from "../../../hooks/preference.js"; -import { INSTANCE_ID_REGEX } from "../../../utils/constants.js"; +import { EMAIL_REGEX, INSTANCE_ID_REGEX, PHONE_JUST_NUMBERS_REGEX } from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; export type Entity = TalerMerchantApi.InstanceConfigurationMessage & { @@ -89,6 +89,18 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { // : undefined; // }), // ), + email: !value.email + ? i18n.str`Required` + : !EMAIL_REGEX.test(value.email) + ? i18n.str`Doesn't have the pattern of an email` + : undefined, + phone_number: !value.phone_number + ? i18n.str`Required` + : !value.phone_number.startsWith("+") + ? i18n.str`Should start with +` + : !PHONE_JUST_NUMBERS_REGEX.test(value.phone_number) + ? i18n.str`A phone number consists of numbers only` + : undefined, default_pay_delay: !value.default_pay_delay ? i18n.str`Required` : value.default_wire_transfer_delay !== undefined && diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -25,7 +25,7 @@ import { InternationalizationAPI, LoginTokenRequest, LoginTokenScope, - TranslatedString + TranslatedString, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -35,27 +35,32 @@ import { NotificationCard } from "../../components/menu/index.js"; import { useSessionContext } from "../../context/session.js"; import { Notification } from "../../utils/types.js"; import { format } from "date-fns"; -import { datetimeFormatForSettings, usePreference } from "../../hooks/preference.js"; +import { + datetimeFormatForSettings, + usePreference, +} from "../../hooks/preference.js"; -interface Props { } +interface Props {} -export const TEMP_TEST_TOKEN = (description: TranslatedString) => ({ - scope: LoginTokenScope.All, - duration: Duration.toTalerProtocolDuration(Duration.fromMilliseconds(100)), - description -} as LoginTokenRequest); +export const TEMP_TEST_TOKEN = (description: TranslatedString) => + ({ + scope: LoginTokenScope.All, + duration: Duration.toTalerProtocolDuration(Duration.fromMilliseconds(100)), + description, + }) as LoginTokenRequest; -export const FOREVER_REFRESHABLE_TOKEN = (description: TranslatedString) => ({ - scope: LoginTokenScope.All_Refreshable, - duration: Duration.toTalerProtocolDuration(Duration.getForever()), - description, -} as LoginTokenRequest); +export const FOREVER_REFRESHABLE_TOKEN = (description: TranslatedString) => + ({ + scope: LoginTokenScope.All_Refreshable, + duration: Duration.toTalerProtocolDuration(Duration.getForever()), + description, + }) as LoginTokenRequest; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; export function LoginPage(_p: Props): VNode { const [password, setPassword] = useState(""); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { state, logIn, getInstanceForUsername } = useSessionContext(); + const { state, logIn, getInstanceForUsername, config } = useSessionContext(); const [username, setUsername] = useState(state.instance); const [settings] = usePreference(); @@ -103,7 +108,9 @@ export function LoginPage(_p: Props): VNode { class="modal-card-head" style={{ border: "1px solid", borderBottom: 0 }} > - <p class="modal-card-title">{i18n.str`Login required`}</p> + <p class="modal-card-title"> + <i18n.Translate>Login required</i18n.Translate> + </p> </header> <section class="modal-card-body" @@ -174,12 +181,32 @@ export function LoginPage(_p: Props): VNode { borderTop: 0, }} > - <div /> - <AsyncButton type="is-info" onClick={doLoginImpl}> + {!config.have_self_provisioning ? ( + <div /> + ) : ( + <a href="#/account/reset" class="button " disabled={!username}> + <i18n.Translate>Forgot password</i18n.Translate> + </a> + )} + <AsyncButton + type="is-info" + disabled={!username || !password} + onClick={doLoginImpl} + > <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> </footer> </div> + <div> + <a href={"#/account/reset"} 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 @@ -0,0 +1,241 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Notification } from "../../utils/types.js"; +import { AsyncButton } from "../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../components/form/FormProvider.js"; +import { Input } from "../../components/form/Input.js"; +import { InputWithAddon } from "../../components/form/InputWithAddon.js"; +import { useSessionContext } from "../../context/session.js"; +import { + EMAIL_REGEX, + INSTANCE_ID_REGEX, + PHONE_JUST_NUMBERS_REGEX, +} from "../../utils/constants.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; + +export interface Account { + id: string; + name: string; + password: string; + repeat: string; + email: string; + phone: string; +} +interface Props { + onCancel: () => void; + onCreated: () => void; +} +export function NewAccount({ onCancel, onCreated }: Props): VNode { + const { i18n } = useTranslationContext(); + const { state: session, lib, logIn } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const [value, setValue] = useState<Partial<Account>>({}); + const errors: FormErrors<Account> = { + id: !value.id + ? i18n.str`Required` + : !INSTANCE_ID_REGEX.test(value.id) + ? i18n.str`Invalid` + : undefined, + name: !value.name ? i18n.str`Required` : undefined, + password: !value.password ? i18n.str`Required` : undefined, + repeat: !value.repeat + ? i18n.str`Required` + : 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` + : undefined, + }; + + function valueHandler(s: (d: Partial<Account>) => Partial<Account>): void { + const next = s(value); + const v: Account = { + id: next.id ?? "", + name: next.name ?? "", + password: next.password ?? "", + repeat: next.repeat ?? "", + email: next.email ?? "", + phone: next.phone ?? "", + }; + setValue(v); + } + + async function doCreateImpl() { + try { + const resp = await lib.instance.createInstanceSelfProvision({ + address: {}, + auth: { + method: MerchantAuthMethod.TOKEN, + password: value.password!, + }, + default_pay_delay: { + d_us: 1000, + }, + default_wire_transfer_delay: { + d_us: 1000, + }, + id: value.id!, + jurisdiction: {}, + name: value.name!, + use_stefan: true, + email: value.email!, + phone_number: value.phone, + }); + + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + setNotif({ + message: i18n.str`The account was created`, + type: "INFO", + description: i18n.str`To complete the process you need to confirm email and phone.`, + }); + } else { + setNotif({ + message: i18n.str`Failed to create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + return; + } + //if auth has been updated, request a new access token + const result = await lib.instance.createAccessToken( + value.id!, + value.password!, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Account created`), + ); + if (result.type === "ok") { + const { access_token: token } = result.body; + logIn(value.id!, token); + } + onCreated(); + } catch (error) { + setNotif({ + message: i18n.str`Failed to create account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title"> + <i18n.Translate>Self provision</i18n.Translate> + </p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + <FormProvider<Account> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <InputWithAddon<Account> + name="id" + addonBefore={ + new URL("instances/", session.backendUrl.href).href + } + label={i18n.str`Identifier`} + tooltip={i18n.str`Name of the instance in URLs. The 'admin' instance is special in that it is used to administer other instances.`} + /> + + <Input<Account> + name="name" + label={i18n.str`Business name`} + tooltip={i18n.str`Legal name of the business represented by this instance.`} + /> + <Input<Account> + label={i18n.str`New password`} + tooltip={i18n.str`Next password to be used`} + inputType="password" + name="password" + /> + <Input<Account> + label={i18n.str`Repeat password`} + tooltip={i18n.str`Confirm the same password`} + 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" + /> + </FormProvider> + </section> + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} + > + <button class="button" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <AsyncButton + type="is-info" + disabled={!errors} + onClick={doCreateImpl} + > + <i18n.Translate>Create</i18n.Translate> + </AsyncButton> + </footer> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx @@ -0,0 +1,164 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Notification } from "../../utils/types.js"; +import { AsyncButton } from "../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../components/form/FormProvider.js"; +import { Input } from "../../components/form/Input.js"; +import { InputWithAddon } from "../../components/form/InputWithAddon.js"; +import { useSessionContext } from "../../context/session.js"; +import { + EMAIL_REGEX, + INSTANCE_ID_REGEX, + PHONE_JUST_NUMBERS_REGEX, +} from "../../utils/constants.js"; +import { NotificationCard } from "../../components/menu/index.js"; +import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; + +export interface Account { + code: string; +} +interface Props { + onCancel: () => void; + instanceId: string; + onReseted: () => void; +} +export function ResetAccount({ onCancel, onReseted, instanceId }: Props): VNode { + const { i18n } = useTranslationContext(); + const { state: session, lib, logIn } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const [value, setValue] = useState<Partial<Account>>({}); + const errors: FormErrors<Account> = { + code: !value.code + ? i18n.str`Required` + : undefined, + }; + + function valueHandler(s: (d: Partial<Account>) => Partial<Account>): void { + const next = s(value); + const v: Account = { + code: next.code ?? "", + }; + setValue(v); + } + + async function doResetImpl() { + // try { + // const resp = await lib.instance.forgotPasswordSelfProvision({ + + // }); + + // if (resp.type === "fail") { + // if (resp.case === HttpStatusCode.Accepted) { + // setNotif({ + // message: i18n.str`The account was created`, + // type: "INFO", + // description: i18n.str`To complete the process you need to confirm email and phone.`, + // }); + // } else { + // setNotif({ + // message: i18n.str`Failed to create account`, + // type: "ERROR", + // description: resp.detail?.hint, + // }); + // } + // return; + // } + // //if auth has been updated, request a new access token + // const result = await lib.instance.createAccessToken( + // value.id!, + // value.password!, + // FOREVER_REFRESHABLE_TOKEN(i18n.str`Account created`), + // ); + // if (result.type === "ok") { + // const { access_token: token } = result.body; + // logIn(value.id!, token); + // } + // onCreated(); + // } catch (error) { + // setNotif({ + // message: i18n.str`Failed to create account`, + // type: "ERROR", + // description: error instanceof Error ? error.message : String(error), + // }); + // } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title"> + <i18n.Translate>Self provision</i18n.Translate> + </p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + <FormProvider<Account> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <Input<Account> + label={i18n.str`Code`} + tooltip={i18n.str`Code received by the challenge channel.`} + name="code" + /> + </FormProvider> + </section> + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} + > + <button class="button" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <AsyncButton + type="is-info" + disabled={!errors} + onClick={doResetImpl} + > + <i18n.Translate>Validate</i18n.Translate> + </AsyncButton> + </footer> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -26,17 +26,8 @@ import { InputToggle } from "../../components/form/InputToggle.js"; import { LangSelector } from "../../components/menu/LangSelector.js"; import { Preferences, usePreference } from "../../hooks/preference.js"; -function getBrowserLang(): string | undefined { - if (typeof window === "undefined") return undefined; - if (window.navigator.languages) return window.navigator.languages[0]; - if (window.navigator.language) return window.navigator.language; - return undefined; -} - export function Settings({ onClose }: { onClose?: () => void }): VNode { const { i18n } = useTranslationContext(); - const borwserLang = getBrowserLang(); - const { update } = useLang(undefined, {}); const [value, , updateValue] = usePreference(); const errors: FormErrors<Preferences> = {}; diff --git a/packages/merchant-backoffice-ui/src/utils/constants.ts b/packages/merchant-backoffice-ui/src/utils/constants.ts @@ -46,6 +46,9 @@ export const DEFAULT_REQUEST_TIMEOUT = 10; export const MAX_IMAGE_SIZE = 1024 * 1024; export const INSTANCE_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.@-]+$/; +export const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +export const PHONE_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; export const COUNTRY_TABLE = { AE: "U.A.E.", diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -2475,14 +2475,20 @@ export class TalerMerchantInstanceHttpClient { } /** - * https://docs.taler.net/core/api-merchant.html#post-[-instances-$INSTANCE]-forgot-password + * https://docs.taler.net/core/api-merchant.html#post--instances-$INSTANCE-forgot-password */ async forgotPasswordSelfProvision( - body: TalerMerchantApi.InstanceConfigurationMessage, + body: TalerMerchantApi.InstanceAuthConfigurationMessage, + opts: { + challengeIds?: string[] + } = {} ) { const url = new URL(`forgot-password`, this.baseUrl); const headers: Record<string, string> = {}; + if (opts.challengeIds && opts.challengeIds.length > 0 ) { + headers["Taler-Challenge-Ids"] = opts.challengeIds.join(", ") + } const resp = await this.httpLib.fetch(url.href, { method: "POST", body, diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -4369,3 +4369,51 @@ export const codecForMerchantReserveCreateConfirmation = .property("accounts", codecForList(codecForExchangeWireAccount())) .property("reserve_pub", codecForEddsaPublicKey()) .build("MerchantReserveCreateConfirmation"); + +export interface ChallengeResponse { + // List of challenge IDs that must be solved before the + // client may proceed. + challenges: Challenge[]; + + // True if **all** challenges must be solved (AND), false if + // it is sufficient to solve one of them (OR). + combi_and: boolean; +} +export interface Challenge { + // Unique identifier of the challenge to solve to run this protected + // operation. + challenge_id: string; + + // Channel of the last successful transmission of the TAN challenge. + tan_channel: TanChannel; + + // Info of the last successful transmission of the TAN challenge. + // Hint to show to the user as to where the challenge was + // sent or what to use to solve the challenge. May not + // contain the full address for privacy. + tan_info: string; +} + +export enum TanChannel { + SMS = "sms", + EMAIL = "email", +} + +export const codecForChallenge = (): Codec<Challenge> => + buildCodecForObject<Challenge>() + .property("challenge_id", codecForString()) + .property( + "tan_channel", + codecForEither( + codecForConstString(TanChannel.SMS), + codecForConstString(TanChannel.EMAIL), + ), + ) + .property("tan_info", codecForString()) + .build("TalerCorebankApi.Challenge"); + +export const codecForChallengeResponse = (): Codec<ChallengeResponse> => + buildCodecForObject<ChallengeResponse>() + .property("challenges", codecForList(codecForChallenge())) + .property("combi_and", codecForBoolean()) + .build("TalerCorebankApi.ChallengeResponse");