/* This file is part of GNU Taler (C) 2022-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 */ import { AmountString, Amounts, PaytoString, TalerCorebankApi, TranslatedString, assertUnreachable, buildPayto, parsePaytoUri, stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { Attention, CopyButton, ShowInputErrorLabel, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; import { useBackendState } from "../../hooks/backend.js"; import { ErrorMessageMappingFor, TanChannel, undefinedIfEmpty, validateIBAN, } from "../../utils.js"; import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; import { getRandomPassword } from "../rnd.js"; const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; 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 AccountFormData = { debit_threshold?: string; isExchange?: boolean; isPublic?: boolean; name?: string; username?: string; payto_uri?: string; cashout_payto_uri?: string; email?: string; phone?: string; tan_channel?: TanChannel | "remove"; }; type ChangeByPurposeType = { create: (a: TalerCorebankApi.RegisterAccountRequest | undefined) => void; update: (a: TalerCorebankApi.AccountReconfiguration | undefined) => void; show: undefined; }; /** * FIXME: * is_public is missing on PATCH * account email/password should require 2FA * * * @param param0 * @returns */ export function AccountForm({ template, username, purpose, onChange, focus, children, }: { focus?: boolean; children: ComponentChildren; username?: string; template: TalerCorebankApi.AccountData | undefined; onChange: ChangeByPurposeType[PurposeType]; purpose: PurposeType; }): VNode { const { config, hints } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); const [form, setForm] = useState({}); const [errors, setErrors] = useState< ErrorMessageMappingFor | undefined >(undefined); const defaultValue: AccountFormData = { debit_threshold: Amounts.stringifyValue( template?.debit_threshold ?? config.default_debit_threshold, ), isExchange: template?.is_taler_exchange, isPublic: template?.is_public, name: template?.name ?? "", cashout_payto_uri: stringifyIbanPayto(template?.cashout_payto_uri) ?? ("" as PaytoString), payto_uri: stringifyIbanPayto(template?.payto_uri) ?? ("" as PaytoString), email: template?.contact_data?.email ?? "", phone: template?.contact_data?.phone ?? "", username: username ?? "", tan_channel: template?.tan_channel, }; const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; const showingCurrentUserInfo = credentials.status !== "loggedIn" ? false : username === credentials.username; const userIsAdmin = credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; const editableUsername = purpose === "create"; const editableName = purpose === "create" || (purpose === "update" && (config.allow_edit_name || userIsAdmin)); const isCashoutEnabled = config.allow_conversion; const editableCashout = showingCurrentUserInfo && (purpose === "create" || (purpose === "update" && (config.allow_edit_cashout_payto_uri || userIsAdmin))); const editableThreshold = userIsAdmin && (purpose === "create" || purpose === "update"); const editableAccount = purpose === "create" && userIsAdmin; const hasPhone = !!defaultValue.phone || !!form.phone; const hasEmail = !!defaultValue.email || !!form.email; function updateForm(newForm: typeof defaultValue): void { const cashoutParsed = !newForm.cashout_payto_uri ? undefined : buildPayto("iban", newForm.cashout_payto_uri, undefined); const internalParsed = !newForm.payto_uri ? undefined : buildPayto("iban", newForm.payto_uri, undefined); const trimmedAmountStr = newForm.debit_threshold?.trim(); const parsedAmount = Amounts.parse( `${config.currency}:${trimmedAmountStr}`, ); const errors = undefinedIfEmpty< ErrorMessageMappingFor >({ cashout_payto_uri: !newForm.cashout_payto_uri ? undefined : !editableCashout ? undefined : !cashoutParsed ? i18n.str`Doesn't have the pattern of an IBAN number` : !cashoutParsed.isKnown || cashoutParsed.targetType !== "iban" ? i18n.str`Only "IBAN" target are supported` : !IBAN_REGEX.test(cashoutParsed.iban) ? i18n.str`IBAN should have just uppercased letters and numbers` : validateIBAN(cashoutParsed.iban, i18n), payto_uri: !newForm.payto_uri ? undefined : !editableAccount ? undefined : !internalParsed ? i18n.str`Doesn't have the pattern of an IBAN number` : !internalParsed.isKnown || internalParsed.targetType !== "iban" ? i18n.str`Only "IBAN" target are supported` : !IBAN_REGEX.test(internalParsed.iban) ? i18n.str`IBAN should have just uppercased letters and numbers` : validateIBAN(internalParsed.iban, i18n), email: !newForm.email ? undefined : !EMAIL_REGEX.test(newForm.email) ? i18n.str`Doesn't have the pattern of an email` : undefined, phone: !newForm.phone ? undefined : !newForm.phone.startsWith("+") // FIXME: better phone number check ? i18n.str`Should start with +` : !REGEX_JUST_NUMBERS_REGEX.test(newForm.phone) ? i18n.str`Phone number can't have other than numbers` : undefined, debit_threshold: !editableThreshold ? undefined : !trimmedAmountStr ? undefined : !parsedAmount ? i18n.str`Not valid` : undefined, name: !editableName ? undefined // disabled : !newForm.name ? i18n.str`Required` : undefined, username: !editableUsername ? undefined : !newForm.username ? i18n.str`Required` : undefined, }); setErrors(errors); setForm(newForm); if (!onChange) return; if (errors) { onChange(undefined); } else { const cashout = !newForm.cashout_payto_uri ? undefined : buildPayto("iban", newForm.cashout_payto_uri, undefined); const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout); const internal = !newForm.payto_uri ? undefined : buildPayto("iban", newForm.payto_uri, undefined); const internalURI = !internal ? undefined : stringifyPaytoUri(internal); const threshold = !parsedAmount ? undefined : Amounts.stringify(parsedAmount); switch (purpose) { case "create": { // typescript doesn't correctly narrow a generic type const callback = onChange as ChangeByPurposeType["create"]; const result: TalerCorebankApi.RegisterAccountRequest = { name: newForm.name!, password: getRandomPassword(), username: newForm.username!, contact_data: undefinedIfEmpty({ email: newForm.email, phone: newForm.phone, }), debit_threshold: threshold ?? config.default_debit_threshold, cashout_payto_uri: cashoutURI, payto_uri: internalURI, is_public: newForm.isPublic, is_taler_exchange: newForm.isExchange, tan_channel: newForm.tan_channel === "remove" ? undefined : newForm.tan_channel, }; callback(result); return; } case "update": { // 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, phone: newForm.phone, }), debit_threshold: threshold, is_public: newForm.isPublic, name: newForm.name, tan_channel: newForm.tan_channel === "remove" ? null : newForm.tan_channel, }; callback(result); return; } case "show": { return; } default: { assertUnreachable(purpose); } } } } return ( { e.preventDefault(); }} > {i18n.str`Login username`} {editableUsername && *} { form.username = e.currentTarget.value; updateForm(structuredClone(form)); }} // placeholder="" autocomplete="off" /> Account identification {i18n.str`Full name`} {editableName && *} { form.name = e.currentTarget.value; updateForm(structuredClone(form)); }} // placeholder="" autocomplete="off" /> Name of the account holder { form.payto_uri = e as PaytoString; updateForm(structuredClone(form)); }} /> {i18n.str`Email`} { form.email = e.currentTarget.value; updateForm(structuredClone(form)); }} autocomplete="off" /> {i18n.str`Phone`} { form.phone = e.currentTarget.value; updateForm(structuredClone(form)); }} autocomplete="off" /> {showingCurrentUserInfo && isCashoutEnabled && ( { form.cashout_payto_uri = e as PaytoString; updateForm(structuredClone(form)); }} /> )} {i18n.str`Max debt`} { form.debit_threshold = e as AmountString; updateForm(structuredClone(form)); } } /> How much is user able to transfer after zero balance {purpose !== "create" || !userIsAdmin ? undefined : ( Is this a Taler Exchange? { form.isExchange = !form.isExchange; updateForm(structuredClone(form)); }} > )} {/* channel, not shown if old cashout api */} {OLD_CASHOUT_API || config.supported_tan_channels.length === 0 ? undefined : ( {i18n.str`Enable second factor authentication`} {config.supported_tan_channels.indexOf(TanChannel.EMAIL) === -1 ? undefined : ( { if (!hasEmail) return; if (form.tan_channel === TanChannel.EMAIL) { form.tan_channel = "remove"; } else { form.tan_channel = TanChannel.EMAIL; } updateForm(structuredClone(form)); e.preventDefault(); }} data-disabled={purpose === "show" || !hasEmail} data-selected={ (form.tan_channel ?? defaultValue.tan_channel) === 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" > Using email {purpose !== "show" && !hasEmail && i18n.str`Add a email in your profile to enable this option`} )} {config.supported_tan_channels.indexOf(TanChannel.SMS) === -1 ? undefined : ( { if (!hasPhone) return; if (form.tan_channel === TanChannel.SMS) { form.tan_channel = "remove"; } else { form.tan_channel = TanChannel.SMS; } updateForm(structuredClone(form)); e.preventDefault(); }} data-disabled={purpose === "show" || !hasPhone} data-selected={ (form.tan_channel ?? defaultValue.tan_channel) === 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" > Using SMS {purpose !== "show" && !hasPhone && i18n.str`Add a phone number in your profile to enable this option`} )} )} Is this account public? { form.isPublic = !(form.isPublic ?? defaultValue.isPublic); updateForm(structuredClone(form)); }} > Public accounts have their balance publicly accessible {children} ); } function stringifyIbanPayto(s: PaytoString | undefined): string | undefined { if (s === undefined) return undefined; const p = parsePaytoUri(s); if (p === undefined) return undefined; if (!p.isKnown) return undefined; if (p.targetType !== "iban") return undefined; return p.iban; } { /* {} { form.cashout_payto_uri = e.currentTarget.value as PaytoString; if (!form.cashout_payto_uri) { form.cashout_payto_uri = undefined } updateForm(structuredClone(form)); }} autocomplete="off" /> */ } function PaytoField({ name, label, help, type, value, disabled, onChange, error, }: { error: TranslatedString | undefined; name: string; label: TranslatedString; help: TranslatedString; onChange: (s: string) => void; type: "iban" | "x-taler-bank" | "bitcoin"; disabled?: boolean; value: string | undefined; }): VNode { if (type === "iban") { return ( {label} { onChange(e.currentTarget.value); }} /> value ?? ""} /> {help} ); } if (type === "x-taler-bank") { return ( {label} value ?? ""} /> {/* internal account id */} {help} ); } if (type === "bitcoin") { return ( {label} value ?? ""} /> {/* bitcoin address */} {help} ); } assertUnreachable(type); }
Account identification
Name of the account holder
How much is user able to transfer after zero balance
Public accounts have their balance publicly accessible
{help}
{/* internal account id */} {help}
{/* bitcoin address */} {help}