diff options
author | Sebastian <sebasjm@gmail.com> | 2024-02-06 16:51:15 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-02-06 16:51:42 -0300 |
commit | 4eda6ac07c78bcb3c2daa7846b4cd36048f9c7dd (patch) | |
tree | 056f0b56bdcf308c0c08d8851c485fcdc444011d /packages/demobank-ui/src | |
parent | 6c496c070d47e26034a3e2dd6d14a1a9ea42b729 (diff) |
support for x-taler-bank and fix cache invalidation when new account is created
Diffstat (limited to 'packages/demobank-ui/src')
-rw-r--r-- | packages/demobank-ui/src/Routing.tsx | 11 | ||||
-rw-r--r-- | packages/demobank-ui/src/components/Cashouts/views.tsx | 20 | ||||
-rw-r--r-- | packages/demobank-ui/src/context/config.ts | 25 | ||||
-rw-r--r-- | packages/demobank-ui/src/hooks/access.ts | 16 | ||||
-rw-r--r-- | packages/demobank-ui/src/hooks/circuit.ts | 24 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PaymentOptions.tsx | 15 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx | 357 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/SolveChallengePage.tsx | 6 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/admin/AccountForm.tsx | 536 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/admin/AccountList.tsx | 24 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/admin/AdminHome.tsx | 6 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx | 2 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/business/CreateCashout.tsx | 23 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx | 12 | ||||
-rw-r--r-- | packages/demobank-ui/src/utils.ts | 99 |
15 files changed, 708 insertions, 468 deletions
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index 9f9475210..00811f2a7 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -57,7 +57,7 @@ export function Routing(): VNode { if (backend.state.status === "loggedIn") { const { isUserAdministrator, username } = backend.state; return ( - <BankFrame account={username}> + <BankFrame account={username} routeAccountDetails={privatePages.myAccountDetails}> <PrivateRouting username={username} isAdmin={isUserAdministrator} /> </BankFrame> ); @@ -147,7 +147,6 @@ function PublicRounting({ <div class="sm:mx-auto sm:w-full sm:max-w-sm"> <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Welcome to ${settings.bankName}!`}</h2> </div> - <LoginForm routeRegister={publicPages.register} /> </Fragment> ); @@ -228,19 +227,19 @@ export const privatePages = { myAccountPassword: urlPattern(/\/my-password/, () => "#/my-password"), myAccountCashouts: urlPattern(/\/my-cashouts/, () => "#/my-cashouts"), accountDetails: urlPattern<{ account: string }>( - /\/profile\/(?<account>[a-zA-Z0-9]+)\/details/, + /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/details/, ({ account }) => `#/profile/${account}/details`, ), accountChangePassword: urlPattern<{ account: string }>( - /\/profile\/(?<account>[a-zA-Z0-9]+)\/change-password/, + /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/change-password/, ({ account }) => `#/profile/${account}/change-password`, ), accountDelete: urlPattern<{ account: string }>( - /\/profile\/(?<account>[a-zA-Z0-9]+)\/delete/, + /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/delete/, ({ account }) => `#/profile/${account}/delete`, ), accountCashouts: urlPattern<{ account: string }>( - /\/profile\/(?<account>[a-zA-Z0-9]+)\/cashouts/, + /\/profile\/(?<account>[a-zA-Z0-9_-]+)\/cashouts/, ({ account }) => `#/profile/${account}/cashouts`, ), startOperation: urlPattern<{ wopid: string }>( diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index d036ec7d2..80eea6379 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -39,8 +39,10 @@ export function FailedView({ error }: State.Failed) { return ( <Attention type="danger" - title={i18n.str`Cashout not implemented`} - ></Attention> + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); } default: @@ -66,8 +68,10 @@ export function ReadyView({ return ( <Attention type="danger" - title={i18n.str`Cashout not implemented`} - ></Attention> + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); } default: @@ -82,8 +86,8 @@ export function ReadyView({ cur.creation_time.t_s === "never" ? "" : format(cur.creation_time.t_s * 1000, "dd/MM/yyyy", { - locale: dateLocale, - }); + locale: dateLocale, + }); if (!prev[d]) { prev[d] = []; } @@ -141,8 +145,8 @@ export function ReadyView({ item.creation_time.t_s === "never" ? "" : format(item.creation_time.t_s * 1000, "HH:mm:ss", { - locale: dateLocale, - }); + locale: dateLocale, + }); return ( <tr key={idx} diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts index 5d8a5c73f..1cabab51c 100644 --- a/packages/demobank-ui/src/context/config.ts +++ b/packages/demobank-ui/src/context/config.ts @@ -157,8 +157,8 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { async deleteAccount(auth: UserAndToken, cid?: string | undefined) { const resp = await super.deleteAccount(auth, cid); if (resp.type === "ok") { - revalidatePublicAccounts(); - revalidateBusinessAccounts(); + await revalidatePublicAccounts(); + await revalidateBusinessAccounts(); } return resp; } @@ -168,8 +168,11 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { ) { const resp = await super.createAccount(auth, body); if (resp.type === "ok") { - revalidatePublicAccounts(); - revalidateBusinessAccounts(); + // admin balance change on new account + await revalidateAccountDetails(); + await revalidateTransactions(); + await revalidatePublicAccounts(); + await revalidateBusinessAccounts(); } return resp; } @@ -180,7 +183,7 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { ) { const resp = await super.updateAccount(auth, body, cid); if (resp.type === "ok") { - revalidateAccountDetails(); + await revalidateAccountDetails(); } return resp; } @@ -191,8 +194,8 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { ) { const resp = await super.createTransaction(auth, body, cid); if (resp.type === "ok") { - revalidateAccountDetails(); - revalidateTransactions(); + await revalidateAccountDetails(); + await revalidateTransactions(); } return resp; } @@ -203,8 +206,8 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { ) { const resp = await super.confirmWithdrawalById(auth, wid, cid); if (resp.type === "ok") { - revalidateAccountDetails(); - revalidateTransactions(); + await revalidateAccountDetails(); + await revalidateTransactions(); } return resp; } @@ -215,8 +218,8 @@ export class CacheAwareApi extends TalerCoreBankHttpClient { ) { const resp = await super.createCashout(auth, body, cid); if (resp.type === "ok") { - revalidateAccountDetails(); - revalidateCashouts(); + await revalidateAccountDetails(); + await revalidateCashouts(); } return resp; } diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index 85d030245..e07a3d1b1 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -35,7 +35,7 @@ export interface InstanceTemplateFilter { } export function revalidateAccountDetails() { - mutate( + return mutate( (key) => Array.isArray(key) && key[key.length - 1] === "getAccount", undefined, { revalidate: true }, @@ -62,9 +62,7 @@ export function useAccountDetails(account: string) { } export function revalidateWithdrawalDetails() { - mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", - ); + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById", undefined, { revalidate: true }); } export function useWithdrawalDetails(wid: string) { @@ -111,8 +109,8 @@ export function useWithdrawalDetails(wid: string) { } export function revalidateTransactionDetails() { - mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getTransactionById", undefined, { revalidate: true } ); } export function useTransactionDetails(account: string, tid: number) { @@ -150,8 +148,8 @@ export function useTransactionDetails(account: string, tid: number) { } export function revalidatePublicAccounts() { - mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts", undefined, { revalidate: true } ); } export function usePublicAccounts( @@ -221,7 +219,7 @@ export function usePublicAccounts( } export function revalidateTransactions() { - mutate( + return mutate( (key) => Array.isArray(key) && key[key.length - 1] === "getTransactions", undefined, { revalidate: true }, diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 2b0781465..88ca7b947 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -52,7 +52,7 @@ type CashoutEstimators = { }; export function revalidateConversionInfo() { - mutate( + return mutate( (key) => Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI", ); @@ -130,7 +130,7 @@ export function useEstimator(): CashoutEstimators { } export function revalidateBusinessAccounts() { - mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts"); + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "getAccounts", undefined, { revalidate: true }); } export function useBusinessAccounts() { const { state: credentials } = useBackendState(); @@ -199,9 +199,9 @@ function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { return c !== undefined; } export function revalidateOnePendingCashouts() { - mutate( + return mutate( (key) => - Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", + Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts", undefined, { revalidate: true } ); } export function useOnePendingCashouts(account: string) { @@ -215,13 +215,11 @@ export function useOnePendingCashouts(account: string) { if (list.type !== "ok") { return list; } - const pendingCashout = list.body.cashouts.find( - (c) => c.status === "pending", - ); + const pendingCashout = list.body.cashouts.length > 0 ? list.body.cashouts[0] : undefined; if (!pendingCashout) return opFixedSuccess(list.httpResp, undefined); const cashoutInfo = await api.getCashoutById( { username, token }, - pendingCashout?.cashout_id, + pendingCashout.cashout_id, ); if (cashoutInfo.type !== "ok") { return cashoutInfo; @@ -261,7 +259,7 @@ export function useOnePendingCashouts(account: string) { } export function revalidateCashouts() { - mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); + return mutate((key) => Array.isArray(key) && key[key.length - 1] === "useCashouts"); } export function useCashouts(account: string) { const { state: credentials } = useBackendState(); @@ -312,8 +310,8 @@ export function useCashouts(account: string) { } export function revalidateCashoutDetails() { - mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getCashoutById", undefined, { revalidate: true } ); } export function useCashoutDetails(cashoutId: number | undefined) { @@ -361,8 +359,8 @@ export type LastMonitor = { previous: TalerCoreBankResultByMethod<"getMonitor">; }; export function revalidateLastMonitorInfo() { - mutate( - (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo", undefined, { revalidate: true } ); } export function useLastMonitorInfo( diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 39b31a094..a508845e1 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -33,18 +33,19 @@ function ShowOperationPendingTag({ }): VNode { const { i18n } = useTranslationContext(); const result = useWithdrawalDetails(woid); + const loading = !result const error = - !result || result instanceof TalerError || result.type === "fail"; - const completed = - !error && - (result.body.status === "aborted" || result.body.status === "confirmed"); + !loading && (result instanceof TalerError || result.type === "fail"); + const pending = + !loading && !error && + (result.body.status === "pending" || result.body.status === "selected"); useEffect(() => { - if (completed && onOperationAlreadyCompleted) { + if (!loading && !pending && onOperationAlreadyCompleted) { onOperationAlreadyCompleted(); } - }, [completed]); + }, [pending]); - if (error || completed) { + if (error || !pending) { return <Fragment />; } diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index 3643e1f6b..54ceb81a9 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -23,28 +23,30 @@ import { FRAC_SEPARATOR, HttpStatusCode, PaytoString, + PaytoUri, TalerErrorCode, TranslatedString, assertUnreachable, buildPayto, parsePaytoUri, - stringifyPaytoUri, + stringifyPaytoUri } from "@gnu-taler/taler-util"; import { + InternationalizationAPI, LocalNotificationBanner, ShowInputErrorLabel, notifyInfo, useLocalNotification, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { Ref, VNode, h } from "preact"; +import { ComponentChildren, Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { mutate } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; import { useBackendState } from "../hooks/backend.js"; import { useBankState } from "../hooks/bank-state.js"; import { RouteDefinition } from "../route.js"; -import { undefinedIfEmpty, validateIBAN } from "../utils.js"; +import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js"; export function PaytoWireTransferForm({ focus, @@ -65,11 +67,11 @@ export function PaytoWireTransferForm({ }): VNode { const [isRawPayto, setIsRawPayto] = useState(false); const { state: credentials } = useBackendState(); - const { api } = useBankCoreApiContext(); + const { api, config, url } = useBankCoreApiContext(); const sendingToFixedAccount = toAccount !== undefined; - // FIXME: support other destination that just IBAN - const [iban, setIban] = useState<string | undefined>(toAccount); + + const [account, setAccount] = useState<string | undefined>(toAccount); const [subject, setSubject] = useState<string | undefined>(); const [amount, setAmount] = useState<string | undefined>(); const [, updateBankState] = useBankState(); @@ -78,49 +80,35 @@ export function PaytoWireTransferForm({ undefined, ); const { i18n } = useTranslationContext(); - const ibanRegex = "^[A-Z][A-Z][0-9]+$"; const trimmedAmountStr = amount?.trim(); const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`); - const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; const [notification, notify, handleError] = useLocalNotification(); + const paytoType = config.wire_type === "X_TALER_BANK" ? "x-taler-bank" as const : "iban" as const; + const errorsWire = undefinedIfEmpty({ - iban: !iban + account: !account ? i18n.str`Required` - : !IBAN_REGEX.test(iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(iban, i18n), - subject: !subject ? i18n.str`Required` : undefined, + : paytoType === "iban" ? validateIBAN(account, i18n) : + paytoType === "x-taler-bank" ? validateTalerBank(account, i18n) : + undefined, + subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n), amount: !trimmedAmountStr ? i18n.str`Required` : !parsedAmount ? i18n.str`Not valid` - : Amounts.isZero(parsedAmount) - ? i18n.str`Should be greater than 0` - : Amounts.cmp(limit, parsedAmount) === -1 - ? i18n.str`Balance is not enough` - : undefined, + : validateAmount(parsedAmount, limit, i18n), }); const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); + const errorsPayto = undefinedIfEmpty({ rawPaytoInput: !rawPaytoInput ? i18n.str`Required` - : !parsed - ? i18n.str`Does not follow the pattern` - : !parsed.isKnown || parsed.targetType !== "iban" - ? i18n.str`Only "IBAN" target are supported` - : !parsed.params.amount - ? i18n.str`Use the "amount" parameter to specify the amount to be transferred` - : Amounts.parse(parsed.params.amount) === undefined - ? i18n.str`The amount is not valid` - : !parsed.params.message - ? i18n.str`Use the "message" parameter to specify a reference text for the transfer` - : !IBAN_REGEX.test(parsed.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : validateIBAN(parsed.iban, i18n), + : !parsed ? i18n.str`Does not follow the pattern` + : validateRawPayto(parsed, limit, url.host, i18n, paytoType), }); async function doSend() { @@ -128,18 +116,30 @@ export function PaytoWireTransferForm({ let sendingAmount: AmountString | undefined; if (credentials.status !== "loggedIn") return; - if (rawPaytoInput) { - const p = parsePaytoUri(rawPaytoInput); + if (isRawPayto) { + const p = parsePaytoUri(rawPaytoInput!); if (!p) return; sendingAmount = p.params.amount as AmountString; delete p.params.amount; // if this payto is valid then it already have message payto_uri = stringifyPaytoUri(p); } else { - if (!iban || !subject) return; - const ibanPayto = buildPayto("iban", iban, undefined); - ibanPayto.params.message = encodeURIComponent(subject); - payto_uri = stringifyPaytoUri(ibanPayto); + if (!account || !subject) return; + let payto; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto("x-taler-bank", url.host, account); + break; + } + case "iban": { + payto = buildPayto("iban", account, undefined); + break; + } + default: assertUnreachable(paytoType) + } + + payto.params.message = encodeURIComponent(subject); + payto_uri = stringifyPaytoUri(payto); sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString; } const puri = payto_uri; @@ -212,7 +212,7 @@ export function PaytoWireTransferForm({ notifyInfo(i18n.str`Wire transfer created!`); onSuccess(); setAmount(undefined); - setIban(undefined); + setAccount(undefined); setSubject(undefined); rawPaytoInputSetter(undefined); }); @@ -243,13 +243,24 @@ export function PaytoWireTransferForm({ aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { - if ( - parsed && - parsed.isKnown && - parsed.targetType === "iban" - ) { - setIban(parsed.iban); - const amountStr = parsed.params["amount"]; + if (parsed && parsed.isKnown) { + switch (parsed.targetType) { + case "iban": { + setAccount(parsed.iban); + break; + } + case "x-taler-bank": { + setAccount(parsed.account); + break; + } + case "bitcoin": { + break; + } + default: { + assertUnreachable(parsed) + } + } + const amountStr = parsed.params["amount"] ?? `${config.currency}:0`; if (amountStr) { const amount = Amounts.parse(parsed.params["amount"]); if (amount) { @@ -290,14 +301,32 @@ export function PaytoWireTransferForm({ aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { - if (iban) { - const payto = buildPayto("iban", iban, undefined); - if (parsedAmount) { - payto.params["amount"] = - Amounts.stringify(parsedAmount); - } - if (subject) { - payto.params["message"] = subject; + if (account) { + let payto; + switch (paytoType) { + case "x-taler-bank": { + payto = buildPayto("x-taler-bank", url.host, account); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + break; + } + case "iban": { + payto = buildPayto("iban", account, undefined); + if (parsedAmount) { + payto.params["amount"] = + Amounts.stringify(parsedAmount); + } + if (subject) { + payto.params["message"] = subject; + } + break; + } + default: assertUnreachable(paytoType) } rawPaytoInputSetter(stringifyPaytoUri(payto)); } @@ -328,39 +357,37 @@ export function PaytoWireTransferForm({ <div class="p-4 sm:p-8"> {!isRawPayto ? ( <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <div class="sm:col-span-5"> - <label - for="iban" - class="block text-sm font-medium leading-6 text-gray-900" - >{i18n.str`Recipient`}</label> - <div class="mt-2"> - <input - ref={focus ? doAutoFocus : undefined} - type="text" - class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - name="iban" - id="iban" - disabled={sendingToFixedAccount} - value={iban ?? ""} - placeholder="CC0123456789" - autocomplete="off" - required - pattern={ibanRegex} - onInput={(e): void => { - setIban(e.currentTarget.value.toUpperCase()); - }} - /> - <ShowInputErrorLabel - message={errorsWire?.iban} - isDirty={iban !== undefined} - /> - </div> - <p class="mt-2 text-sm text-gray-500"> - <i18n.Translate> - IBAN of the recipient's account - </i18n.Translate> - </p> - </div> + {(() => { + switch (paytoType) { + case "x-taler-bank": { + return <TextField + id="x-taler-bank" + label={i18n.str`Recipient`} + help={i18n.str`Id of the recipient's account`} + error={errorsWire?.account} + onChange={setAccount} + value={account} + placeholder={i18n.str`username`} + focus={focus} + disabled={sendingToFixedAccount} + /> + } + case "iban": { + return <TextField + id="iban" + label={i18n.str`Recipient`} + help={i18n.str`IBAN of the recipient's account`} + placeholder={"CC0123456789" as TranslatedString} + error={errorsWire?.account} + onChange={(v) => setAccount(v.toUpperCase())} + value={account} + focus={focus} + disabled={sendingToFixedAccount} + /> + } + default: assertUnreachable(paytoType) + } + })()} <div class="sm:col-span-5"> <label @@ -434,7 +461,13 @@ export function PaytoWireTransferForm({ value={rawPaytoInput ?? ""} required title={i18n.str`Uniform resource identifier of the target account`} - placeholder={i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`} + + placeholder={((): TranslatedString => { + switch (paytoType) { + case "x-taler-bank": return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]` + case "iban": return i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]` + } + })()} onInput={(e): void => { rawPaytoInputSetter(e.currentTarget.value); }} @@ -538,13 +571,13 @@ export function InputAmount( if ( sep_pos !== -1 && l - sep_pos - 1 > - config.currency_specification.num_fractional_input_digits + config.currency_specification.num_fractional_input_digits ) { e.currentTarget.value = e.currentTarget.value.substring( 0, sep_pos + - config.currency_specification.num_fractional_input_digits + - 1, + config.currency_specification.num_fractional_input_digits + + 1, ); } onChange(e.currentTarget.value); @@ -587,3 +620,147 @@ export function RenderAmount({ </span> ); } + + +function validateRawPayto(parsed: PaytoUri, limit: AmountJson, host: string, i18n: InternationalizationAPI, type: "iban" | "x-taler-bank"): TranslatedString | undefined { + if (!parsed.isKnown) { + return i18n.str`The target type is unknown, use "${type}"` + } + let result: TranslatedString | undefined; + switch (type) { + case "x-taler-bank": { + if (parsed.targetType !== "x-taler-bank") { + return i18n.str`Only "x-taler-bank" target are supported` + } + + if (parsed.host !== host) { + return i18n.str`Only this host is allowed. Use "${host}"` + } + + if (!parsed.account) { + return i18n.str`Missing account name` + } + const result = validateTalerBank(parsed.account, i18n) + if (result) return result + break; + } + case "iban": { + if (parsed.targetType !== "iban") { + return i18n.str`Only "IBAN" target are supported` + } + const result = validateIBAN(parsed.iban, i18n) + if (result) return result + break; + } + default: assertUnreachable(type) + } + if (!parsed.params.amount) { + return i18n.str`Missing "amount" parameter to specify the amount to be transferred` + } + const amount = Amounts.parse(parsed.params.amount) + if (!amount) { + return i18n.str`The "amount" parameter is not valid` + } + result = validateAmount(amount, limit, i18n) + if (result) return result; + + if (!parsed.params.message) { + return i18n.str`Missing the "message" parameter to specify a reference text for the transfer` + } + const subject = parsed.params.message + result = validateSubject(subject, i18n) + if (result) return result; + + return undefined +} + +function validateAmount(amount: AmountJson, limit: AmountJson, i18n: InternationalizationAPI): TranslatedString | undefined { + if (amount.currency !== limit.currency) { + return i18n.str`The only currecy allowed is "${limit.currency}"` + } + if (Amounts.isZero(amount)) { + return i18n.str`Can't transfer zero amount` + } + if (Amounts.cmp(limit, amount) === -1) { + return i18n.str`Balance is not enough` + } + return undefined +} + +function validateSubject(text: string, i18n: InternationalizationAPI): TranslatedString | undefined { + if (text.length < 2) { + return i18n.str`Use a longer subject` + } + return undefined +} + +interface PaytoFieldProps { + id: string, + label: TranslatedString; + help?: TranslatedString; + placeholder?: TranslatedString; + error: string | undefined; + value: string | undefined; + rightIcons?: VNode; + onChange: (p: string) => void; + focus?: boolean; + disabled?: boolean; +} + +function Wrapper({ withIcon, children }: { withIcon: boolean, children: ComponentChildren }): VNode { + if (withIcon) { + return <div class="flex justify-between"> + {children} + </div> + } + return <Fragment>{children}</Fragment> +} + +export function TextField({ + id, + label, + help, + focus, + disabled, + onChange, + placeholder, + rightIcons, + value, + error, +}: PaytoFieldProps): VNode { + return <div class="sm:col-span-5"> + <label + for={id} + class="block text-sm font-medium leading-6 text-gray-900" + >{label}</label> + <div class="mt-2"> + <Wrapper withIcon={rightIcons !== undefined}> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name={id} + id={id} + disabled={disabled} + value={value ?? ""} + placeholder={placeholder} + autocomplete="off" + required + onInput={(e): void => { + onChange(e.currentTarget.value); + }} + /> + {rightIcons} + </Wrapper> + <ShowInputErrorLabel + message={error} + isDirty={value !== undefined} + /> + </div> + {help && + <p class="mt-2 text-sm text-gray-500"> + {help} + </p> + } + </div> +} diff --git a/packages/demobank-ui/src/pages/SolveChallengePage.tsx b/packages/demobank-ui/src/pages/SolveChallengePage.tsx index de0ba483f..61decf586 100644 --- a/packages/demobank-ui/src/pages/SolveChallengePage.tsx +++ b/packages/demobank-ui/src/pages/SolveChallengePage.tsx @@ -666,8 +666,10 @@ function ShowCashoutDetails({ return ( <Attention type="danger" - title={i18n.str`Cashout not implemented`} - ></Attention> + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); } default: 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); +// } diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx index 41d54c43d..5528b5226 100644 --- a/packages/demobank-ui/src/pages/admin/AccountList.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountList.tsx @@ -62,6 +62,7 @@ export function AccountList({ } } + const { accounts } = result.data.body; return ( <Fragment> @@ -170,15 +171,20 @@ export function AccountList({ <i18n.Translate>Change password</i18n.Translate> </a> <br /> - <a - href={routeShowCashoutsAccount.url({ - account: item.username, - })} - class="text-indigo-600 hover:text-indigo-900" - > - <i18n.Translate>Cashouts</i18n.Translate> - </a> - <br /> + {config.allow_conversion ? + <Fragment> + + <a + href={routeShowCashoutsAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>Cashouts</i18n.Translate> + </a> + <br /> + </Fragment> + : undefined} {noBalance ? ( <a href={routeRemoveAccount.url({ diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx index 1a7edd6b9..35106edeb 100644 --- a/packages/demobank-ui/src/pages/admin/AdminHome.tsx +++ b/packages/demobank-ui/src/pages/admin/AdminHome.tsx @@ -170,8 +170,10 @@ function Metrics({ routeDownloadStats }: { return ( <Attention type="danger" - title={i18n.str`Cashout not implemented`} - ></Attention> + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); } default: diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx index c4e4266f9..23d5a1e90 100644 --- a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx @@ -29,7 +29,6 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { mutate } from "swr"; import { useBankCoreApiContext } from "../../context/config.js"; import { useBackendState } from "../../hooks/backend.js"; import { RouteDefinition } from "../../route.js"; @@ -70,7 +69,6 @@ export function CreateNewAccount({ const resp = await api.createAccount(token, submitAccount); if (resp.type === "ok") { - mutate(() => true); // clean account list notifyInfo( i18n.str`Account created with password "${submitAccount.password}". The user must change the password on the next login.`, ); diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index 8ec34276f..6d538575b 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -140,8 +140,10 @@ export function CreateCashout({ return ( <Attention type="danger" - title={i18n.str`Cashout not implemented`} - ></Attention> + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); } default: @@ -188,8 +190,7 @@ export function CreateCashout({ * depending on the isDebit flag */ const inputAmount = Amounts.parseOrThrow( - `${form.isDebit ? regional_currency : fiat_currency}:${ - !form.amount ? "0" : form.amount + `${form.isDebit ? regional_currency : fiat_currency}:${!form.amount ? "0" : form.amount }`, ); @@ -291,7 +292,7 @@ export function CreateCashout({ case HttpStatusCode.NotImplemented: return notify({ type: "error", - title: i18n.str`Cashouts are not supported`, + title: i18n.str`Cashout are disabled`, description: resp.detail.hint as TranslatedString, debug: resp.detail, }); @@ -471,9 +472,9 @@ export function CreateCashout({ cashoutDisabled ? undefined : (value) => { - form.amount = value; - updateForm(structuredClone(form)); - } + form.amount = value; + updateForm(structuredClone(form)); + } } /> <ShowInputErrorLabel @@ -514,7 +515,7 @@ export function CreateCashout({ </dd> </div> {Amounts.isZero(sellFee) || - Amounts.isZero(calc.beforeFee) ? undefined : ( + Amounts.isZero(calc.beforeFee) ? undefined : ( <div class="flex items-center justify-between border-t-2 afu pt-4"> <dt class="flex items-center text-sm text-gray-600"> <span> @@ -547,7 +548,7 @@ export function CreateCashout({ {/* channel, not shown if new cashout api */} {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels - .length === 0 ? ( + .length === 0 ? ( <div class="sm:col-span-5"> <Attention type="warning" @@ -619,7 +620,7 @@ export function CreateCashout({ )} {config.supported_tan_channels.indexOf(TanChannel.SMS) === - -1 ? undefined : ( + -1 ? undefined : ( <label onClick={() => { if (!resultAccount.body.contact_data?.phone) return; diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx index 7b251d3ca..1e70886ad 100644 --- a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx +++ b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx @@ -69,8 +69,10 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { return ( <Attention type="warning" - title={i18n.str`Cashouts are not supported`} - ></Attention> + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); default: assertUnreachable(result); @@ -87,7 +89,11 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { switch (info.case) { case HttpStatusCode.NotImplemented: { return ( - <Attention type="danger" title={i18n.str`Cashout not implemented`} /> + <Attention type="danger" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> ); } default: diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts index 4413ce814..ab0b60d72 100644 --- a/packages/demobank-ui/src/utils.ts +++ b/packages/demobank-ui/src/utils.ts @@ -23,6 +23,7 @@ import { } from "@gnu-taler/taler-util"; import { ErrorNotification, + InternationalizationAPI, notify, notifyError, useTranslationContext, @@ -72,36 +73,36 @@ export type PartialButDefined<T> = { */ export type WithIntermediate<Type> = { [prop in keyof Type]: Type[prop] extends PaytoString - ? Type[prop] | undefined - : Type[prop] extends AmountString - ? Type[prop] | undefined - : Type[prop] extends TranslatedString - ? Type[prop] | undefined - : Type[prop] extends object - ? WithIntermediate<Type[prop]> - : Type[prop] | undefined; + ? Type[prop] | undefined + : Type[prop] extends AmountString + ? Type[prop] | undefined + : Type[prop] extends TranslatedString + ? Type[prop] | undefined + : Type[prop] extends object + ? WithIntermediate<Type[prop]> + : Type[prop] | undefined; }; export type RecursivePartial<Type> = { [P in keyof Type]?: Type[P] extends (infer U)[] - ? RecursivePartial<U>[] - : Type[P] extends object - ? RecursivePartial<Type[P]> - : Type[P]; + ? RecursivePartial<U>[] + : Type[P] extends object + ? RecursivePartial<Type[P]> + : Type[P]; }; export type ErrorMessageMappingFor<Type> = { [prop in keyof Type]+?: Exclude<Type[prop], undefined> extends PaytoString // enumerate known object - ? TranslatedString - : Exclude<Type[prop], undefined> extends AmountString - ? TranslatedString - : Exclude<Type[prop], undefined> extends TranslatedString - ? TranslatedString - : // arrays: every element - Exclude<Type[prop], undefined> extends (infer U)[] - ? ErrorMessageMappingFor<U>[] - : // map: every field - Exclude<Type[prop], undefined> extends object - ? ErrorMessageMappingFor<Type[prop]> - : TranslatedString; + ? TranslatedString + : Exclude<Type[prop], undefined> extends AmountString + ? TranslatedString + : Exclude<Type[prop], undefined> extends TranslatedString + ? TranslatedString + : // arrays: every element + Exclude<Type[prop], undefined> extends (infer U)[] + ? ErrorMessageMappingFor<U>[] + : // map: every field + Exclude<Type[prop], undefined> extends object + ? ErrorMessageMappingFor<Type[prop]> + : TranslatedString; }; export enum TanChannel { @@ -367,26 +368,30 @@ export const COUNTRY_TABLE = { * If the remainder is 1, the check digit test is passed and the IBAN might be valid. * */ +const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; export function validateIBAN( - iban: string, - i18n: ReturnType<typeof useTranslationContext>["i18n"], + account: string, + i18n: InternationalizationAPI, ): TranslatedString | undefined { + if (!IBAN_REGEX.test(account)) { + return i18n.str`IBAN only have uppercased letters and numbers` + } // Check total length - if (iban.length < 4) - return i18n.str`IBAN numbers usually have more that 4 digits`; - if (iban.length > 34) - return i18n.str`IBAN numbers usually have less that 34 digits`; + if (account.length < 4) + return i18n.str`IBAN numbers have more that 4 digits`; + if (account.length > 34) + return i18n.str`IBAN numbers have less that 34 digits`; const A_code = "A".charCodeAt(0); const Z_code = "Z".charCodeAt(0); - const IBAN = iban.toUpperCase(); + const IBAN = account.toUpperCase(); // check supported country const code = IBAN.substring(0, 2); const found = code in COUNTRY_TABLE; if (!found) return i18n.str`IBAN country code not found`; // 2.- Move the four initial characters to the end of the string - const step2 = IBAN.substring(4) + iban.substring(0, 4); + const step2 = IBAN.substring(4) + account.substring(0, 4); const step3 = Array.from(step2) .map((letter) => { const code = letter.charCodeAt(0); @@ -411,3 +416,33 @@ function calculate_iban_checksum(str: string): number { } return result; } + +const USERNAME_REGEX = /^[A-Za-z][A-Za-z0-9]*$/; + +export function validateTalerBank( + account: string, + i18n: InternationalizationAPI, +): TranslatedString | undefined { + if (!USERNAME_REGEX.test(account)) { + return i18n.str`Account only have letters and numbers` + } + return undefined +} + +export function validateRawIBAN( + payto: string, + i18n: InternationalizationAPI, +): TranslatedString | undefined { + return undefined +} + + + +export function validateRawTalerBank( + payto: string, + currentHost: string, + i18n: InternationalizationAPI, +): TranslatedString | undefined { + return undefined +} + |