diff options
Diffstat (limited to 'packages/demobank-ui/src/pages/admin/AccountForm.tsx')
-rw-r--r-- | packages/demobank-ui/src/pages/admin/AccountForm.tsx | 536 |
1 files changed, 273 insertions, 263 deletions
diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx index 5d4a5c5db..3aba99cea 100644 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -39,11 +39,11 @@ import { TanChannel, undefinedIfEmpty, validateIBAN, + validateTalerBank, } from "../../utils.js"; -import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; +import { InputAmount, TextField, 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 ]*$/; @@ -90,7 +90,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ onChange: ChangeByPurposeType[PurposeType]; purpose: PurposeType; }): VNode { - const { config, hints } = useBankCoreApiContext(); + const { config, hints, url } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); const [form, setForm] = useState<AccountFormData>({}); @@ -99,6 +99,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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, @@ -107,8 +110,8 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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), + 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 ?? "", @@ -117,10 +120,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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; @@ -131,7 +130,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const isCashoutEnabled = config.allow_conversion; const editableCashout = - showingCurrentUserInfo && (purpose === "create" || (purpose === "update" && (config.allow_edit_cashout_payto_uri || userIsAdmin))); @@ -143,13 +141,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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( @@ -163,24 +154,20 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ? 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), + : !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 - : !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), + : !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) @@ -219,14 +206,31 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ if (errors) { onChange(undefined); } else { - const cashout = !newForm.cashout_payto_uri - ? undefined - : buildPayto("iban", newForm.cashout_payto_uri, undefined); + 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); - - const internal = !newForm.payto_uri - ? undefined - : buildPayto("iban", newForm.payto_uri, undefined); + 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 @@ -328,7 +332,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ /> </div> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate>Account identification</i18n.Translate> + <i18n.Translate>Account id for authentication</i18n.Translate> </p> </div> @@ -366,22 +370,26 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </p> </div> - <PaytoField - type="iban" - name="internal-account" - label={i18n.str`Internal IBAN`} + <TextField + id="internal-account" + label={i18n.str`Internal account`} help={ purpose === "create" - ? i18n.str`If empty a random account number will be assigned` - : i18n.str`Account number for bank transfers` + ? i18n.str`If empty a random account id will be assigned` + : i18n.str`Share this id to receive bank transfers` } - value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} - disabled={!editableAccount} + 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"> @@ -411,6 +419,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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"> @@ -440,102 +451,26 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ 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> - {showingCurrentUserInfo && isCashoutEnabled && ( - <PaytoField - type="iban" - name="cashout-account" - label={i18n.str`Cashout IBAN`} + {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`} - value={ - (form.cashout_payto_uri ?? - defaultValue.cashout_payto_uri) as PaytoString - } - disabled={!editableCashout} 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 is user able to transfer after zero balance - </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 a payment provider?</i18n.Translate> - </span> - </span> - <button - type="button" - 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> - )} {/* channel, not shown if old cashout api */} {OLD_CASHOUT_API || config.supported_tan_channels.length === 0 ? undefined : ( @@ -584,7 +519,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </span> {purpose !== "show" && !hasEmail && - i18n.str`Add a email in your profile to enable this option`} + i18n.str`Add an email in your profile to enable this option`} </span> </span> <svg @@ -669,6 +604,38 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ )} <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 @@ -703,11 +670,51 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </button> </div> <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - Public accounts have their balance publicly accessible - </i18n.Translate> + <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" + 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} @@ -715,13 +722,14 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ ); } -function stringifyIbanPayto(s: PaytoString | undefined): string | undefined { +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 undefined; - if (p.targetType !== "iban") return undefined; - return p.iban; + if (!p.isKnown) return "<unkown>"; + if (type === "iban" && p.targetType === "iban") return p.iban; + if (type === "x-taler-bank" && p.targetType === "x-taler-bank") return p.account; + return "<unsupported>"; } { @@ -762,126 +770,128 @@ function stringifyIbanPayto(s: PaytoString | undefined): string | undefined { </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 ?? ""} - /> - <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"> - {/* <i18n.Translate>internal account id</i18n.Translate> */} - {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); -} +// 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); +// } |