summaryrefslogtreecommitdiff
path: root/packages/demobank-ui/src/pages/AdminPage.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/demobank-ui/src/pages/AdminPage.tsx')
-rw-r--r--packages/demobank-ui/src/pages/AdminPage.tsx707
1 files changed, 707 insertions, 0 deletions
diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx
new file mode 100644
index 000000000..9efd37f12
--- /dev/null
+++ b/packages/demobank-ui/src/pages/AdminPage.tsx
@@ -0,0 +1,707 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 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 { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util";
+import {
+ HttpResponsePaginated,
+ RequestError,
+ useTranslationContext,
+} from "@gnu-taler/web-util/lib/index.browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorMessage, usePageContext } from "../context/pageState.js";
+import {
+ useAccountDetails,
+ useAccounts,
+ useAdminAccountAPI,
+} from "../hooks/circuit.js";
+import {
+ PartialButDefined,
+ undefinedIfEmpty,
+ WithIntermediate,
+} from "../utils.js";
+import { ErrorBanner } from "./BankFrame.js";
+import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
+
+const charset =
+ "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+const upperIdx = charset.indexOf("A");
+
+function randomPassword(): string {
+ const random = Array.from({ length: 16 }).map(() => {
+ return charset.charCodeAt(Math.random() * charset.length);
+ });
+ // first char can't be upper
+ const charIdx = charset.indexOf(String.fromCharCode(random[0]));
+ random[0] =
+ charIdx > upperIdx ? charset.charCodeAt(charIdx - upperIdx) : random[0];
+ return String.fromCharCode(...random);
+}
+
+interface Props {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+}
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+export function AdminPage({ onLoadNotOk }: Props): VNode {
+ const [account, setAccount] = useState<string | undefined>();
+ const [showDetails, setShowDetails] = useState<string | undefined>();
+ const [updatePassword, setUpdatePassword] = useState<string | undefined>();
+ const [createAccount, setCreateAccount] = useState(false);
+ const { pageStateSetter } = usePageContext();
+
+ function showInfoMessage(info: TranslatedString): void {
+ pageStateSetter((prev) => ({
+ ...prev,
+ info,
+ }));
+ }
+
+ const result = useAccounts({ account });
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <div />;
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ const { customers } = result.data;
+
+ if (showDetails) {
+ return (
+ <ShowAccountDetails
+ account={showDetails}
+ onLoadNotOk={onLoadNotOk}
+ onUpdateSuccess={() => {
+ showInfoMessage(i18n.str`Account updated`);
+ setShowDetails(undefined);
+ }}
+ onClear={() => {
+ setShowDetails(undefined);
+ }}
+ />
+ );
+ }
+ if (updatePassword) {
+ return (
+ <UpdateAccountPassword
+ account={updatePassword}
+ onLoadNotOk={onLoadNotOk}
+ onUpdateSuccess={() => {
+ showInfoMessage(i18n.str`Password changed`);
+ setUpdatePassword(undefined);
+ }}
+ onClear={() => {
+ setUpdatePassword(undefined);
+ }}
+ />
+ );
+ }
+ if (createAccount) {
+ return (
+ <CreateNewAccount
+ onClose={() => setCreateAccount(false)}
+ onCreateSuccess={(password) => {
+ showInfoMessage(
+ i18n.str`Account created with password "${password}"`,
+ );
+ setCreateAccount(false);
+ }}
+ />
+ );
+ }
+ return (
+ <Fragment>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div></div>
+ <div>
+ <input
+ class="pure-button pure-button-primary content"
+ type="submit"
+ value={i18n.str`Create account`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ setCreateAccount(true);
+ }}
+ />
+ </div>
+ </div>
+ </p>
+
+ <section id="main">
+ <article>
+ <h2>{i18n.str`Accounts:`}</h2>
+ <div class="results">
+ <table class="pure-table pure-table-striped">
+ <thead>
+ <tr>
+ <th>{i18n.str`Username`}</th>
+ <th>{i18n.str`Name`}</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ {customers.map((item, idx) => {
+ return (
+ <tr key={idx}>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setShowDetails(item.username);
+ }}
+ >
+ {item.username}
+ </a>
+ </td>
+ <td>{item.name}</td>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ setUpdatePassword(item.username);
+ }}
+ >
+ change password
+ </a>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </article>
+ </section>
+ </Fragment>
+ );
+}
+
+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 ]*$/;
+
+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;
+}
+
+function UpdateAccountPassword({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+}: {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const { changePassword } = useAdminAccountAPI();
+ const [password, setPassword] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+
+ if (result.clientError) {
+ if (result.isNotfound) return <div>account not found</div>;
+ }
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ const errors = undefinedIfEmpty({
+ password: !password ? i18n.str`required` : undefined,
+ repeat: !repeat
+ ? i18n.str`required`
+ : password !== repeat
+ ? i18n.str`password doesn't match`
+ : undefined,
+ });
+
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+
+ <form class="pure-form">
+ <fieldset>
+ <label for="username">{i18n.str`Username`}</label>
+ <input name="username" type="text" readOnly value={account} />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Password`}</label>
+ <input
+ type="password"
+ value={password ?? ""}
+ onChange={(e) => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Repeast password`}</label>
+ <input
+ type="password"
+ value={repeat ?? ""}
+ onChange={(e) => {
+ setRepeat(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeat}
+ isDirty={repeat !== undefined}
+ />
+ </fieldset>
+ </form>
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!!errors}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+ if (!!errors || !password) return;
+ try {
+ const r = await changePassword(account, {
+ new_password: password,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
+function CreateNewAccount({
+ onClose,
+ onCreateSuccess,
+}: {
+ onClose: () => void;
+ onCreateSuccess: (password: string) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { createAccount } = useAdminAccountAPI();
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+
+ <AccountForm
+ template={undefined}
+ purpose="create"
+ onChange={(a) => setSubmitAccount(a)}
+ />
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClose();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!submitAccount}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ 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: randomPassword(),
+ };
+
+ await createAccount(account);
+ onCreateSuccess(account.password);
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
+function ShowAccountDetails({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+}: {
+ onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const { updateAccount } = useAdminAccountAPI();
+ const [update, setUpdate] = useState(false);
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+ const [error, saveError] = useState<ErrorMessage | undefined>();
+
+ if (result.clientError) {
+ if (result.isNotfound) return <div>account not found</div>;
+ }
+ if (!result.ok) {
+ return onLoadNotOk(result);
+ }
+
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+ {error && (
+ <ErrorBanner error={error} onClear={() => saveError(undefined)} />
+ )}
+ <AccountForm
+ template={result.data}
+ purpose={update ? "update" : "show"}
+ onChange={(a) => setSubmitAccount(a)}
+ />
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={update && !submitAccount}
+ type="submit"
+ value={update ? i18n.str`Confirm` : i18n.str`Update`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (!update) {
+ setUpdate(true);
+ } else {
+ if (!submitAccount) return;
+ try {
+ await updateAccount(account, {
+ cashout_address: submitAccount.cashout_address,
+ contact_data: submitAccount.contact_data,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ handleError(error, saveError, i18n);
+ }
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+}
+
+/**
+ * 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
+ */
+function AccountForm({
+ template,
+ purpose,
+ onChange,
+}: {
+ 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<typeof initial | undefined>(undefined);
+ const { i18n } = useTranslationContext();
+
+ function updateForm(newForm: typeof initial): void {
+ const parsed = !newForm.cashout_address
+ ? undefined
+ : parsePaytoUri(newForm.cashout_address);
+
+ const validationResult = undefinedIfEmpty<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`
+ : undefined,
+ contact_data: {
+ email: !newForm.contact_data.email
+ ? undefined
+ : !EMAIL_REGEX.test(newForm.contact_data.email)
+ ? i18n.str`it should be an email`
+ : undefined,
+ phone: !newForm.contact_data.phone
+ ? undefined
+ : !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
+ ? i18n.str`required`
+ : !IBAN_REGEX.test(newForm.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : undefined,
+ name: !newForm.name ? i18n.str`required` : undefined,
+ username: !newForm.username ? i18n.str`required` : undefined,
+ });
+
+ setErrors(validationResult);
+ setForm(newForm);
+ onChange(validationResult === undefined ? undefined : (newForm as any));
+ }
+
+ return (
+ <form class="pure-form">
+ <fieldset>
+ <label for="username">{i18n.str`Username`}</label>
+ <input
+ name="username"
+ type="text"
+ disabled={purpose !== "create"}
+ value={form.username}
+ onChange={(e) => {
+ form.username = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={form.username !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Name`}</label>
+ <input
+ disabled={purpose !== "create"}
+ value={form.name ?? ""}
+ onChange={(e) => {
+ form.name = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={form.name !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`IBAN`}</label>
+ <input
+ disabled={purpose !== "create"}
+ value={form.iban ?? ""}
+ onChange={(e) => {
+ form.iban = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.iban}
+ isDirty={form.iban !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Email`}</label>
+ <input
+ disabled={purpose === "show"}
+ value={form.contact_data.email ?? ""}
+ onChange={(e) => {
+ form.contact_data.email = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data.email}
+ isDirty={form.contact_data.email !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Phone`}</label>
+ <input
+ disabled={purpose === "show"}
+ value={form.contact_data.phone ?? ""}
+ onChange={(e) => {
+ form.contact_data.phone = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data.phone}
+ isDirty={form.contact_data?.phone !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Cashout address`}</label>
+ <input
+ disabled={purpose === "show"}
+ value={form.cashout_address ?? ""}
+ onChange={(e) => {
+ form.cashout_address = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.cashout_address}
+ isDirty={form.cashout_address !== undefined}
+ />
+ </fieldset>
+ </form>
+ );
+}
+
+function handleError(
+ error: unknown,
+ saveError: (e: ErrorMessage) => void,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): void {
+ if (error instanceof RequestError) {
+ const payload = error.info.error as SandboxBackend.SandboxError;
+ saveError({
+ title: error.info.serverError
+ ? i18n.str`Server had an error`
+ : i18n.str`Server didn't accept the request`,
+ description: payload.error.description,
+ });
+ } else if (error instanceof Error) {
+ saveError({
+ title: i18n.str`Could not update account`,
+ description: error.message,
+ });
+ } else {
+ saveError({
+ title: i18n.str`Error, please report`,
+ debug: JSON.stringify(error),
+ });
+ }
+}