diff options
Diffstat (limited to 'packages/bank-ui/src/pages/admin/AccountForm.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/admin/AccountForm.tsx | 901 |
1 files changed, 901 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx new file mode 100644 index 000000000..bce7afe11 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -0,0 +1,901 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ +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 { 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<PurposeType extends keyof ChangeByPurposeType>({ + 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<AccountFormData>({}); + + const [errors, setErrors] = useState< + ErrorMessageMappingFor<typeof defaultValue> | 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<typeof defaultValue> + >({ + 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 ( + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Login username`} + {editableUsername && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + ref={focus && purpose === "create" ? doAutoFocus : undefined} + 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="username" + id="username" + data-error={!!errors?.username && form.username !== undefined} + disabled={!editableUsername} + value={form.username ?? defaultValue.username} + onChange={(e) => { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={form.username !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Account id for authentication</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="name" + > + {i18n.str`Full name`} + {editableName && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <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="name" + data-error={!!errors?.name && form.name !== undefined} + id="name" + disabled={!editableName} + value={form.name ?? defaultValue.name} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Name of the account holder</i18n.Translate> + </p> + </div> + + {purpose === "create" ? undefined : + <TextField + id="internal-account" + label={i18n.str`Internal account`} + help={ + purpose === "create" + ? i18n.str`If empty a random account id will be assigned` + : i18n.str`Share this id to receive bank transfers` + } + + error={errors?.payto_uri} + onChange={(e) => { + form.payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + rightIcons={<CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => form.payto_uri ?? defaultValue.payto_uri ?? ""} + />} + value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} + disabled={!editableAccount} + /> + } + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="email" + > + {i18n.str`Email`} + </label> + <div class="mt-2"> + <input + type="email" + 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="email" + id="email" + data-error={!!errors?.email && form.email !== undefined} + disabled={purpose === "show"} + value={form.email ?? defaultValue.email} + onChange={(e) => { + form.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.email} + isDirty={form.email !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>To be used when second factor authentication is enabled</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="phone" + > + {i18n.str`Phone`} + </label> + <div class="mt-2"> + <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="phone" + id="phone" + disabled={purpose === "show"} + value={form.phone ?? defaultValue.phone} + data-error={!!errors?.phone && form.phone !== undefined} + onChange={(e) => { + form.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.phone} + isDirty={form.phone !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>To be used when second factor authentication is enabled</i18n.Translate> + </p> + </div> + + {isCashoutEnabled && ( + <TextField + id="cashout-account" + label={i18n.str`Cashout account`} + help={i18n.str`External account number where the money is going to be sent when doing cashouts`} + error={errors?.cashout_payto_uri} + onChange={(e) => { + 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 : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Enable second factor authentication`} + </label> + <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)); + 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" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Using email</i18n.Translate> + </span> + {purpose !== "show" && + !hasEmail && + i18n.str`Add an email in your profile to enable this option`} + </span> + </span> + <svg + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.EMAIL + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === + -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)); + 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" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-1-label" + class="block text-sm font-medium text-gray-900" + > + <i18n.Translate>Using SMS</i18n.Translate> + </span> + {purpose !== "show" && + !hasPhone && + i18n.str`Add a phone number in your profile to enable this option`} + </span> + </span> + <svg + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.SMS + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + </div> + </div> + </div> + )} + + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Max debt`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={form.debit_threshold ?? defaultValue.debit_threshold} + onChange={ + !editableThreshold + ? undefined + : (e) => { + form.debit_threshold = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.debit_threshold + ? String(errors?.debit_threshold) + : undefined + } + isDirty={form.debit_threshold !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>How much the balance can go below zero.</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Is this account public?</i18n.Translate> + </span> + </span> + <button + type="button" + 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" + onClick={() => { + form.isPublic = !(form.isPublic ?? defaultValue.isPublic); + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={ + form.isPublic ?? defaultValue.isPublic ? "true" : "false" + } + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Public accounts have their balance publicly accessible</i18n.Translate> + </p> + </div> + + {purpose !== "create" || !userIsAdmin ? undefined : ( + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Is this account a payment provider?</i18n.Translate> + </span> + </span> + <button + type="button" + name="is exchange" + data-enabled={ + form.isExchange ?? defaultValue.isExchange + ? "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" + onClick={() => { + form.isExchange = !form.isExchange; + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={ + form.isExchange ?? defaultValue.isExchange + ? "true" + : "false" + } + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + )} + </div> + </div> + {children} + </form> + ); +} + +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 "<unknown>"; + if (type === "iban" && p.targetType === "iban") return p.iban; + if (type === "x-taler-bank" && p.targetType === "x-taler-bank") return p.account; + return "<unsupported>"; +} + +{ + /* <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="cashout" + > + {} + </label> + <div class="mt-2"> + <input + type="text" + ref={focus && purpose === "update" ? doAutoFocus : undefined} + data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} + 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="cashout" + id="cashout" + disabled={purpose === "show"} + value={form.cashout_payto_uri ?? defaultValue.cashout_payto_uri} + onChange={(e) => { + form.cashout_payto_uri = e.currentTarget.value as PaytoString; + if (!form.cashout_payto_uri) { + form.cashout_payto_uri = undefined + } + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.cashout_payto_uri} + isDirty={form.cashout_payto_uri !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate></i18n.Translate> + </p> + </div> */ +} + +// 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 ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline 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={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// onChange={(e) => { +// onChange(e.currentTarget.value); +// }} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// </div> +// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> +// </div> +// <p class="mt-2 text-sm text-gray-500">{help}</p> +// </div> +// ); +// } +// if (type === "x-taler-bank") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline 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={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// onChange={(e) => { +// onChange(e.currentTarget.value); +// }} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// </div> +// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {help} +// </p> +// </div> +// ); +// } +// if (type === "bitcoin") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline 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={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// <ShowInputErrorLabel +// message={error} +// isDirty={value !== undefined} +// /> +// </div> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {/* <i18n.Translate>bitcoin address</i18n.Translate> */} +// {help} +// </p> +// </div> +// ); +// } +// assertUnreachable(type); +// } |