taler-typescript-core

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

commit ee4d9d0337207af3b4b2ddcdc57d14b797ce1a92
parent 7445b88e1f7bbea8c087a3b76112d580d3286d23
Author: Florian Dold <dold@taler.net>
Date:   Mon, 24 Aug 2026 02:29:34 +0200

bank web UI: omit unchanged account fields and normalize transfers

Diffstat:
Mpackages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx | 9+++++----
Mpackages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx | 42++++++++++++++++++++++--------------------
Mpackages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx | 287+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mpackages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx | 1+
Mpackages/libeufin-bank-webui/src/utils.ts | 232+++++++------------------------------------------------------------------------
5 files changed, 232 insertions(+), 339 deletions(-)

diff --git a/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx b/packages/libeufin-bank-webui/src/pages/PaytoWireTransferForm.tsx @@ -149,10 +149,11 @@ export function PaytoWireTransferForm({ let sendingAmount: AmountString | undefined; if (isRawPayto) { - const res = !rawPaytoInput ? undefined : Paytos.fromString(rawPaytoInput); - - if (res && res.tag === "ok") { - parsedURI = res.value; + if (parsed?.tag === "ok") { + parsedURI = { + ...parsed.value, + params: { ...parsed.value.params }, + }; sendingAmount = parsedURI.params.amount as AmountString; delete parsedURI.params.amount; // we don't want to send twice in the request } diff --git a/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx b/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx @@ -38,7 +38,10 @@ import { useState } from "preact/hooks"; import { Paytos } from "@gnu-taler/taler-util"; import { useBankChallengeHandlerContext } from "../../context/challenge.js"; -import { useAccountDetails } from "../../hooks/account.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../../hooks/account.js"; import { useSessionState } from "../../hooks/session.js"; import { AccountForm } from "../admin/AccountForm.js"; import { LoginForm } from "../LoginForm.js"; @@ -106,10 +109,11 @@ export function ShowAccountDetails({ challengeIds?: string[], ) => bank.updateAccount({ username, token }, account, { challengeIds }), { - onSuccess: () => { + onSuccess: async () => { mfa.cancel(); + await revalidateAccountDetails(); displayInfo(i18n.str`Account updated`); - // onUpdateSuccess(); + onUpdateSuccess(); }, onFail: showError( i18n.str`Failed to update the account.`, @@ -218,10 +222,7 @@ export function ShowAccountDetails({ <h2 class="text-base font-semibold leading-7 text-gray-900"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-semibold leading-6 " - id="availability-label" - > + <span class="text-sm text-black font-semibold leading-6 "> <i18n.Translate>Change details</i18n.Translate> </span> </span> @@ -262,10 +263,7 @@ export function ShowAccountDetails({ <h2 class="text-base font-semibold leading-7 text-gray-900"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-semibold leading-6 " - id="availability-label" - > + <span class="text-sm text-black font-semibold leading-6 "> <i18n.Translate>Merchant integration</i18n.Translate> </span> </span> @@ -317,7 +315,7 @@ export function ShowAccountDetails({ <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" - for="iban" + for="bitcoin-address" > {i18n.str`IBAN`} </label> @@ -427,27 +425,31 @@ export function ShowAccountDetails({ <input type="text" class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" + name="bitcoin-address" + id="bitcoin-address" disabled={true} - value={"asd"} + value={payto.address} autocomplete="off" /> <CopyButton class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => "Asd"} + getContent={() => payto.address} /> </div> <p class="mt-2 text-sm text-gray-500"> <i18n.Translate> - International Bank Account Number. + Bitcoin address for this account. </i18n.Translate> </p> </div> ); } default: - return `unsupported account type ${payto.targetType}`; + return ( + <i18n.Translate> + Unsupported account type "{payto.targetType}" + </i18n.Translate> + ); } })(payto)} @@ -496,12 +498,12 @@ export function ShowAccountDetails({ name="iban" id="iban" disabled={true} - value={baseURL} + value={revenueURL.href} autocomplete="off" /> <CopyButton class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " - getContent={() => baseURL} + getContent={() => revenueURL.href} /> </div> </div> diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountForm.tsx @@ -63,11 +63,44 @@ export type AccountFormData = { cashout_payto_uri?: string; email?: string; phone?: string; - // the API changed, it now support having more than one channel - // in the future we want to add support for this - tan_channel?: TanChannel | "remove"; + tan_channels?: TanChannel[]; }; +export function buildAccountReconfiguration( + changed: AccountFormData, + cashoutPaytoUri: PaytoString | null, + debitThreshold: AmountString | undefined, +): TalerCorebankApi.AccountReconfiguration { + const result: TalerCorebankApi.AccountReconfiguration = {}; + if (changed.cashout_payto_uri !== undefined) { + result.cashout_payto_uri = cashoutPaytoUri; + } + const contactData: TalerCorebankApi.ChallengeContactDataPatch = {}; + if (changed.email !== undefined) { + contactData.email = changed.email.trim() || null; + } + if (changed.phone !== undefined) { + contactData.phone = changed.phone.trim() || null; + } + const contactDataPatch = undefinedIfEmpty(contactData); + if (contactDataPatch !== undefined) { + result.contact_data = contactDataPatch; + } + if (changed.debit_threshold !== undefined) { + result.debit_threshold = debitThreshold; + } + if (changed.isPublic !== undefined) { + result.is_public = changed.isPublic; + } + if (changed.name !== undefined) { + result.name = changed.name; + } + if (changed.tan_channels !== undefined) { + result.tan_channels = changed.tan_channels; + } + return result; +} + type ChangeByPurposeType = { create: (a: TalerCorebankApi.RegisterAccountRequest | undefined) => void; update: (a: TalerCorebankApi.AccountReconfiguration | undefined) => void; @@ -101,6 +134,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const { i18n } = useTranslationContext(); const { state: credentials } = useSessionState(); const [form, setForm] = useState<AccountFormData>({}); + const [channelNotice, setChannelNotice] = useState<string>(); const [errors, setErrors] = useState< ErrorMessageMappingFor<typeof defaultValue> | undefined @@ -132,7 +166,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ email: template?.contact_data?.email ?? "", phone: template?.contact_data?.phone ?? "", username: username ?? "", - tan_channel: template?.tan_channel, + tan_channels: template?.tan_channels ?? [], }; const userIsAdmin = @@ -152,11 +186,50 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ userIsAdmin && (purpose === "create" || purpose === "update"); const editableAccount = purpose === "create" && userIsAdmin; - const hasPhone = !!defaultValue.phone || !!form.phone; - const hasEmail = !!defaultValue.email || !!form.email; + const effectiveForm = { ...defaultValue, ...form }; + const hasPhone = !!effectiveForm.phone?.trim(); + const hasEmail = !!effectiveForm.email?.trim(); function updateForm(newForm: typeof defaultValue): void { - const trimmedDebitThresholdStr = newForm.debit_threshold?.trim(); + const effective = { ...defaultValue, ...newForm }; + const disabledChannels: TanChannel[] = []; + let effectiveChannels = effective.tan_channels ?? []; + if ( + newForm.email !== undefined && + !newForm.email.trim() && + effectiveChannels.includes(TanChannel.EMAIL) + ) { + effectiveChannels = effectiveChannels.filter( + (channel) => channel !== TanChannel.EMAIL, + ); + disabledChannels.push(TanChannel.EMAIL); + } + if ( + newForm.phone !== undefined && + !newForm.phone.trim() && + effectiveChannels.includes(TanChannel.SMS) + ) { + effectiveChannels = effectiveChannels.filter( + (channel) => channel !== TanChannel.SMS, + ); + disabledChannels.push(TanChannel.SMS); + } + if (disabledChannels.length > 0) { + newForm = { ...newForm, tan_channels: effectiveChannels }; + effective.tan_channels = effectiveChannels; + setChannelNotice( + disabledChannels.includes(TanChannel.EMAIL) && + disabledChannels.includes(TanChannel.SMS) + ? i18n.str`Email and SMS authentication were disabled because their contact details were removed.` + : disabledChannels.includes(TanChannel.EMAIL) + ? i18n.str`Email authentication was disabled because the email address was removed.` + : i18n.str`SMS authentication was disabled because the phone number was removed.`, + ); + } else { + setChannelNotice(undefined); + } + + const trimmedDebitThresholdStr = effective.debit_threshold?.trim(); const parsedDebitThreshold = Amounts.parse( `${config.currency}:${trimmedDebitThresholdStr}`, ); @@ -164,40 +237,40 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const errors = undefinedIfEmpty< ErrorMessageMappingFor<typeof defaultValue> >({ - cashout_payto_uri: !newForm.cashout_payto_uri + cashout_payto_uri: !effective.cashout_payto_uri ? undefined : !editableCashout ? undefined - : !newForm.cashout_payto_uri + : !effective.cashout_payto_uri ? undefined : cashoutPaytoType === "iban" - ? validateIBAN(newForm.cashout_payto_uri, i18n) + ? validateIBAN(effective.cashout_payto_uri, i18n) : cashoutPaytoType === "x-taler-bank" - ? validateTalerBank(newForm.cashout_payto_uri, i18n) + ? validateTalerBank(effective.cashout_payto_uri, i18n) : undefined, - payto_uri: !newForm.payto_uri + payto_uri: !effective.payto_uri ? undefined : !editableAccount ? undefined - : !newForm.payto_uri + : !effective.payto_uri ? undefined : paytoType === "iban" - ? validateIBAN(newForm.payto_uri, i18n) + ? validateIBAN(effective.payto_uri, i18n) : paytoType === "x-taler-bank" - ? validateTalerBank(newForm.payto_uri, i18n) + ? validateTalerBank(effective.payto_uri, i18n) : undefined, - email: !newForm.email + email: !effective.email ? undefined - : !EMAIL_REGEX.test(newForm.email) + : !EMAIL_REGEX.test(effective.email) ? i18n.str`Invalid email format` : undefined, - phone: !newForm.phone + phone: !effective.phone ? undefined - : !newForm.phone.startsWith("+") // FIXME: better phone number check + : !effective.phone.startsWith("+") // FIXME: better phone number check ? i18n.str`Should start with +` - : !REGEX_JUST_NUMBERS_REGEX.test(newForm.phone) + : !REGEX_JUST_NUMBERS_REGEX.test(effective.phone) ? i18n.str`A phone number consists of numbers only` : undefined, debit_threshold: !editableThreshold @@ -211,12 +284,12 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ? undefined // disabled : purpose === "update" && newForm.name === undefined ? undefined // the field hasn't been changed - : !newForm.name + : !effective.name ? i18n.str`Required` : undefined, username: !editableUsername ? undefined - : !newForm.username + : !effective.username ? i18n.str`Required` : undefined, }); @@ -229,18 +302,18 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ onChange(undefined); } else { let cashout: Paytos.URI | undefined; - if (newForm.cashout_payto_uri) + if (effective.cashout_payto_uri) switch (cashoutPaytoType) { case "x-taler-bank": { cashout = Paytos.createTalerBank( url.href as HostPortPath, - newForm.cashout_payto_uri, + effective.cashout_payto_uri, ); break; } case "iban": { cashout = Paytos.createIban( - newForm.cashout_payto_uri as IbanString, + effective.cashout_payto_uri as IbanString, undefined, ); break; @@ -250,18 +323,18 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ } const cashoutURI = !cashout ? null : Paytos.toFullString(cashout); let internal: Paytos.URI | undefined; - if (newForm.payto_uri) + if (effective.payto_uri) switch (paytoType) { case "x-taler-bank": { internal = Paytos.createTalerBank( url.href as HostPortPath, - newForm.payto_uri, + effective.payto_uri, ); break; } case "iban": { internal = Paytos.createIban( - newForm.payto_uri as IbanString, + effective.payto_uri as IbanString, undefined, ); break; @@ -280,23 +353,21 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ // typescript doesn't correctly narrow a generic type const callback = onChange as ChangeByPurposeType["create"]; const result: TalerCorebankApi.RegisterAccountRequest = { - name: newForm.name!, + name: effective.name!, password: getRandomPassword(), - username: newForm.username!, + username: effective.username!, contact_data: undefinedIfEmpty({ - email: !newForm.email ? undefined : newForm.email, - phone: !newForm.phone ? undefined : newForm.phone, + email: !effective.email ? undefined : effective.email, + phone: !effective.phone ? undefined : effective.phone, }), debit_threshold: threshold ?? config.default_debit_threshold, cashout_payto_uri: cashoutURI === null ? undefined : cashoutURI, payto_uri: internalURI, - is_public: newForm.isPublic, - is_taler_exchange: newForm.isExchange, - tan_channels: - newForm.tan_channel === undefined || - newForm.tan_channel === "remove" - ? undefined - : [newForm.tan_channel], + is_public: effective.isPublic, + is_taler_exchange: effective.isExchange, + tan_channels: effective.tan_channels?.length + ? effective.tan_channels + : undefined, }; callback(result); return; @@ -305,21 +376,11 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ // typescript doesn't correctly narrow a generic type const callback = onChange as ChangeByPurposeType["update"]; - const result: TalerCorebankApi.AccountReconfiguration = { - cashout_payto_uri: cashoutURI, - contact_data: undefinedIfEmpty({ - email: !newForm.email ? undefined : newForm.email, - phone: !newForm.phone ? undefined : newForm.phone, - }), - debit_threshold: threshold, - is_public: newForm.isPublic, - name: newForm.name, - tan_channels: - newForm.tan_channel === "remove" || - newForm.tan_channel === undefined - ? undefined - : [newForm.tan_channel], - }; + const result = buildAccountReconfiguration( + newForm, + cashoutURI, + threshold, + ); callback(result); return; } @@ -332,6 +393,16 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ } } } + + function toggleTanChannel(channel: TanChannel): void { + const channels = effectiveForm.tan_channels ?? []; + updateForm({ + ...form, + tan_channels: channels.includes(channel) + ? channels.filter((candidate) => candidate !== channel) + : [...channels, channel], + }); + } return ( <form class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" @@ -516,38 +587,45 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ > {i18n.str`Enable second factor authentication`} </label> + {channelNotice && ( + <p + class="mt-2 rounded-md bg-amber-50 p-3 text-sm text-amber-900" + role="status" + > + {channelNotice} + </p> + )} <div class="mt-2 max-w-xl text-sm text-gray-500"> <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> {config.supported_tan_channels.indexOf(TanChannel.EMAIL) === -1 ? undefined : ( <label onClick={(e) => { - if (!hasEmail) return; - if (form.tan_channel === TanChannel.EMAIL) { - form.tan_channel = "remove"; - } else { - form.tan_channel = TanChannel.EMAIL; - } - updateForm(structuredClone(form)); + if (purpose === "show" || !hasEmail) return; + toggleTanChannel(TanChannel.EMAIL); e.preventDefault(); }} data-disabled={purpose === "show" || !hasEmail} - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.EMAIL - } + data-selected={effectiveForm.tan_channels?.includes( + TanChannel.EMAIL, + )} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" > <input - type="radio" - name="channel" - value="Newsletter" + type="checkbox" + name="email-channel" + value={TanChannel.EMAIL} + disabled={purpose === "show" || !hasEmail} + aria-labelledby="tan-email-label" + checked={effectiveForm.tan_channels?.includes( + TanChannel.EMAIL, + )} class="sr-only" /> <span class="flex flex-1"> <span class="flex flex-col"> <span - id="project-type-0-label" + id="tan-email-label" class="block text-sm font-medium text-gray-900 " > <i18n.Translate>Using email</i18n.Translate> @@ -558,10 +636,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </span> </span> <svg - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.EMAIL - } + data-selected={effectiveForm.tan_channels?.includes( + TanChannel.EMAIL, + )} class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" @@ -580,32 +657,31 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ -1 ? undefined : ( <label onClick={(e) => { - if (!hasPhone) return; - if (form.tan_channel === TanChannel.SMS) { - form.tan_channel = "remove"; - } else { - form.tan_channel = TanChannel.SMS; - } - updateForm(structuredClone(form)); + if (purpose === "show" || !hasPhone) return; + toggleTanChannel(TanChannel.SMS); e.preventDefault(); }} data-disabled={purpose === "show" || !hasPhone} - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.SMS - } + data-selected={effectiveForm.tan_channels?.includes( + TanChannel.SMS, + )} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" > <input - type="radio" - name="channel" - value="Existing Customers" + type="checkbox" + name="sms-channel" + value={TanChannel.SMS} + disabled={purpose === "show" || !hasPhone} + aria-labelledby="tan-sms-label" + checked={effectiveForm.tan_channels?.includes( + TanChannel.SMS, + )} class="sr-only" /> <span class="flex flex-1"> <span class="flex flex-col"> <span - id="project-type-1-label" + id="tan-sms-label" class="block text-sm font-medium text-gray-900" > <i18n.Translate>Using SMS</i18n.Translate> @@ -616,10 +692,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </span> </span> <svg - data-selected={ - (form.tan_channel ?? defaultValue.tan_channel) === - TanChannel.SMS - } + data-selected={effectiveForm.tan_channels?.includes( + TanChannel.SMS, + )} class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" @@ -695,23 +770,25 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="public-account-label" > <i18n.Translate>Is this account public?</i18n.Translate> </span> </span> <button type="button" + disabled={purpose === "show"} name="is public" data-enabled={ (form.isPublic ?? defaultValue.isPublic) ? "true" : "false" } class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" + aria-checked={!!(form.isPublic ?? defaultValue.isPublic)} + aria-labelledby="public-account-label" + aria-describedby="public-account-description" onClick={() => { + if (purpose === "show") return; form.isPublic = !(form.isPublic ?? defaultValue.isPublic); updateForm(structuredClone(form)); }} @@ -725,9 +802,14 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ></span> </button> </div> - <p class="mt-2 text-sm text-gray-500"> + <p + id="public-account-description" + class="mt-2 text-sm text-gray-500" + > <i18n.Translate> - Public accounts have their balance publicly accessible + Public accounts expose their balance and complete transaction + history, including subjects and both account addresses, to + anonymous visitors. </i18n.Translate> </p> </div> @@ -738,7 +820,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <span class="flex flex-grow flex-col"> <span class="text-sm text-black font-medium leading-6 " - id="availability-label" + id="exchange-account-label" > <i18n.Translate> Does this account belong to a Payment Service Provider? @@ -755,9 +837,8 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ } class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" + aria-checked={!!(form.isExchange ?? defaultValue.isExchange)} + aria-labelledby="exchange-account-label" onClick={() => { form.isExchange = !form.isExchange; updateForm(structuredClone(form)); @@ -800,5 +881,5 @@ function getAccountId( if (type === "x-taler-bank" && p.value.targetType === PaytoType.TalerBank) { return p.value.account; } - return "<unsupported>"; + return undefined; } diff --git a/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/CreateNewAccount.tsx @@ -63,6 +63,7 @@ export function CreateNewAccount({ >((ct, t, ac) => api.createAccount(t, ac), { onSuccess: showSuccess((success, token, account) => { onCreateSuccess(); + // Translators: The placeholder is a newly generated account password. return i18n.str`Account created with password "${account.password}".`; }), onFail: showError(i18n.str`Failed to create a new account.`, (fail) => { diff --git a/packages/libeufin-bank-webui/src/utils.ts b/packages/libeufin-bank-webui/src/utils.ts @@ -15,35 +15,13 @@ */ import { - AbsoluteTime, AmountString, + ParseIbanError, PaytoString, - TalerError, - TalerErrorCode, TranslatedString, + parseIban, } from "@gnu-taler/taler-util"; -import { - ErrorNotification, - InternationalizationAPI, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; - -const TALER_SCREEN_ID = 2; -/** - * Validate (the number part of) an amount. If needed, - * replace comma with a dot. Returns 'false' whenever - * the input is invalid, the valid amount otherwise. - */ -const amountRegex = /^[0-9]+(.[0-9]+)?$/; -export function validateAmount( - maybeAmount: string | undefined, -): string | undefined { - if (!maybeAmount || !amountRegex.test(maybeAmount)) { - return; - } - return maybeAmount; -} - +import { InternationalizationAPI } from "@gnu-taler/web-util/browser"; /** * Extract IBAN from a Payto URI. */ @@ -124,152 +102,6 @@ export const PAGINATED_LIST_SIZE = 20; // and use it to know if there are more to request export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1; -export const COUNTRY_TABLE = { - AE: "U.A.E.", - AF: "Afghanistan", - AL: "Albania", - AM: "Armenia", - AN: "Netherlands Antilles", - AR: "Argentina", - AT: "Austria", - AU: "Australia", - AZ: "Azerbaijan", - BA: "Bosnia and Herzegovina", - BD: "Bangladesh", - BE: "Belgium", - BG: "Bulgaria", - BH: "Bahrain", - BN: "Brunei Darussalam", - BO: "Bolivia", - BR: "Brazil", - BT: "Bhutan", - BY: "Belarus", - BZ: "Belize", - CA: "Canada", - CG: "Congo", - CH: "Switzerland", - CI: "Cote d'Ivoire", - CL: "Chile", - CM: "Cameroon", - CN: "People's Republic of China", - CO: "Colombia", - CR: "Costa Rica", - CS: "Serbia and Montenegro", - CZ: "Czech Republic", - DE: "Germany", - DK: "Denmark", - DO: "Dominican Republic", - DZ: "Algeria", - EC: "Ecuador", - EE: "Estonia", - EG: "Egypt", - ER: "Eritrea", - ES: "Spain", - ET: "Ethiopia", - FI: "Finland", - FO: "Faroe Islands", - FR: "France", - GB: "United Kingdom", - GD: "Caribbean", - GE: "Georgia", - GL: "Greenland", - GR: "Greece", - GT: "Guatemala", - HK: "Hong Kong", - // HK: "Hong Kong S.A.R.", - HN: "Honduras", - HR: "Croatia", - HT: "Haiti", - HU: "Hungary", - ID: "Indonesia", - IE: "Ireland", - IL: "Israel", - IN: "India", - IQ: "Iraq", - IR: "Iran", - IS: "Iceland", - IT: "Italy", - JM: "Jamaica", - JO: "Jordan", - JP: "Japan", - KE: "Kenya", - KG: "Kyrgyzstan", - KH: "Cambodia", - KR: "South Korea", - KW: "Kuwait", - KZ: "Kazakhstan", - LA: "Laos", - LB: "Lebanon", - LI: "Liechtenstein", - LK: "Sri Lanka", - LT: "Lithuania", - LU: "Luxembourg", - LV: "Latvia", - LY: "Libya", - MA: "Morocco", - MC: "Principality of Monaco", - MD: "Moldava", - // MD: "Moldova", - ME: "Montenegro", - MK: "Former Yugoslav Republic of Macedonia", - ML: "Mali", - MM: "Myanmar", - MN: "Mongolia", - MO: "Macau S.A.R.", - MT: "Malta", - MV: "Maldives", - MX: "Mexico", - MY: "Malaysia", - NG: "Nigeria", - NI: "Nicaragua", - NL: "Netherlands", - NO: "Norway", - NP: "Nepal", - NZ: "New Zealand", - OM: "Oman", - PA: "Panama", - PE: "Peru", - PH: "Philippines", - PK: "Islamic Republic of Pakistan", - PL: "Poland", - PR: "Puerto Rico", - PT: "Portugal", - PY: "Paraguay", - QA: "Qatar", - RE: "Reunion", - RO: "Romania", - RS: "Serbia", - RU: "Russia", - RW: "Rwanda", - SA: "Saudi Arabia", - SE: "Sweden", - SG: "Singapore", - SI: "Slovenia", - SK: "Slovak", - SN: "Senegal", - SO: "Somalia", - SR: "Suriname", - SV: "El Salvador", - SY: "Syria", - TH: "Thailand", - TJ: "Tajikistan", - TM: "Turkmenistan", - TN: "Tunisia", - TR: "Turkey", - TT: "Trinidad and Tobago", - TW: "Taiwan", - TZ: "Tanzania", - UA: "Ukraine", - US: "United States", - UY: "Uruguay", - VA: "Vatican", - VE: "Venezuela", - VN: "Viet Nam", - YE: "Yemen", - ZA: "South Africa", - ZW: "Zimbabwe", -}; - /** * An IBAN is validated by converting it into an integer and performing a * basic mod-97 operation (as described in ISO 7064) on it. @@ -284,52 +116,28 @@ export const COUNTRY_TABLE = { * If the remainder is 1, the check digit test is passed and the IBAN might be valid. * */ -const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; export function validateIBAN( account: string, i18n: InternationalizationAPI, ): TranslatedString | undefined { - if (!IBAN_REGEX.test(account)) { - return i18n.str`An IBAN consists of capital letters and numbers only`; - } - // Check total length - if (account.length < 4) return i18n.str`IBAN numbers have more than 4 digits`; - if (account.length > 34) - return i18n.str`IBAN numbers have less than 34 digits`; - - const A_code = "A".charCodeAt(0); - const Z_code = "Z".charCodeAt(0); - const IBAN = account.toUpperCase(); - // check supported country - const code = IBAN.substring(0, 2); - const found = code in COUNTRY_TABLE; - if (!found) return i18n.str`IBAN country code not found`; - - // 2.- Move the four initial characters to the end of the string - const step2 = IBAN.substring(4) + account.substring(0, 4); - const step3 = Array.from(step2) - .map((letter) => { - const code = letter.charCodeAt(0); - if (code < A_code || code > Z_code) return letter; - return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`; - }) - .join(""); - - const checksum = calculate_iban_checksum(step3); - if (checksum !== 1) - return i18n.str`IBAN number is not valid, checksum is wrong`; - return undefined; -} - -function calculate_iban_checksum(str: string): number { - const numberStr = str.substring(0, 5); - const rest = str.substring(5); - const number = parseInt(numberStr, 10); - const result = number % 97; - if (rest.length > 0) { - return calculate_iban_checksum(`${result}${rest}`); + const result = parseIban(account); + if (result.tag === "ok") return undefined; + switch (result.error) { + case ParseIbanError.UNSUPPORTED_COUNTRY: + return i18n.str`IBAN country code not found`; + case ParseIbanError.TOO_LONG: + return i18n.str`IBAN numbers have less than 34 digits`; + case ParseIbanError.TOO_SHORT: + return i18n.str`IBAN numbers have more than 4 digits`; + case ParseIbanError.INVALID_LENGTH: + return i18n.str`IBAN length is invalid for this country`; + case ParseIbanError.INVALID_CHARSET: + return i18n.str`The IBAN contains invalid characters`; + case ParseIbanError.INVALID_CHECKSUM: + return i18n.str`IBAN number is not valid, checksum is wrong`; + default: + return i18n.str`The IBAN is not valid`; } - return result; } export const USERNAME_REGEX = /^[a-zA-Z0-9\-._~]*$/;