aboutsummaryrefslogtreecommitdiff
path: root/packages/demobank-ui/src/pages/admin
diff options
context:
space:
mode:
authorÖzgür Kesim <oec-taler@kesim.org>2023-10-06 16:33:05 +0200
committerÖzgür Kesim <oec-taler@kesim.org>2023-10-06 16:33:05 +0200
commitfe7b51ef2736edbf04f5bbd9d19f2a2d04baccc2 (patch)
tree66c68c8d6a666f6e74dc663c9ee4f07879f6626c /packages/demobank-ui/src/pages/admin
parent35611f0bf9cf67638b171c2a300fab1797d3d8f0 (diff)
parent97d7be7503168f4f3bbd05905d32aa76ca1636b2 (diff)
Merge branch 'master' into age-withdraw
Diffstat (limited to 'packages/demobank-ui/src/pages/admin')
-rw-r--r--packages/demobank-ui/src/pages/admin/Account.tsx38
-rw-r--r--packages/demobank-ui/src/pages/admin/AccountForm.tsx315
-rw-r--r--packages/demobank-ui/src/pages/admin/AccountList.tsx132
-rw-r--r--packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx101
-rw-r--r--packages/demobank-ui/src/pages/admin/Home.tsx148
-rw-r--r--packages/demobank-ui/src/pages/admin/RemoveAccount.tsx171
6 files changed, 905 insertions, 0 deletions
diff --git a/packages/demobank-ui/src/pages/admin/Account.tsx b/packages/demobank-ui/src/pages/admin/Account.tsx
new file mode 100644
index 000000000..676fc43d0
--- /dev/null
+++ b/packages/demobank-ui/src/pages/admin/Account.tsx
@@ -0,0 +1,38 @@
+import { Amounts } from "@gnu-taler/taler-util";
+import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { useAccountDetails } from "../../hooks/access.js";
+import { useBackendContext } from "../../context/backend.js";
+import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+
+export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ const r = useBackendContext();
+ const account = r.state.status !== "loggedOut" ? r.state.username : "admin";
+ const result = useAccountDetails(account);
+
+ if (!result.ok) {
+ return handleNotOkResult(i18n)(result);
+ }
+ const { data } = result;
+
+ const balance = Amounts.parseOrThrow(data.balance.amount);
+ const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
+
+ const debitThreshold = Amounts.parseOrThrow(result.data.debit_threshold);
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+ if (!balance) return <Fragment />;
+ return (
+ <PaytoWireTransferForm
+ title={i18n.str`Make a wire transfer`}
+ limit={limit}
+ onSuccess={() => {
+ notifyInfo(i18n.str`Wire transfer created!`);
+ }}
+ onCancel={undefined}
+ />
+ );
+}
diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx
new file mode 100644
index 000000000..ed8bf610d
--- /dev/null
+++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx
@@ -0,0 +1,315 @@
+import { ComponentChildren, VNode, h } from "preact";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { PartialButDefined, RecursivePartial, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { buildPayto, parsePaytoUri } from "@gnu-taler/taler-util";
+import { doAutoFocus } from "../PaytoWireTransferForm.js";
+
+const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
+const EMAIL_REGEX =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
+
+/**
+ * Create valid account object to update or create
+ * Take template as initial values for the form
+ * Purpose indicate if all field al read only (show), part of them (update)
+ * or none (create)
+ * @param param0
+ * @returns
+ */
+export function AccountForm({
+ template,
+ purpose,
+ onChange,
+ focus,
+ children,
+}: {
+ focus?: boolean,
+ children: ComponentChildren,
+ template: SandboxBackend.Circuit.CircuitAccountData | undefined;
+ onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void;
+ purpose: "create" | "update" | "show";
+}): VNode {
+ const initial = initializeFromTemplate(template);
+ const [form, setForm] = useState(initial);
+ const [errors, setErrors] = useState<
+ RecursivePartial<typeof initial> | undefined
+ >(undefined);
+ const { i18n } = useTranslationContext();
+
+ function updateForm(newForm: typeof initial): void {
+
+ const parsed = !newForm.cashout_address
+ ? undefined
+ : buildPayto("iban", newForm.cashout_address, undefined);;
+
+ const errors = undefinedIfEmpty<RecursivePartial<typeof initial>>({
+ cashout_address: !newForm.cashout_address
+ ? i18n.str`required`
+ : !parsed
+ ? i18n.str`does not follow the pattern`
+ : !parsed.isKnown || parsed.targetType !== "iban"
+ ? i18n.str`only "IBAN" target are supported`
+ : !IBAN_REGEX.test(parsed.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : validateIBAN(parsed.iban, i18n),
+ contact_data: undefinedIfEmpty({
+ email: !newForm.contact_data?.email
+ ? i18n.str`required`
+ : !EMAIL_REGEX.test(newForm.contact_data.email)
+ ? i18n.str`it should be an email`
+ : undefined,
+ phone: !newForm.contact_data?.phone
+ ? i18n.str`required`
+ : !newForm.contact_data.phone.startsWith("+")
+ ? i18n.str`should start with +`
+ : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone)
+ ? i18n.str`phone number can't have other than numbers`
+ : undefined,
+ }),
+ // iban: !newForm.iban
+ // ? undefined //optional field
+ // : !IBAN_REGEX.test(newForm.iban)
+ // ? i18n.str`IBAN should have just uppercased letters and numbers`
+ // : validateIBAN(newForm.iban, i18n),
+ name: !newForm.name ? i18n.str`required` : undefined,
+ username: !newForm.username ? i18n.str`required` : undefined,
+ });
+ setErrors(errors);
+ setForm(newForm);
+ onChange(errors === undefined ? (newForm as any) : undefined);
+ }
+
+ 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`Username`}
+ {purpose === "create" && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? 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={purpose !== "create"}
+ value={form.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 identification in the bank</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`Name`}
+ {purpose === "create" && <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={purpose !== "create"}
+ value={form.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 person owner the account</i18n.Translate>
+ </p>
+ </div>
+
+
+ {purpose !== "create" && (<div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="internal-iban"
+ >
+ {i18n.str`Internal IBAN`}
+ </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="internal-iban"
+ id="internal-iban"
+ disabled={true}
+ value={form.iban ?? ""}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate>international bank account number</i18n.Translate>
+ </p>
+ </div>)}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="email"
+ >
+ {i18n.str`Email`}
+ {purpose === "create" && <b style={{ color: "red" }}> *</b>}
+ </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?.contact_data?.email && form.contact_data.email !== undefined}
+ disabled={purpose !== "create"}
+ value={form.contact_data.email ?? ""}
+ onChange={(e) => {
+ form.contact_data.email = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data?.email}
+ isDirty={form.contact_data.email !== undefined}
+ />
+ </div>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="phone"
+ >
+ {i18n.str`Phone`}
+ {purpose === "create" && <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="phone"
+ id="phone"
+ disabled={purpose !== "create"}
+ value={form.contact_data.phone ?? ""}
+ data-error={!!errors?.contact_data?.phone && form.contact_data.phone !== undefined}
+ onChange={(e) => {
+ form.contact_data.phone = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data?.phone}
+ isDirty={form.contact_data.phone !== undefined}
+ />
+ </div>
+ </div>
+
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="cashout"
+ >
+ {i18n.str`Cashout IBAN`}
+ {purpose !== "show" && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ data-error={!!errors?.cashout_address && form.cashout_address !== 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_address ?? ""}
+ onChange={(e) => {
+ form.cashout_address = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.cashout_address}
+ isDirty={form.cashout_address !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate>account number where the money is going to be sent when doing cashouts</i18n.Translate>
+ </p>
+ </div>
+
+ </div>
+ </div>
+ {children}
+ </form>
+ );
+}
+
+function initializeFromTemplate(
+ account: SandboxBackend.Circuit.CircuitAccountData | undefined,
+): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> {
+ const emptyAccount = {
+ cashout_address: undefined,
+ iban: undefined,
+ name: undefined,
+ username: undefined,
+ contact_data: undefined,
+ };
+ const emptyContact = {
+ email: undefined,
+ phone: undefined,
+ };
+
+ const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> =
+ structuredClone(account) ?? emptyAccount;
+ if (typeof initial.contact_data === "undefined") {
+ initial.contact_data = emptyContact;
+ }
+ initial.contact_data.email;
+ return initial as any;
+}
+
+
diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx
new file mode 100644
index 000000000..a6899e679
--- /dev/null
+++ b/packages/demobank-ui/src/pages/admin/AccountList.tsx
@@ -0,0 +1,132 @@
+import { h, VNode } from "preact";
+import { useBusinessAccounts } from "../../hooks/circuit.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { AccountAction } from "./Home.js";
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+
+interface Props {
+ onAction: (type: AccountAction, account: string) => void;
+ account: string | undefined;
+ onCreateAccount: () => void;
+}
+
+export function AccountList({ account, onAction, onCreateAccount }: Props): VNode {
+ const result = useBusinessAccounts({ account });
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <div />;
+ if (!result.ok) {
+ return handleNotOkResult(i18n)(result);
+ }
+
+ const { customers } = result.data;
+ return <div class="px-4 sm:px-6 lg:px-8">
+ <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 business account in the bank.</i18n.Translate>
+ </p>
+ </div>
+ <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
+ <button 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"
+ onClick={(e) => {
+ e.preventDefault()
+ onCreateAccount()
+ }}>
+ <i18n.Translate>Create account</i18n.Translate>
+ </button>
+ </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">
+ {!customers.length ? (
+ <div></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">
+ {customers.map((item, idx) => {
+ const balance = !item.balance
+ ? undefined
+ : Amounts.parse(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 href="#" class="text-indigo-600 hover:text-indigo-900"
+ onClick={(e) => {
+ e.preventDefault();
+ onAction("show-details", item.username)
+ }}
+ >
+ {item.username}
+ </a>
+
+
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {item.name}
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {!balance ? (
+ i18n.str`unknown`
+ ) : (
+ <span class="amount">
+ <RenderAmount value={balance} negative={balanceIsDebit} />
+ </span>
+ )}
+ </td>
+ <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
+ <a href="#" class="text-indigo-600 hover:text-indigo-900"
+ onClick={(e) => {
+ e.preventDefault();
+ onAction("update-password", item.username)
+ }}
+ >
+ change password
+ </a>
+ <br />
+ <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => {
+ e.preventDefault();
+ onAction("show-cashout", item.username)
+ }}
+ >
+ cashouts
+ </a>
+ <br />
+ <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => {
+ e.preventDefault();
+ onAction("remove-account", item.username)
+ }}
+ >
+ remove
+ </a>
+ </td>
+ </tr>
+ })}
+
+ </tbody>
+ </table>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+} \ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx
new file mode 100644
index 000000000..2146fc6f0
--- /dev/null
+++ b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx
@@ -0,0 +1,101 @@
+import { RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h, Fragment } from "preact";
+import { useAdminAccountAPI } from "../../hooks/circuit.js";
+import { useState } from "preact/hooks";
+import { buildRequestErrorMessage } from "../../utils.js";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { getRandomPassword } from "../rnd.js";
+import { AccountForm } from "./AccountForm.js";
+
+export function CreateNewAccount({
+ onCancel,
+ onCreateSuccess,
+}: {
+ onCancel: () => void;
+ onCreateSuccess: (password: string) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { createAccount } = useAdminAccountAPI();
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+
+ async function doCreate() {
+ if (!submitAccount) return;
+ try {
+ const account: SandboxBackend.Circuit.CircuitAccountRequest =
+ {
+ cashout_address: submitAccount.cashout_address,
+ contact_data: submitAccount.contact_data,
+ internal_iban: submitAccount.iban,
+ name: submitAccount.name,
+ username: submitAccount.username,
+ password: getRandomPassword(),
+ };
+
+ await createAccount(account);
+ onCreateSuccess(account.password);
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The rights to perform the operation are not sufficient`
+ : status === HttpStatusCode.BadRequest
+ ? i18n.str`Server replied that input data was invalid`
+ : status === HttpStatusCode.Conflict
+ ? i18n.str`At least one registration detail was not available`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+
+ 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">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>New business 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">
+ {onCancel ?
+ <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ : <div />
+ }
+ <button type="submit"
+ 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/demobank-ui/src/pages/admin/Home.tsx b/packages/demobank-ui/src/pages/admin/Home.tsx
new file mode 100644
index 000000000..d50ff14b4
--- /dev/null
+++ b/packages/demobank-ui/src/pages/admin/Home.tsx
@@ -0,0 +1,148 @@
+import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { ShowCashoutDetails } from "../business/Home.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { ShowAccountDetails } from "../ShowAccountDetails.js";
+import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
+import { AdminAccount } from "./Account.js";
+import { AccountList } from "./AccountList.js";
+import { CreateNewAccount } from "./CreateNewAccount.js";
+import { RemoveAccount } from "./RemoveAccount.js";
+import { Transactions } from "../../components/Transactions/index.js";
+
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+interface Props {
+ onRegister: () => void;
+}
+export type AccountAction = "show-details" |
+ "show-cashout" |
+ "update-password" |
+ "remove-account" |
+ "show-cashouts-details";
+
+export function AdminHome({ onRegister }: Props): VNode {
+ const [action, setAction] = useState<{
+ type: AccountAction,
+ account: string
+ } | undefined>()
+
+ const [createAccount, setCreateAccount] = useState(false);
+
+ const { i18n } = useTranslationContext();
+
+ if (action) {
+ switch (action.type) {
+ case "show-cashouts-details": return <ShowCashoutDetails
+ id={action.account}
+ onLoadNotOk={handleNotOkResult(i18n)}
+ onCancel={() => {
+ setAction(undefined);
+ }}
+ />
+ case "show-cashout": return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Cashout for account {action.account}</i18n.Translate>
+ </h1>
+ </div>
+ <Cashouts
+ account={action.account}
+ onSelected={(id) => {
+ setAction({
+ type: "show-cashouts-details",
+ account: action.account
+ });
+ }}
+ />
+ <p>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ setAction(undefined);
+ }}
+ />
+ </p>
+ </div>
+ )
+ case "update-password": return <UpdateAccountPassword
+ account={action.account}
+ onLoadNotOk={handleNotOkResult(i18n)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Password changed`);
+ setAction(undefined);
+ }}
+ onCancel={() => {
+ setAction(undefined);
+ }}
+ />
+ case "remove-account": return <RemoveAccount
+ account={action.account}
+ onLoadNotOk={handleNotOkResult(i18n)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Account removed`);
+ setAction(undefined);
+ }}
+ onCancel={() => {
+ setAction(undefined);
+ }}
+ />
+ case "show-details": return <ShowAccountDetails
+ account={action.account}
+ onLoadNotOk={handleNotOkResult(i18n)}
+ onChangePassword={() => {
+ setAction({
+ type: "update-password",
+ account: action.account,
+ })
+ }}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Account updated`);
+ setAction(undefined);
+ }}
+ onClear={() => {
+ setAction(undefined);
+ }}
+ />
+ }
+ }
+
+ if (createAccount) {
+ return (
+ <CreateNewAccount
+ onCancel={() => setCreateAccount(false)}
+ onCreateSuccess={(password) => {
+ notifyInfo(
+ i18n.str`Account created with password "${password}". The user must change the password on the next login.`,
+ );
+ setCreateAccount(false);
+ }}
+ />
+ );
+ }
+
+ return (
+ <Fragment>
+
+ <AccountList
+ onCreateAccount={() => {
+ setCreateAccount(true);
+ }}
+ account={undefined}
+ onAction={(type, account) => setAction({ account, type })}
+
+ />
+
+ <AdminAccount onRegister={onRegister} />
+
+ <Transactions account="admin"/>
+ </Fragment>
+ );
+} \ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx
new file mode 100644
index 000000000..b323b0d01
--- /dev/null
+++ b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx
@@ -0,0 +1,171 @@
+import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h, Fragment } from "preact";
+import { useAccountDetails } from "../../hooks/access.js";
+import { useAdminAccountAPI } from "../../hooks/circuit.js";
+import { Amounts, HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { buildRequestErrorMessage, undefinedIfEmpty } from "../../utils.js";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { Attention } from "../../components/Attention.js";
+import { doAutoFocus } from "../PaytoWireTransferForm.js";
+
+export function RemoveAccount({
+ account,
+ onCancel,
+ onUpdateSuccess,
+ onLoadNotOk,
+ focus,
+}: {
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ focus?: boolean;
+ onCancel: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const [accountName, setAccountName] = useState<string | undefined>()
+ const { deleteAccount } = useAdminAccountAPI();
+
+ if (!result.ok) {
+ if (result.loading || result.type === ErrorType.TIMEOUT) {
+ return onLoadNotOk(result);
+ }
+ if (result.status === HttpStatusCode.NotFound) {
+ return <div>account not found</div>;
+ }
+ return onLoadNotOk(result);
+ }
+ const balance = Amounts.parse(result.data.balance.amount);
+ if (!balance) {
+ return <div>there was an error reading the balance</div>;
+ }
+ const isBalanceEmpty = Amounts.isZero(balance);
+ if (!isBalanceEmpty) {
+ return <Attention type="warning" title={i18n.str`Can't delete the account`} onClose={onCancel}>
+ <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>
+ }
+
+ async function doRemove() {
+ try {
+ const r = await deleteAccount(account);
+ onUpdateSuccess();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The administrator specified a institutional username`
+ : status === HttpStatusCode.NotFound
+ ? i18n.str`The username was not found`
+ : status === HttpStatusCode.PreconditionFailed
+ ? i18n.str`Balance was not zero`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString);
+ }
+ }
+ }
+
+ const errors = undefinedIfEmpty({
+ accountName: !accountName
+ ? i18n.str`required`
+ : account !== accountName
+ ? i18n.str`name doesn't match`
+ : undefined,
+ });
+
+
+ return (
+ <div>
+ <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">
+ {onCancel ?
+ <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ : <div />
+ }
+ <button type="submit"
+ 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>
+ );
+}