/* 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, assertUnreachable, buildPayto, parsePaytoUri, stringifyPaytoUri, } from "@gnu-taler/taler-util"; import { 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 { useSessionState } from "../../hooks/session.js"; import { ErrorMessageMappingFor, TanChannel, undefinedIfEmpty, validateIBAN, validateTalerBank, } from "../../utils.js"; import { InputAmount, TextField, doAutoFocus, } from "../PaytoWireTransferForm.js"; import { getRandomPassword } from "../rnd.js"; 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, url } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); const { state: credentials } = useSessionState(); const [form, setForm] = useState({}); const [errors, setErrors] = useState< ErrorMessageMappingFor | undefined >(undefined); const paytoType = config.wire_type === "X_TALER_BANK" ? ("x-taler-bank" as const) : ("iban" as const); const cashoutPaytoType: typeof paytoType = "iban" as const; 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: getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ?? ("" as PaytoString), payto_uri: getAccountId(paytoType, 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 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 = 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 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 : !newForm.cashout_payto_uri ? undefined : cashoutPaytoType === "iban" ? validateIBAN(newForm.cashout_payto_uri, i18n) : cashoutPaytoType === "x-taler-bank" ? validateTalerBank(newForm.cashout_payto_uri, i18n) : undefined, payto_uri: !newForm.payto_uri ? undefined : !editableAccount ? undefined : !newForm.payto_uri ? undefined : paytoType === "iban" ? validateIBAN(newForm.payto_uri, i18n) : paytoType === "x-taler-bank" ? validateTalerBank(newForm.payto_uri, i18n) : undefined, 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 { let cashout; if (newForm.cashout_payto_uri) switch (cashoutPaytoType) { case "x-taler-bank": { cashout = buildPayto( "x-taler-bank", url.host, newForm.cashout_payto_uri, ); break; } case "iban": { cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined); break; } default: assertUnreachable(cashoutPaytoType); } const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout); let internal; if (newForm.payto_uri) switch (paytoType) { case "x-taler-bank": { internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri); break; } case "iban": { internal = buildPayto("iban", newForm.payto_uri, undefined); break; } default: assertUnreachable(paytoType); } 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 ? undefined : newForm.email, phone: !newForm.phone ? undefined : 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 ? undefined : newForm.email, phone: !newForm.phone ? undefined : 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 id for authentication {i18n.str`Full name`} {editableName && *} { form.name = e.currentTarget.value; updateForm(structuredClone(form)); }} // placeholder="" autocomplete="off" /> Name of the account holder {purpose === "create" ? undefined : ( { form.payto_uri = e as PaytoString; updateForm(structuredClone(form)); }} rightIcons={ form.payto_uri ?? defaultValue.payto_uri ?? "" } /> } value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} disabled={!editableAccount} /> )} {i18n.str`Email`} { form.email = e.currentTarget.value; updateForm(structuredClone(form)); }} autocomplete="off" /> To be used when second factor authentication is enabled {i18n.str`Phone`} { form.phone = e.currentTarget.value; updateForm(structuredClone(form)); }} autocomplete="off" /> To be used when second factor authentication is enabled {isCashoutEnabled && ( { form.cashout_payto_uri = e as PaytoString; updateForm(structuredClone(form)); }} value={ (form.cashout_payto_uri ?? defaultValue.cashout_payto_uri) as PaytoString } disabled={!editableCashout} /> )} {/* 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 an 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`} )} )} {i18n.str`Max debt`} { form.debit_threshold = e as AmountString; updateForm(structuredClone(form)); } } /> How much the balance can go below zero. Is this account public? { form.isPublic = !(form.isPublic ?? defaultValue.isPublic); updateForm(structuredClone(form)); }} > Public accounts have their balance publicly accessible {purpose !== "create" || !userIsAdmin ? undefined : ( Is this account a payment provider? { form.isExchange = !form.isExchange; updateForm(structuredClone(form)); }} > )} {children} ); } function getAccountId( type: "iban" | "x-taler-bank", s: PaytoString | undefined, ): string | undefined { if (s === undefined) return undefined; const p = parsePaytoUri(s); if (p === undefined) return undefined; if (!p.isKnown) return ""; if (type === "iban" && p.targetType === "iban") return p.iban; if (type === "x-taler-bank" && p.targetType === "x-taler-bank") return p.account; return ""; } { /* {} { 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} // // // // { // onChange(e.currentTarget.value); // }} // /> // value ?? ""} // /> // // // // // {help} // // // ); // } // if (type === "bitcoin") { // return ( // // // {label} // // // // // value ?? ""} // /> // // // // // {/* bitcoin address */} // {help} // // // ); // } // assertUnreachable(type); // }
Account id for authentication
Name of the account holder
To be used when second factor authentication is enabled
How much the balance can go below zero.
Public accounts have their balance publicly accessible
{help}
// {help} //
// {/* bitcoin address */} // {help} //