diff options
Diffstat (limited to 'packages/bank-ui/src/pages/admin')
-rw-r--r-- | packages/bank-ui/src/pages/admin/AccountForm.tsx | 901 | ||||
-rw-r--r-- | packages/bank-ui/src/pages/admin/AccountList.tsx | 244 | ||||
-rw-r--r-- | packages/bank-ui/src/pages/admin/AdminHome.tsx | 541 | ||||
-rw-r--r-- | packages/bank-ui/src/pages/admin/CreateNewAccount.tsx | 204 | ||||
-rw-r--r-- | packages/bank-ui/src/pages/admin/DownloadStats.tsx | 585 | ||||
-rw-r--r-- | packages/bank-ui/src/pages/admin/RemoveAccount.tsx | 267 |
6 files changed, 2742 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx new file mode 100644 index 000000000..bce7afe11 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx @@ -0,0 +1,901 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + AmountString, + Amounts, + PaytoString, + TalerCorebankApi, + TranslatedString, + assertUnreachable, + buildPayto, + parsePaytoUri, + stringifyPaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + CopyButton, + ShowInputErrorLabel, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { + ErrorMessageMappingFor, + TanChannel, + undefinedIfEmpty, + validateIBAN, + validateTalerBank, +} from "../../utils.js"; +import { InputAmount, TextField, doAutoFocus } from "../PaytoWireTransferForm.js"; +import { getRandomPassword } from "../rnd.js"; + +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; + +export type AccountFormData = { + debit_threshold?: string; + isExchange?: boolean; + isPublic?: boolean; + name?: string; + username?: string; + payto_uri?: string; + cashout_payto_uri?: string; + email?: string; + phone?: string; + tan_channel?: TanChannel | "remove"; +}; + +type ChangeByPurposeType = { + create: (a: TalerCorebankApi.RegisterAccountRequest | undefined) => void; + update: (a: TalerCorebankApi.AccountReconfiguration | undefined) => void; + show: undefined; +}; +/** + * FIXME: + * is_public is missing on PATCH + * account email/password should require 2FA + * + * + * @param param0 + * @returns + */ +export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ + template, + username, + purpose, + onChange, + focus, + children, +}: { + focus?: boolean; + children: ComponentChildren; + username?: string; + template: TalerCorebankApi.AccountData | undefined; + onChange: ChangeByPurposeType[PurposeType]; + purpose: PurposeType; +}): VNode { + const { config, hints, url } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const [form, setForm] = useState<AccountFormData>({}); + + const [errors, setErrors] = useState< + ErrorMessageMappingFor<typeof defaultValue> | undefined + >(undefined); + + const paytoType = config.wire_type === "X_TALER_BANK" ? "x-taler-bank" as const : "iban" as const; + const cashoutPaytoType: typeof paytoType = "iban" as const; + + const defaultValue: AccountFormData = { + debit_threshold: Amounts.stringifyValue( + template?.debit_threshold ?? config.default_debit_threshold, + ), + isExchange: template?.is_taler_exchange, + isPublic: template?.is_public, + name: template?.name ?? "", + cashout_payto_uri: + getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ?? ("" as PaytoString), + payto_uri: getAccountId(paytoType, template?.payto_uri) ?? ("" as PaytoString), + email: template?.contact_data?.email ?? "", + phone: template?.contact_data?.phone ?? "", + username: username ?? "", + tan_channel: template?.tan_channel, + }; + + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1; + + const userIsAdmin = + credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator; + + const editableUsername = purpose === "create"; + const editableName = + purpose === "create" || + (purpose === "update" && (config.allow_edit_name || userIsAdmin)); + + const isCashoutEnabled = config.allow_conversion; + const editableCashout = + (purpose === "create" || + (purpose === "update" && + (config.allow_edit_cashout_payto_uri || userIsAdmin))); + const editableThreshold = + userIsAdmin && (purpose === "create" || purpose === "update"); + const editableAccount = purpose === "create" && userIsAdmin; + + const hasPhone = !!defaultValue.phone || !!form.phone; + const hasEmail = !!defaultValue.email || !!form.email; + + function updateForm(newForm: typeof defaultValue): void { + + const trimmedAmountStr = newForm.debit_threshold?.trim(); + const parsedAmount = Amounts.parse( + `${config.currency}:${trimmedAmountStr}`, + ); + + const errors = undefinedIfEmpty< + ErrorMessageMappingFor<typeof defaultValue> + >({ + cashout_payto_uri: !newForm.cashout_payto_uri + ? undefined + : !editableCashout + ? undefined + : !newForm.cashout_payto_uri ? undefined + : cashoutPaytoType === "iban" ? validateIBAN(newForm.cashout_payto_uri, i18n) : + cashoutPaytoType === "x-taler-bank" ? validateTalerBank(newForm.cashout_payto_uri, i18n) : + undefined, + + payto_uri: !newForm.payto_uri + ? undefined + : !editableAccount + ? undefined + : !newForm.payto_uri ? undefined + : paytoType === "iban" ? validateIBAN(newForm.payto_uri, i18n) : + paytoType === "x-taler-bank" ? validateTalerBank(newForm.payto_uri, i18n) : + undefined, + + email: !newForm.email + ? undefined + : !EMAIL_REGEX.test(newForm.email) + ? i18n.str`Doesn't have the pattern of an email` + : undefined, + phone: !newForm.phone + ? undefined + : !newForm.phone.startsWith("+") // FIXME: better phone number check + ? i18n.str`Should start with +` + : !REGEX_JUST_NUMBERS_REGEX.test(newForm.phone) + ? i18n.str`Phone number can't have other than numbers` + : undefined, + debit_threshold: !editableThreshold + ? undefined + : !trimmedAmountStr + ? undefined + : !parsedAmount + ? i18n.str`Not valid` + : undefined, + name: !editableName + ? undefined // disabled + : !newForm.name + ? i18n.str`Required` + : undefined, + username: !editableUsername + ? undefined + : !newForm.username + ? i18n.str`Required` + : undefined, + }); + setErrors(errors); + + setForm(newForm); + if (!onChange) return; + + if (errors) { + onChange(undefined); + } else { + let cashout; + if (newForm.cashout_payto_uri) switch (cashoutPaytoType) { + case "x-taler-bank": { + cashout = buildPayto("x-taler-bank", url.host, newForm.cashout_payto_uri); + break; + } + case "iban": { + cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined); + break; + } + default: assertUnreachable(cashoutPaytoType) + } + const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout); + let internal; + if (newForm.payto_uri) switch (paytoType) { + case "x-taler-bank": { + internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri); + break; + } + case "iban": { + internal = buildPayto("iban", newForm.payto_uri, undefined); + break; + } + default: assertUnreachable(paytoType) + } + const internalURI = !internal ? undefined : stringifyPaytoUri(internal); + + const threshold = !parsedAmount + ? undefined + : Amounts.stringify(parsedAmount); + + switch (purpose) { + case "create": { + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["create"]; + const result: TalerCorebankApi.RegisterAccountRequest = { + name: newForm.name!, + password: getRandomPassword(), + username: newForm.username!, + contact_data: undefinedIfEmpty({ + email: !newForm.email ? undefined : newForm.email, + phone: !newForm.phone ? undefined :newForm.phone, + }), + debit_threshold: threshold ?? config.default_debit_threshold, + cashout_payto_uri: cashoutURI, + payto_uri: internalURI, + is_public: newForm.isPublic, + is_taler_exchange: newForm.isExchange, + tan_channel: + newForm.tan_channel === "remove" + ? undefined + : newForm.tan_channel, + }; + callback(result); + return; + } + case "update": { + // typescript doesn't correctly narrow a generic type + const callback = onChange as ChangeByPurposeType["update"]; + + const result: TalerCorebankApi.AccountReconfiguration = { + cashout_payto_uri: cashoutURI, + contact_data: undefinedIfEmpty({ + email: !newForm.email ? undefined : newForm.email, + phone: !newForm.phone ? undefined :newForm.phone, + }), + debit_threshold: threshold, + is_public: newForm.isPublic, + name: newForm.name, + tan_channel: + newForm.tan_channel === "remove" ? null : newForm.tan_channel, + }; + callback(result); + return; + } + case "show": { + return; + } + default: { + assertUnreachable(purpose); + } + } + } + } + return ( + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-4 py-6 sm:p-8"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="username" + > + {i18n.str`Login username`} + {editableUsername && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + ref={focus && purpose === "create" ? doAutoFocus : undefined} + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="username" + id="username" + data-error={!!errors?.username && form.username !== undefined} + disabled={!editableUsername} + value={form.username ?? defaultValue.username} + onChange={(e) => { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={form.username !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Account id for authentication</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="name" + > + {i18n.str`Full name`} + {editableName && <b style={{ color: "red" }}> *</b>} + </label> + <div class="mt-2"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="name" + data-error={!!errors?.name && form.name !== undefined} + id="name" + disabled={!editableName} + value={form.name ?? defaultValue.name} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Name of the account holder</i18n.Translate> + </p> + </div> + + {purpose === "create" ? undefined : + <TextField + id="internal-account" + label={i18n.str`Internal account`} + help={ + purpose === "create" + ? i18n.str`If empty a random account id will be assigned` + : i18n.str`Share this id to receive bank transfers` + } + + error={errors?.payto_uri} + onChange={(e) => { + form.payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + rightIcons={<CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => form.payto_uri ?? defaultValue.payto_uri ?? ""} + />} + value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString} + disabled={!editableAccount} + /> + } + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="email" + > + {i18n.str`Email`} + </label> + <div class="mt-2"> + <input + type="email" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="email" + id="email" + data-error={!!errors?.email && form.email !== undefined} + disabled={purpose === "show"} + value={form.email ?? defaultValue.email} + onChange={(e) => { + form.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.email} + isDirty={form.email !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>To be used when second factor authentication is enabled</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="phone" + > + {i18n.str`Phone`} + </label> + <div class="mt-2"> + <input + type="text" + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="phone" + id="phone" + disabled={purpose === "show"} + value={form.phone ?? defaultValue.phone} + data-error={!!errors?.phone && form.phone !== undefined} + onChange={(e) => { + form.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.phone} + isDirty={form.phone !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>To be used when second factor authentication is enabled</i18n.Translate> + </p> + </div> + + {isCashoutEnabled && ( + <TextField + id="cashout-account" + label={i18n.str`Cashout account`} + help={i18n.str`External account number where the money is going to be sent when doing cashouts`} + error={errors?.cashout_payto_uri} + onChange={(e) => { + form.cashout_payto_uri = e as PaytoString; + updateForm(structuredClone(form)); + }} + value={(form.cashout_payto_uri ?? defaultValue.cashout_payto_uri) as PaytoString} + disabled={!editableCashout} + /> + )} + + {/* channel, not shown if old cashout api */} + {OLD_CASHOUT_API || + config.supported_tan_channels.length === 0 ? undefined : ( + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Enable second factor authentication`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + {config.supported_tan_channels.indexOf(TanChannel.EMAIL) === + -1 ? undefined : ( + <label + onClick={(e) => { + if (!hasEmail) return; + if (form.tan_channel === TanChannel.EMAIL) { + form.tan_channel = "remove"; + } else { + form.tan_channel = TanChannel.EMAIL; + } + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show" || !hasEmail} + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.EMAIL + } + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Newsletter" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-0-label" + class="block text-sm font-medium text-gray-900 " + > + <i18n.Translate>Using email</i18n.Translate> + </span> + {purpose !== "show" && + !hasEmail && + i18n.str`Add an email in your profile to enable this option`} + </span> + </span> + <svg + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.EMAIL + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === + -1 ? undefined : ( + <label + onClick={(e) => { + if (!hasPhone) return; + if (form.tan_channel === TanChannel.SMS) { + form.tan_channel = "remove"; + } else { + form.tan_channel = TanChannel.SMS; + } + updateForm(structuredClone(form)); + e.preventDefault(); + }} + data-disabled={purpose === "show" || !hasPhone} + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.SMS + } + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" + > + <input + type="radio" + name="channel" + value="Existing Customers" + class="sr-only" + /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span + id="project-type-1-label" + class="block text-sm font-medium text-gray-900" + > + <i18n.Translate>Using SMS</i18n.Translate> + </span> + {purpose !== "show" && + !hasPhone && + i18n.str`Add a phone number in your profile to enable this option`} + </span> + </span> + <svg + data-selected={ + (form.tan_channel ?? defaultValue.tan_channel) === + TanChannel.SMS + } + class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" + clip-rule="evenodd" + /> + </svg> + </label> + )} + </div> + </div> + </div> + )} + + <div class="sm:col-span-5"> + <label + for="debit" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Max debt`}</label> + <InputAmount + name="debit" + left + currency={config.currency} + value={form.debit_threshold ?? defaultValue.debit_threshold} + onChange={ + !editableThreshold + ? undefined + : (e) => { + form.debit_threshold = e as AmountString; + updateForm(structuredClone(form)); + } + } + /> + <ShowInputErrorLabel + message={ + errors?.debit_threshold + ? String(errors?.debit_threshold) + : undefined + } + isDirty={form.debit_threshold !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>How much the balance can go below zero.</i18n.Translate> + </p> + </div> + + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Is this account public?</i18n.Translate> + </span> + </span> + <button + type="button" + name="is public" + data-enabled={ + form.isPublic ?? defaultValue.isPublic ? "true" : "false" + } + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + form.isPublic = !(form.isPublic ?? defaultValue.isPublic); + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={ + form.isPublic ?? defaultValue.isPublic ? "true" : "false" + } + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Public accounts have their balance publicly accessible</i18n.Translate> + </p> + </div> + + {purpose !== "create" || !userIsAdmin ? undefined : ( + <div class="sm:col-span-5"> + <div class="flex items-center justify-between"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate>Is this account a payment provider?</i18n.Translate> + </span> + </span> + <button + type="button" + name="is exchange" + data-enabled={ + form.isExchange ?? defaultValue.isExchange + ? "true" + : "false" + } + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + form.isExchange = !form.isExchange; + updateForm(structuredClone(form)); + }} + > + <span + aria-hidden="true" + data-enabled={ + form.isExchange ?? defaultValue.isExchange + ? "true" + : "false" + } + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> + </div> + )} + </div> + </div> + {children} + </form> + ); +} + +function getAccountId(type: "iban" | "x-taler-bank", s: PaytoString | undefined): string | undefined { + if (s === undefined) return undefined; + const p = parsePaytoUri(s); + if (p === undefined) return undefined; + if (!p.isKnown) return "<unknown>"; + if (type === "iban" && p.targetType === "iban") return p.iban; + if (type === "x-taler-bank" && p.targetType === "x-taler-bank") return p.account; + return "<unsupported>"; +} + +{ + /* <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="cashout" + > + {} + </label> + <div class="mt-2"> + <input + type="text" + ref={focus && purpose === "update" ? doAutoFocus : undefined} + data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} + class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="cashout" + id="cashout" + disabled={purpose === "show"} + value={form.cashout_payto_uri ?? defaultValue.cashout_payto_uri} + onChange={(e) => { + form.cashout_payto_uri = e.currentTarget.value as PaytoString; + if (!form.cashout_payto_uri) { + form.cashout_payto_uri = undefined + } + updateForm(structuredClone(form)); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.cashout_payto_uri} + isDirty={form.cashout_payto_uri !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate></i18n.Translate> + </p> + </div> */ +} + +// function PaytoField({ +// name, +// label, +// help, +// type, +// value, +// disabled, +// onChange, +// error, +// }: { +// error: TranslatedString | undefined; +// name: string; +// label: TranslatedString; +// help: TranslatedString; +// onChange: (s: string) => void; +// type: "iban" | "x-taler-bank" | "bitcoin"; +// disabled?: boolean; +// value: string | undefined; +// }): VNode { +// if (type === "iban") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" +// name={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// onChange={(e) => { +// onChange(e.currentTarget.value); +// }} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// </div> +// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> +// </div> +// <p class="mt-2 text-sm text-gray-500">{help}</p> +// </div> +// ); +// } +// if (type === "x-taler-bank") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" +// name={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// onChange={(e) => { +// onChange(e.currentTarget.value); +// }} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// </div> +// <ShowInputErrorLabel message={error} isDirty={value !== undefined} /> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {help} +// </p> +// </div> +// ); +// } +// if (type === "bitcoin") { +// return ( +// <div class="sm:col-span-5"> +// <label +// class="block text-sm font-medium leading-6 text-gray-900" +// for={name} +// > +// {label} +// </label> +// <div class="mt-2"> +// <div class="flex justify-between"> +// <input +// type="text" +// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" +// name={name} +// id={name} +// disabled={disabled} +// value={value ?? ""} +// /> +// <CopyButton +// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " +// getContent={() => value ?? ""} +// /> +// <ShowInputErrorLabel +// message={error} +// isDirty={value !== undefined} +// /> +// </div> +// </div> +// <p class="mt-2 text-sm text-gray-500"> +// {/* <i18n.Translate>bitcoin address</i18n.Translate> */} +// {help} +// </p> +// </div> +// ); +// } +// assertUnreachable(type); +// } diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx new file mode 100644 index 000000000..4e465d4b5 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AccountList.tsx @@ -0,0 +1,244 @@ +/* + 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 { + Amounts, + HttpStatusCode, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useBusinessAccounts } from "../../hooks/regional.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { RouteDefinition } from "../../route.js"; + +interface Props { + routeCreate: RouteDefinition; + + routeShowAccount: RouteDefinition<{ account: string }>; + routeRemoveAccount: RouteDefinition<{ account: string }>; + routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; + routeShowCashoutsAccount: RouteDefinition<{ account: string }>; +} + +export function AccountList({ + routeCreate, + routeRemoveAccount, + routeShowAccount, + routeShowCashoutsAccount, + routeUpdatePasswordAccount, +}: Props): VNode { + const result = useBusinessAccounts(); + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.data.type === "fail") { + switch (result.data.case) { + case HttpStatusCode.Unauthorized: + return <Fragment />; + default: + assertUnreachable(result.data.case); + } + } + + const onGoStart = result.isFirstPage ? undefined : result.loadFirst + const onGoNext = result.isLastPage ? undefined : result.loadNext + + const accounts = result.result; + return ( + <Fragment> + <div class="px-4 sm:px-6 lg:px-8 mt-4"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Accounts</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-700"> + <i18n.Translate> + A list of all bank accounts. + </i18n.Translate> + </p> + </div> + <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none"> + <a + href={routeCreate.url({})} + name="create account" + type="button" + class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Create account</i18n.Translate> + </a> + </div> + </div> + <div class="mt-8 flow-root"> + <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + {!accounts.length ? ( + <div> + {/* FIXME: ADD empty list */} + </div> + ) : ( + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0" + >{i18n.str`Username`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Name`}</th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + >{i18n.str`Balance`}</th> + <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0"> + <span class="sr-only">{i18n.str`Actions`}</span> + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {accounts.map((item, idx) => { + const balance = !item.balance + ? undefined + : Amounts.parse(item.balance.amount); + const noBalance = Amounts.isZero(item.balance.amount); + const balanceIsDebit = + item.balance && + item.balance.credit_debit_indicator == "debit"; + + return ( + <tr key={idx}> + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0"> + <a + name={`show account ${item.username}`} + href={routeShowAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + {item.username} + </a> + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {item.name} + </td> + <td + data-negative={ + noBalance + ? undefined + : balanceIsDebit + ? "true" + : "false" + } + class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600 " + > + {!balance ? ( + i18n.str`Unknown` + ) : ( + <span class="amount"> + <RenderAmount + value={balance} + negative={balanceIsDebit} + spec={config.currency_specification} + /> + </span> + )} + </td> + <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> + <a + name={`update password ${item.username}`} + href={routeUpdatePasswordAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>Change password</i18n.Translate> + </a> + <br /> + {/* {config.allow_conversion ? + <Fragment> + + <a + name={`show cashout ${item.username}`} + 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 + name={`remove account ${item.username}`} + href={routeRemoveAccount.url({ + account: item.username, + })} + class="text-indigo-600 hover:text-indigo-900" + > + <i18n.Translate>Remove</i18n.Translate> + </a> + ) : undefined} + </td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoStart} + onClick={onGoStart} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoNext} + onClick={onGoNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx new file mode 100644 index 000000000..752d86aa6 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx @@ -0,0 +1,541 @@ +/* + 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, + CurrencySpecification, + HttpStatusCode, + TalerCorebankApi, + TalerError, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + format, + getDate, + getHours, + getMonth, + getYear, + setDate, + setHours, + setMonth, + setYear, + sub, +} from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { Transactions } from "../../components/Transactions/index.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js"; +import { RouteDefinition } from "../../route.js"; +import { RenderAmount } from "../PaytoWireTransferForm.js"; +import { WireTransfer } from "../WireTransfer.js"; +import { AccountList } from "./AccountList.js"; + +/** + * Query account information and show QR code if there is pending withdrawal + */ +interface Props { + routeCreate: RouteDefinition; + routeDownloadStats: RouteDefinition; + routeCreateWireTransfer: RouteDefinition<{ + account?: string, + subject?: string, + amount?: string, + }>; + + routeShowAccount: RouteDefinition<{ account: string }>; + routeRemoveAccount: RouteDefinition<{ account: string }>; + routeUpdatePasswordAccount: RouteDefinition<{ account: string }>; + routeShowCashoutsAccount: RouteDefinition<{ account: string }>; + onAuthorizationRequired: () => void; +} +export function AdminHome({ + routeCreate, + routeRemoveAccount, + routeShowAccount, + routeShowCashoutsAccount, + routeUpdatePasswordAccount, + routeDownloadStats, + routeCreateWireTransfer, + onAuthorizationRequired, +}: Props): VNode { + return ( + <Fragment> + <Metrics routeDownloadStats={routeDownloadStats} /> + <WireTransfer routeHere={routeCreateWireTransfer} onAuthorizationRequired={onAuthorizationRequired} /> + + <Transactions + account="admin" + routeCreateWireTransfer={routeCreateWireTransfer} + /> + <AccountList + routeCreate={routeCreate} + routeRemoveAccount={routeRemoveAccount} + routeShowAccount={routeShowAccount} + routeShowCashoutsAccount={routeShowCashoutsAccount} + routeUpdatePasswordAccount={routeUpdatePasswordAccount} + /> + </Fragment> + ); +} + +function getDateForTimeframe( + which: number, + timeframe: TalerCorebankApi.MonitorTimeframeParam, + locale: Locale, +): string { + const time = Date.now(); + switch (timeframe) { + case TalerCorebankApi.MonitorTimeframeParam.hour: + return `${format(setHours(time, which), "HH", { locale })}hs`; + case TalerCorebankApi.MonitorTimeframeParam.day: + return format(setDate(time, which), "EEEE", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.month: + return format(setMonth(time, which), "MMMM", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.year: + return format(setYear(time, which), "yyyy", { locale }); + case TalerCorebankApi.MonitorTimeframeParam.decade: + return format(setYear(time, which), "yyyy", { locale }); + } + assertUnreachable(timeframe); +} + +export function getTimeframesForDate( + time: Date, + timeframe: TalerCorebankApi.MonitorTimeframeParam, +): { current: number; previous: number } { + switch (timeframe) { + case TalerCorebankApi.MonitorTimeframeParam.hour: + return { + current: getHours(sub(time, { hours: 1 })), + previous: getHours(sub(time, { hours: 2 })), + }; + case TalerCorebankApi.MonitorTimeframeParam.day: + return { + current: getDate(sub(time, { days: 1 })), + previous: getDate(sub(time, { days: 2 })), + }; + case TalerCorebankApi.MonitorTimeframeParam.month: + return { + current: getMonth(sub(time, { months: 1 })), + previous: getMonth(sub(time, { months: 2 })), + }; + case TalerCorebankApi.MonitorTimeframeParam.year: + return { + current: getYear(sub(time, { years: 1 })), + previous: getYear(sub(time, { years: 2 })), + }; + case TalerCorebankApi.MonitorTimeframeParam.decade: + return { + current: getYear(sub(time, { years: 10 })), + previous: getYear(sub(time, { years: 20 })), + }; + default: + assertUnreachable(timeframe); + } +} + +function Metrics({ + routeDownloadStats, +}: { + routeDownloadStats: RouteDefinition; +}): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const [metricType, setMetricType] = + useState<TalerCorebankApi.MonitorTimeframeParam>( + TalerCorebankApi.MonitorTimeframeParam.hour, + ); + const { config } = useBankCoreApiContext(); + const respInfo = useConversionInfo(); + const params = getTimeframesForDate(new Date(), metricType); + + const resp = useLastMonitorInfo(params.current, params.previous, metricType); + if (!resp) return <Fragment />; + if (resp instanceof TalerError) { + return <ErrorLoadingWithDebug error={resp} />; + } + if (!respInfo) return <Fragment />; + if (respInfo instanceof TalerError) { + return <ErrorLoadingWithDebug error={respInfo} />; + } + if (respInfo.type === "fail") { + switch (respInfo.case) { + case HttpStatusCode.NotImplemented: { + return ( + <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: + assertUnreachable(respInfo.case); + } + } + + if (resp.current.type !== "ok" || resp.previous.type !== "ok") { + return <Fragment />; + } + return ( + <Fragment> + <div class="sm:hidden"> + <label for="tabs" class="sr-only"> + <i18n.Translate>Select a section</i18n.Translate> + </label> + <select + id="tabs" + name="tabs" + class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" + onChange={(e) => { + // const op = e.currentTarget.value as typeof metricType + setMetricType( + e.currentTarget + .value as unknown as TalerCorebankApi.MonitorTimeframeParam, + ); + }} + > + <option + value={TalerCorebankApi.MonitorTimeframeParam.hour} + selected={metricType == TalerCorebankApi.MonitorTimeframeParam.hour} + > + <i18n.Translate>Last hour</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.day} + selected={metricType == TalerCorebankApi.MonitorTimeframeParam.day} + > + <i18n.Translate>Previous day</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.month} + selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.month + } + > + <i18n.Translate>Last month</i18n.Translate> + </option> + <option + value={TalerCorebankApi.MonitorTimeframeParam.year} + selected={metricType == TalerCorebankApi.MonitorTimeframeParam.year} + > + <i18n.Translate>Last year</i18n.Translate> + </option> + </select> + </div> + <div class="hidden sm:block"> + {/* FIXME: This should be LINKS */} + <nav + class="isolate flex divide-x divide-gray-200 rounded-lg shadow" + aria-label="Tabs" + > + <button + type="button" + name="set last hour" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.hour); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.hour + } + class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last hour</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.hour + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set previous day" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.day); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.day + } + class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Previous day</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.day + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set last month" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.month); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.month + } + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last month</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.month + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + <button + type="button" + name="set last year" + onClick={(e) => { + e.preventDefault(); + setMetricType(TalerCorebankApi.MonitorTimeframeParam.year); + }} + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.year + } + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Last Year</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={ + metricType == TalerCorebankApi.MonitorTimeframeParam.year + } + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </button> + </nav> + </div> + + <div class="w-full flex justify-between"> + <h1 class="text-base font-semibold leading-7 text-gray-900 mt-5"> + {i18n.str`Trading volume on ${getDateForTimeframe( + params.current, + metricType, + dateLocale, + )} compared to ${getDateForTimeframe( + params.previous, + metricType, + dateLocale, + )}`} + </h1> + </div> + <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0"> + {resp.current.body.type !== "with-conversions" || + resp.previous.body.type !== "with-conversions" ? undefined : ( + <Fragment> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Cashin</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate>Transferred from an external account to an account in this bank.</i18n.Translate> + </div> + </dt> + <MetricValue + current={resp.current.body.cashinFiatVolume} + previous={resp.previous.body.cashinFiatVolume} + spec={respInfo.body.fiat_currency_specification} + /> + </div> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Cashout</i18n.Translate> + </dt> + <div class="text-xs text-gray-500"> + <i18n.Translate>Transferred from an account in this bank to an external account.</i18n.Translate> + </div> + <MetricValue + current={resp.current.body.cashoutFiatVolume} + previous={resp.previous.body.cashoutFiatVolume} + spec={respInfo.body.fiat_currency_specification} + /> + </div> + </Fragment> + )} + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Payin</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate>Transferred from an account to a Taler exchange.</i18n.Translate> + </div> + </dt> + <MetricValue + current={resp.current.body.talerInVolume} + previous={resp.previous.body.talerInVolume} + spec={config.currency_specification} + /> + </div> + <div class="px-4 py-5 sm:p-6"> + <dt class="text-base font-normal text-gray-900"> + <i18n.Translate>Payout</i18n.Translate> + <div class="text-xs text-gray-500"> + <i18n.Translate>Transferred from a Taler exchange to another account.</i18n.Translate> + </div> + </dt> + <MetricValue + current={resp.current.body.talerOutVolume} + previous={resp.previous.body.talerOutVolume} + spec={config.currency_specification} + /> + </div> + </dl> + <div class="flex justify-end mt-2"> + <a + href={routeDownloadStats.url({})} + name="download stats" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Download stats as CSV</i18n.Translate> + </a> + </div> + </Fragment> + ); +} + +function MetricValue({ + current, + previous, + spec, +}: { + spec: CurrencySpecification; + current: AmountString | undefined; + previous: AmountString | undefined; +}): VNode { + const { i18n } = useTranslationContext(); + const cmp = current && previous ? Amounts.cmp(current, previous) : 0; + const cv = !current ? undefined : Amounts.stringifyValue(current); + const currAmount = !cv ? undefined : Number.parseFloat(cv); + const prevAmount = !previous + ? undefined + : Number.parseFloat(Amounts.stringifyValue(previous)); + + const rate = + !currAmount || + Number.isNaN(currAmount) || + !prevAmount || + Number.isNaN(prevAmount) + ? 0 + : cmp === -1 + ? 1 - Math.round(currAmount) / Math.round(prevAmount) + : cmp === 1 + ? Math.round(currAmount) / Math.round(prevAmount) - 1 + : 0; + + const negative = cmp === 0 ? undefined : cmp === -1; + const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`; + return ( + <Fragment> + <dd class="mt-1 block "> + <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600"> + {!current ? ( + "-" + ) : ( + <RenderAmount + value={Amounts.parseOrThrow(current)} + spec={spec} + hideSmall + /> + )} + </div> + <div class="flex flex-col"> + <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600"> + <small class="ml-2 text-sm font-medium text-gray-500"> + <i18n.Translate>from</i18n.Translate>{" "} + {!previous ? ( + "-" + ) : ( + <RenderAmount + value={Amounts.parseOrThrow(previous)} + spec={spec} + hideSmall + /> + )} + </small> + </div> + {!!rate && ( + <span + data-negative={negative} + class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre" + > + {negative ? ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75" + /> + </svg> + ) : ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75" + /> + </svg> + )} + + {negative ? ( + <span class="sr-only"> + <i18n.Translate>Decreased by</i18n.Translate> + </span> + ) : ( + <span class="sr-only"> + <i18n.Translate>Increased by</i18n.Translate> + </span> + )} + {rateStr} + </span> + )} + </div> + </dd> + </Fragment> + ); +} diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx new file mode 100644 index 000000000..38119735e --- /dev/null +++ b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx @@ -0,0 +1,204 @@ +/* + 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 { + HttpStatusCode, + TalerCorebankApi, + TalerErrorCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { RouteDefinition } from "../../route.js"; +import { AccountForm } from "./AccountForm.js"; + +export function CreateNewAccount({ + routeCancel, + onCreateSuccess, +}: { + routeCancel: RouteDefinition; + onCreateSuccess: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: credentials } = useSessionState(); + const token = + credentials.status !== "loggedIn" ? undefined : credentials.token; + const { bank: api } = useBankCoreApiContext(); + + const [submitAccount, setSubmitAccount] = useState< + TalerCorebankApi.RegisterAccountRequest | undefined + >(); + const [notification, notify, handleError] = useLocalNotification(); + + async function doCreate() { + if (!submitAccount || !token) return; + await handleError(async () => { + const resp = await api.createAccount(token, submitAccount); + if (resp.type === "ok") { + notifyInfo( + i18n.str`Account created with password "${submitAccount.password}".`, + ); + onCreateSuccess(); + } else { + switch (resp.case) { + case HttpStatusCode.BadRequest: + return notify({ + type: "error", + title: i18n.str`Server replied that phone or email is invalid`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`The rights to perform the operation are not sufficient`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return notify({ + type: "error", + title: i18n.str`Account username is already taken`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return notify({ + type: "error", + title: i18n.str`Account id is already taken`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return notify({ + type: "error", + title: i18n.str`Bank ran out of bonus credit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return notify({ + type: "error", + title: i18n.str`Account username can't be used because is reserved`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return notify({ + type: "error", + title: i18n.str`Only admin is allow to set debt limit.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return notify({ + type: "error", + title: i18n.str`No information for the selected authentication channel.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return notify({ + type: "error", + title: i18n.str`Authentication channel is not supported.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return notify({ + type: "error", + title: i18n.str`Only admin can create accounts with second factor authentication.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + default: + assertUnreachable(resp); + } + } + }); + } + + if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Can't create accounts`}> + <i18n.Translate> + Only system admin can create accounts. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeCancel.url({})} + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>New bank account</i18n.Translate> + </h2> + </div> + <AccountForm + template={undefined} + purpose="create" + onChange={(a) => { + setSubmitAccount(a); + }} + > + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="create" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!submitAccount} + onClick={(e) => { + e.preventDefault(); + doCreate(); + }} + > + <i18n.Translate>Create</i18n.Translate> + </button> + </div> + </AccountForm> + </div> + ); +} diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx new file mode 100644 index 000000000..fba366676 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/DownloadStats.tsx @@ -0,0 +1,585 @@ +/* + 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 { + AccessToken, + AmountString, + TalerCoreBankHttpClient, + TalerCorebankApi, + TalerError, +} from "@gnu-taler/taler-util"; +import { + Attention, + LocalNotificationBanner, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useSessionState } from "../../hooks/session.js"; +import { EmptyObject, RouteDefinition } from "../../route.js"; +import { getTimeframesForDate } from "./AdminHome.js"; + +interface Props { + routeCancel: RouteDefinition; +} + +type Options = { + dayMetric: boolean; + hourMetric: boolean; + monthMetric: boolean; + yearMetric: boolean; + compareWithPrevious: boolean; + endOnFirstFail: boolean; + includeHeader: boolean; +}; + +/** + * Show histories of public accounts. + */ +export function DownloadStats({ routeCancel }: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useSessionState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + const { bank: api } = useBankCoreApiContext(); + + const [options, setOptions] = useState<Options>({ + compareWithPrevious: true, + dayMetric: true, + endOnFirstFail: false, + hourMetric: true, + includeHeader: true, + monthMetric: true, + yearMetric: true, + }); + const [lastStep, setLastStep] = useState<{ step: number; total: number }>(); + const [downloaded, setDownloaded] = useState<string>(); + const referenceDates = [new Date()]; + const [notification, , handleError] = useLocalNotification(); + + if (!creds) { + return <div>only admin can download stats</div>; + } + + return ( + <div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Download bank stats</i18n.Translate> + </h2> + </div> + + <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"> + <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>Include hour metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`hour switch`} + data-enabled={options.hourMetric} + 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={() => { + setOptions({ + ...options, + hourMetric: !options.hourMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.hourMetric} + 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 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>Include day metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`day switch`} + data-enabled={!!options.dayMetric} + 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={() => { + setOptions({ ...options, dayMetric: !options.dayMetric }); + }} + > + <span + aria-hidden="true" + data-enabled={options.dayMetric} + 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 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>Include month metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`month switch`} + data-enabled={!!options.monthMetric} + 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={() => { + setOptions({ + ...options, + monthMetric: !options.monthMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.monthMetric} + 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 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>Include year metric</i18n.Translate> + </span> + </span> + <button + type="button" + name={`year switch`} + data-enabled={!!options.yearMetric} + 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={() => { + setOptions({ + ...options, + yearMetric: !options.yearMetric, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.yearMetric} + 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 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>Include table header</i18n.Translate> + </span> + </span> + <button + type="button" + name={`header switch`} + data-enabled={!!options.includeHeader} + 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={() => { + setOptions({ + ...options, + includeHeader: !options.includeHeader, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.includeHeader} + 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 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> + Add previous metric for compare + </i18n.Translate> + </span> + </span> + <button + type="button" + name={`compare switch`} + data-enabled={!!options.compareWithPrevious} + 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={() => { + setOptions({ + ...options, + compareWithPrevious: !options.compareWithPrevious, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.compareWithPrevious} + 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 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>Fail on first error</i18n.Translate> + </span> + </span> + <button + type="button" + name={`fail switch`} + data-enabled={!!options.endOnFirstFail} + 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={() => { + setOptions({ + ...options, + endOnFirstFail: !options.endOnFirstFail, + }); + }} + > + <span + aria-hidden="true" + data-enabled={options.endOnFirstFail} + 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> + + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="download" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={lastStep !== undefined} + onClick={async () => { + setDownloaded(undefined); + await handleError(async () => { + const csv = await fetchAllStatus( + api, + creds.token, + options, + referenceDates, + (step, total) => { + setLastStep({ step, total }); + }, + ); + setDownloaded(csv); + }); + setLastStep(undefined); + }} + > + <i18n.Translate>Download</i18n.Translate> + </button> + </div> + </form> + </div> + {!lastStep || lastStep.step === lastStep.total ? ( + <div class="h-5 mb-5" /> + ) : ( + <div> + <div class="relative mb-5 h-5 rounded-full bg-gray-200"> + <div + class="h-full animate-pulse rounded-full bg-blue-500" + style={{ + width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`, + }} + > + <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white"> + <i18n.Translate> + downloading...{" "} + {Math.round((lastStep.step / lastStep.total) * 100)} + </i18n.Translate> + </span> + </div> + </div> + </div> + )} + {!downloaded ? ( + <div class="h-5 mb-5" /> + ) : ( + <a + href={ + "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded) + } + name="save file" + download={"bank-stats.csv"} + > + <Attention title={i18n.str`Download completed`}> + <i18n.Translate> + Click here to save the file in your computer. + </i18n.Translate> + </Attention> + </a> + )} + </div> + ); +} + +async function fetchAllStatus( + api: TalerCoreBankHttpClient, + token: AccessToken, + options: Options, + references: Date[], + progress: (current: number, total: number) => void, +): Promise<string> { + const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = []; + if (options.hourMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour); + } + if (options.dayMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day); + } + if (options.monthMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month); + } + if (options.yearMetric) { + allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year); + } + + /** + * convert request into frames + */ + const allFrames = allMetrics.flatMap((timeframe) => + references.map((reference) => ({ + reference, + timeframe, + moment: getTimeframesForDate(reference, timeframe), + })), + ); + const total = allFrames.length; + + /** + * call API for info + */ + const allInfo = await allFrames.reduce( + async (prev, frame, index) => { + const accumulatedMap = await prev; + progress(index, total); + // await delay() + const previous = options.compareWithPrevious + ? await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.previous, + }) + : undefined; + + if (previous && previous.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(previous.detail); + } + + const current = await api.getMonitor(token, { + timeframe: frame.timeframe, + which: frame.moment.current, + }); + + if (current.type === "fail" && options.endOnFirstFail) { + throw TalerError.fromUncheckedDetail(current.detail); + } + + const metricName = + TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]]; + accumulatedMap[metricName] = { + reference: frame.reference, + current: current.type !== "ok" ? undefined : current.body, + previous: + !previous || previous.type !== "ok" ? undefined : previous.body, + }; + return accumulatedMap; + }, + Promise.resolve({} as Record<string, Data>), + ); + progress(total, total); + + /** + * convert into table format + * + */ + const table: Array<string[]> = []; + if (options.includeHeader) { + table.push([ + "date", + "metric", + "reference", + "talerInCount", + "talerInVolume", + "talerOutCount", + "talerOutVolume", + "cashinCount", + "cashinFiatVolume", + "cashinRegionalVolume", + "cashoutCount", + "cashoutFiatVolume", + "cashoutRegionalVolume", + ]); + } + Object.entries(allInfo).forEach(([name, data]) => { + if (data.current) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "current", + ...dataToRow(data.current), + }; + table.push(Object.values(row) as string[]); + } + + if (data.previous) { + const row: TableRow = { + date: data.reference.getTime(), + metric: name, + reference: "previous", + ...dataToRow(data.previous), + }; + table.push(Object.values(row) as string[]); + } + }); + + const csv = table.reduce((acc, row) => { + return acc + row.join(",") + "\n"; + }, ""); + + return csv; +} + +type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">; +function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData { + return { + talerInCount: info.talerInCount, + talerInVolume: info.talerInVolume, + talerOutCount: info.talerOutCount, + talerOutVolume: info.talerOutVolume, + cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount, + cashinFiatVolume: + info.type === "no-conversions" ? undefined : info.cashinFiatVolume, + cashinRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashinRegionalVolume, + cashoutCount: + info.type === "no-conversions" ? undefined : info.cashoutCount, + cashoutFiatVolume: + info.type === "no-conversions" ? undefined : info.cashoutFiatVolume, + cashoutRegionalVolume: + info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume, + }; +} + +type Data = { + reference: Date; + previous: TalerCorebankApi.MonitorResponse | undefined; + current: TalerCorebankApi.MonitorResponse | undefined; +}; +type TableRow = { + date: number; + metric: string; + reference: "current" | "previous"; + cashinCount?: number; + cashinRegionalVolume?: AmountString; + cashinFiatVolume?: AmountString; + cashoutCount?: number; + cashoutRegionalVolume?: AmountString; + cashoutFiatVolume?: AmountString; + talerInCount: number; + talerInVolume: AmountString; + talerOutCount: number; + talerOutVolume: AmountString; +}; diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx new file mode 100644 index 000000000..61def9a95 --- /dev/null +++ b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx @@ -0,0 +1,267 @@ +/* + 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 { + AbsoluteTime, + Amounts, + HttpStatusCode, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + notifyInfo, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useAccountDetails } from "../../hooks/account.js"; +import { useSessionState } from "../../hooks/session.js"; +import { undefinedIfEmpty } from "../../utils.js"; +import { LoginForm } from "../LoginForm.js"; +import { doAutoFocus } from "../PaytoWireTransferForm.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { RouteDefinition } from "../../route.js"; + +export function RemoveAccount({ + account, + routeCancel, + onUpdateSuccess, + onAuthorizationRequired, + focus, + routeHere, +}: { + focus?: boolean; + routeHere: RouteDefinition<{ account: string }>; + onAuthorizationRequired: () => void; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const [accountName, setAccountName] = useState<string | undefined>(); + + const { state } = useSessionState(); + const token = state.status !== "loggedIn" ? undefined : state.token; + const { bank: api } = useBankCoreApiContext(); + const [notification, notify, handleError] = useLocalNotification(); + const [, updateBankState] = useBankState(); + + if (!result) { + return <Loading />; + } + if (result instanceof TalerError) { + return <ErrorLoadingWithDebug error={result} />; + } + if (result.type === "fail") { + switch (result.case) { + case HttpStatusCode.Unauthorized: + return <LoginForm currentUser={account} />; + case HttpStatusCode.NotFound: + return <LoginForm currentUser={account} />; + default: + assertUnreachable(result); + } + } + + const balance = Amounts.parse(result.body.balance.amount); + if (!balance) { + return <div>there was an error reading the balance</div>; + } + const isBalanceEmpty = Amounts.isZero(balance); + if (!isBalanceEmpty) { + return ( + <Fragment> + <Attention type="warning" title={i18n.str`Can't delete the account`}> + <i18n.Translate> + The account can't be delete while still holding some balance. First + make sure that the owner make a complete cashout. + </i18n.Translate> + </Attention> + <div class="mt-5 sm:mt-6"> + <a + href={routeCancel.url({})} + name="close" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Close</i18n.Translate> + </a> + </div> + </Fragment> + ); + } + + async function doRemove() { + if (!token) return; + await handleError(async () => { + const resp = await api.deleteAccount({ username: account, token }); + if (resp.type === "ok") { + notifyInfo(i18n.str`Account removed`); + onUpdateSuccess(); + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: + return notify({ + type: "error", + title: i18n.str`No enough permission to delete the account.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.NotFound: + return notify({ + type: "error", + title: i18n.str`The username was not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return notify({ + type: "error", + title: i18n.str`Can't delete a reserved username.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO: + return notify({ + type: "error", + title: i18n.str`Can't delete an account with balance different than zero.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + case HttpStatusCode.Accepted: { + updateBankState("currentChallenge", { + operation: "delete-account", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + location: routeHere.url({ account }), + request: account, + }); + return onAuthorizationRequired(); + } + default: { + assertUnreachable(resp); + } + } + } + }); + } + + const errors = undefinedIfEmpty({ + accountName: !accountName + ? i18n.str`Required` + : account !== accountName + ? i18n.str`Name doesn't match` + : undefined, + }); + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + + <Attention + type="warning" + title={i18n.str`You are going to remove the account`} + > + <i18n.Translate>This step can't be undone.</i18n.Translate> + </Attention> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Deleting account "{account}"</i18n.Translate> + </h2> + </div> + <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="password" + > + {i18n.str`Verification`} + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="text" + class="block w-full 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="password" + id="password" + data-error={ + !!errors?.accountName && accountName !== undefined + } + value={accountName ?? ""} + onChange={(e) => { + setAccountName(e.currentTarget.value); + }} + placeholder={account} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.accountName} + isDirty={accountName !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Enter the account name that is going to be deleted + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a + href={routeCancel.url({})} + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="delete" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + disabled={!!errors} + onClick={(e) => { + e.preventDefault(); + doRemove(); + }} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </div> + </form> + </div> + </div> + ); +} |