diff options
Diffstat (limited to 'packages/bank-ui/src/pages/admin/AccountForm.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/admin/AccountForm.tsx | 804 |
1 files changed, 804 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..c8195ddb0 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -0,0 +1,804 @@ +/* + 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, + 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 { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; +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, 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 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; + + 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} + /> + )} + + <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); +// } |