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:
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\-._~]*$/;