summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/pages/admin
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/pages/admin')
-rw-r--r--packages/bank-ui/src/pages/admin/AccountForm.tsx901
-rw-r--r--packages/bank-ui/src/pages/admin/AccountList.tsx244
-rw-r--r--packages/bank-ui/src/pages/admin/AdminHome.tsx541
-rw-r--r--packages/bank-ui/src/pages/admin/CreateNewAccount.tsx204
-rw-r--r--packages/bank-ui/src/pages/admin/DownloadStats.tsx585
-rw-r--r--packages/bank-ui/src/pages/admin/RemoveAccount.tsx267
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>
+ );
+}