aboutsummaryrefslogtreecommitdiff
path: root/packages/demobank-ui/src/pages
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
parent35611f0bf9cf67638b171c2a300fab1797d3d8f0 (diff)
parent97d7be7503168f4f3bbd05905d32aa76ca1636b2 (diff)
Merge branch 'master' into age-withdraw
Diffstat (limited to 'packages/demobank-ui/src/pages')
-rw-r--r--packages/demobank-ui/src/pages/AccountPage.tsx170
-rw-r--r--packages/demobank-ui/src/pages/AccountPage/index.ts92
-rw-r--r--packages/demobank-ui/src/pages/AccountPage/state.ts92
-rw-r--r--packages/demobank-ui/src/pages/AccountPage/stories.tsx (renamed from packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx)24
-rw-r--r--packages/demobank-ui/src/pages/AccountPage/test.ts32
-rw-r--r--packages/demobank-ui/src/pages/AccountPage/views.tsx93
-rw-r--r--packages/demobank-ui/src/pages/AdminPage.tsx1064
-rw-r--r--packages/demobank-ui/src/pages/BankFrame.tsx609
-rw-r--r--packages/demobank-ui/src/pages/HomePage.tsx96
-rw-r--r--packages/demobank-ui/src/pages/LoginForm.tsx360
-rw-r--r--packages/demobank-ui/src/pages/OperationState/index.ts122
-rw-r--r--packages/demobank-ui/src/pages/OperationState/state.ts265
-rw-r--r--packages/demobank-ui/src/pages/OperationState/stories.tsx29
-rw-r--r--packages/demobank-ui/src/pages/OperationState/test.ts32
-rw-r--r--packages/demobank-ui/src/pages/OperationState/views.tsx376
-rw-r--r--packages/demobank-ui/src/pages/PaymentOptions.tsx134
-rw-r--r--packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx640
-rw-r--r--packages/demobank-ui/src/pages/PublicHistoriesPage.tsx16
-rw-r--r--packages/demobank-ui/src/pages/QrCodeSection.tsx126
-rw-r--r--packages/demobank-ui/src/pages/RegistrationPage.tsx440
-rw-r--r--packages/demobank-ui/src/pages/Routing.tsx110
-rw-r--r--packages/demobank-ui/src/pages/ShowAccountDetails.tsx167
-rw-r--r--packages/demobank-ui/src/pages/UpdateAccountPassword.tsx177
-rw-r--r--packages/demobank-ui/src/pages/WalletWithdrawForm.tsx339
-rw-r--r--packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx389
-rw-r--r--packages/demobank-ui/src/pages/WithdrawalQRCode.tsx119
-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
-rw-r--r--packages/demobank-ui/src/pages/business/Home.tsx (renamed from packages/demobank-ui/src/pages/BusinessAccount.tsx)226
-rw-r--r--packages/demobank-ui/src/pages/rnd.ts2895
34 files changed, 7400 insertions, 2739 deletions
diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx
deleted file mode 100644
index 820c59984..000000000
--- a/packages/demobank-ui/src/pages/AccountPage.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-/*
- 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 { Amounts, HttpStatusCode, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util";
-import {
- ErrorType,
- HttpResponsePaginated,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
-import { Transactions } from "../components/Transactions/index.js";
-import { useBackendContext } from "../context/backend.js";
-import { useAccountDetails } from "../hooks/access.js";
-import { LoginForm } from "./LoginForm.js";
-import { PaymentOptions } from "./PaymentOptions.js";
-import { notifyError } from "../hooks/notification.js";
-import { useEffect, useState } from "preact/hooks";
-
-interface Props {
- account: string;
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
-}
-
-export const CopyIcon = (): VNode => (
- <svg height="16" viewBox="0 0 16 16" width="16">
- <path
- fill-rule="evenodd"
- d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
- />
- <path
- fill-rule="evenodd"
- d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
- />
- </svg>
-);
-
-export const CopiedIcon = (): VNode => (
- <svg height="16" viewBox="0 0 16 16" width="16">
- <path
- fill-rule="evenodd"
- d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
- />
- </svg>
-);
-
-function CopyButton({ getContent }: { getContent: () => string }): VNode {
- const [copied, setCopied] = useState(false);
- function copyText(): void {
- navigator.clipboard.writeText(getContent() || "");
- setCopied(true);
- }
- useEffect(() => {
- if (copied) {
- setTimeout(() => {
- setCopied(false);
- }, 1000);
- }
- }, [copied]);
-
- if (!copied) {
- return (
- <button onClick={copyText} style={{width:32, height:32, fontSize: "initial"}}>
- <CopyIcon />
- </button>
- );
- }
- return (
- <div content="Copied" style={{display:"inline-block"}}>
- <button disabled style={{width:32, height:32 , fontSize: "initial"}}>
- <CopiedIcon />
- </button>
- </div>
- );
-}
-
-
-/**
- * Query account information and show QR code if there is pending withdrawal
- */
-export function AccountPage({ account, onLoadNotOk }: Props): VNode {
- const result = useAccountDetails(account);
- const backend = useBackendContext();
- const { i18n } = useTranslationContext();
-
- if (!result.ok) {
- if (result.loading || result.type === ErrorType.TIMEOUT) {
- return onLoadNotOk(result);
- }
- //logout if there is any error, not if loading
- backend.logOut();
- if (result.status === HttpStatusCode.NotFound) {
- notifyError({
- title: i18n.str`Username or account label "${account}" not found`,
- });
- return <LoginForm />;
- }
- return onLoadNotOk(result);
- }
-
- const { data } = result;
- const balance = Amounts.parseOrThrow(data.balance.amount);
- const debitThreshold = Amounts.parseOrThrow(data.debitThreshold);
- const payto = parsePaytoUri(data.paytoUri);
- if (!payto || !payto.isKnown || payto.targetType !== "iban") {
- return (
- <div>Payto from server is not valid &quot;{data.paytoUri}&quot;</div>
- );
- }
- const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
- const limit = balanceIsDebit
- ? Amounts.sub(debitThreshold, balance).amount
- : Amounts.add(balance, debitThreshold).amount;
- return (
- <Fragment>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>
- Welcome, {account} (<a href={stringifyPaytoUri(payto)}>{payto.iban}</a>)! <CopyButton getContent={() => stringifyPaytoUri(payto)} />
- </i18n.Translate>
- </h1>
- </div>
-
- <section id="assets">
- <div class="asset-summary">
- <h2>{i18n.str`Bank account balance`}</h2>
- {!balance ? (
- <div class="large-amount" style={{ color: "gray" }}>
- Waiting server response...
- </div>
- ) : (
- <div class="large-amount amount">
- {balanceIsDebit ? <b>-</b> : null}
- <span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
- &nbsp;
- <span class="currency">{`${balance.currency}`}</span>
- </div>
- )}
- </div>
- </section>
- <section id="payments">
- <div class="payments">
- <h2>{i18n.str`Payments`}</h2>
- <PaymentOptions limit={limit} />
- </div>
- </section>
-
- <section style={{ marginTop: "2em" }}>
- <div class="active">
- <h3>{i18n.str`Latest transactions`}</h3>
- <Transactions account={account} />
- </div>
- </section>
- </Fragment>
- );
-}
diff --git a/packages/demobank-ui/src/pages/AccountPage/index.ts b/packages/demobank-ui/src/pages/AccountPage/index.ts
new file mode 100644
index 000000000..9230fb6b1
--- /dev/null
+++ b/packages/demobank-ui/src/pages/AccountPage/index.ts
@@ -0,0 +1,92 @@
+/*
+ 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 { HttpError, HttpResponseOk, HttpResponsePaginated, utils } from "@gnu-taler/web-util/browser";
+import { AbsoluteTime, AmountJson, PaytoUriIBAN, PaytoUriTalerBank } from "@gnu-taler/taler-util";
+import { Loading } from "../../components/Loading.js";
+import { useComponentState } from "./state.js";
+import { ReadyView, InvalidIbanView } from "./views.js";
+import { VNode } from "preact";
+import { LoginForm } from "../LoginForm.js";
+import { ErrorLoading } from "../../components/ErrorLoading.js";
+
+export interface Props {
+ account: string;
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ goToBusinessAccount: () => void;
+ goToConfirmOperation: (id: string) => void;
+}
+
+export type State = State.Loading | State.LoadingError | State.Ready | State.InvalidIban | State.UserNotFound;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingError {
+ status: "loading-error";
+ error: HttpError<SandboxBackend.SandboxError>;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ account: string,
+ limit: AmountJson,
+ goToBusinessAccount: () => void;
+ goToConfirmOperation: (id: string) => void;
+ }
+
+ export interface InvalidIban {
+ status: "invalid-iban",
+ error: HttpResponseOk<SandboxBackend.CoreBank.AccountData>;
+ }
+
+ export interface UserNotFound {
+ status: "error-user-not-found",
+ error: HttpError<any>;
+ onRegister?: () => void;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ "error-user-not-found": LoginForm,
+ "invalid-iban": InvalidIbanView,
+ "loading-error": ErrorLoading,
+ ready: ReadyView,
+};
+
+export const AccountPage = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts
new file mode 100644
index 000000000..ca7e1d447
--- /dev/null
+++ b/packages/demobank-ui/src/pages/AccountPage/state.ts
@@ -0,0 +1,92 @@
+/*
+ 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 { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util";
+import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useBackendContext } from "../../context/backend.js";
+import { useAccountDetails } from "../../hooks/access.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({ account, goToBusinessAccount, goToConfirmOperation }: Props): State {
+ const result = useAccountDetails(account);
+ const backend = useBackendContext();
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ if (!result.ok) {
+ if (result.loading || result.type === ErrorType.TIMEOUT) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+ //logout if there is any error, not if loading
+ // backend.logOut();
+ if (result.status === HttpStatusCode.NotFound) {
+ notifyError(i18n.str`Username or account label "${account}" not found`, undefined);
+ return {
+ status: "error-user-not-found",
+ error: result,
+ };
+ }
+ if (result.status === HttpStatusCode.Unauthorized) {
+ notifyError(i18n.str`Authorization denied`, i18n.str`Maybe the session has expired, login again.`);
+ return {
+ status: "error-user-not-found",
+ error: result,
+ };
+ }
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+
+ const { data } = result;
+
+ const balance = Amounts.parseOrThrow(data.balance.amount);
+
+ const debitThreshold = Amounts.parseOrThrow(data.debit_threshold);
+ const payto = parsePaytoUri(data.payto_uri);
+
+ if (!payto || !payto.isKnown || (payto.targetType !== "iban" && payto.targetType !== "x-taler-bank")) {
+ return {
+ status: "invalid-iban",
+ error: result
+ };
+ }
+
+ const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+
+
+ return {
+ status: "ready",
+ goToBusinessAccount,
+ goToConfirmOperation,
+ error: undefined,
+ account,
+ limit,
+ };
+}
diff --git a/packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx b/packages/demobank-ui/src/pages/AccountPage/stories.tsx
index dacffe20a..f3828a5d6 100644
--- a/packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx
+++ b/packages/demobank-ui/src/pages/AccountPage/stories.tsx
@@ -14,16 +14,16 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Fragment, h, VNode } from "preact";
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "account page",
+};
-export function ShowInputErrorLabel({
- isDirty,
- message,
-}: {
- message: string | undefined;
- isDirty: boolean;
-}): VNode {
- if (message && isDirty)
- return <div style={{ marginTop: 8, color: "red" }}>{message}</div>;
- return <Fragment />;
-}
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/demobank-ui/src/pages/AccountPage/test.ts b/packages/demobank-ui/src/pages/AccountPage/test.ts
new file mode 100644
index 000000000..588b84c35
--- /dev/null
+++ b/packages/demobank-ui/src/pages/AccountPage/test.ts
@@ -0,0 +1,32 @@
+/*
+ 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { CASHOUT_API_EXAMPLE } from "../../endpoints.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+describe("Account states", () => {
+ it("should do some tests", async () => {
+ });
+});
diff --git a/packages/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx
new file mode 100644
index 000000000..483cb579a
--- /dev/null
+++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx
@@ -0,0 +1,93 @@
+/*
+ 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Attention } from "../../components/Attention.js";
+import { Transactions } from "../../components/Transactions/index.js";
+import { useBusinessAccountDetails } from "../../hooks/circuit.js";
+import { useSettings } from "../../hooks/settings.js";
+import { PaymentOptions } from "../PaymentOptions.js";
+import { State } from "./index.js";
+
+export function InvalidIbanView({ error }: State.InvalidIban) {
+ return (
+ <div>Payto from server is not valid &quot;{error.data.payto_uri}&quot;</div>
+ );
+}
+
+const IS_PUBLIC_ACCOUNT_ENABLED = false
+
+function ShowDemoInfo(): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings();
+ if (!settings.showDemoDescription) return <Fragment />
+ return <Attention title={i18n.str`This is a demo bank`} onClose={() => {
+ updateSettings("showDemoDescription", false);
+ }}>
+ {IS_PUBLIC_ACCOUNT_ENABLED ? (
+ <i18n.Translate>
+ This part of the demo shows how a bank that supports Taler
+ directly would work. In addition to using your own bank
+ account, you can also see the transaction history of some{" "}
+ <a href="/public-accounts">Public Accounts</a>.
+ </i18n.Translate>
+ ) : (
+ <i18n.Translate>
+ This part of the demo shows how a bank that supports Taler
+ directly would work.
+ </i18n.Translate>
+ )}
+ </Attention>
+}
+
+export function ReadyView({ account, limit, goToBusinessAccount, goToConfirmOperation }: State.Ready): VNode<{}> {
+ const { i18n } = useTranslationContext();
+
+ return <Fragment>
+ <MaybeBusinessButton account={account} onClick={goToBusinessAccount} />
+
+ <ShowDemoInfo />
+
+ <PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} />
+ <Transactions account={account} />
+ </Fragment>;
+}
+
+function MaybeBusinessButton({
+ account,
+ onClick,
+}: {
+ account: string;
+ onClick: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useBusinessAccountDetails(account);
+ if (!result.ok) return <Fragment />;
+ return (
+ <div class="w-full flex justify-end">
+ <button
+ 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"
+ onClick={(e) => {
+ e.preventDefault()
+ onClick()
+ }}
+ >
+ <i18n.Translate>Business Profile</i18n.Translate>
+ </button>
+ </div>
+ );
+}
diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx
deleted file mode 100644
index ce0feebce..000000000
--- a/packages/demobank-ui/src/pages/AdminPage.tsx
+++ /dev/null
@@ -1,1064 +0,0 @@
-/*
- 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 { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util";
-import {
- ErrorType,
- HttpResponsePaginated,
- RequestError,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { Cashouts } from "../components/Cashouts/index.js";
-import { useBackendContext } from "../context/backend.js";
-import { useAccountDetails } from "../hooks/access.js";
-import {
- useAdminAccountAPI,
- useBusinessAccountDetails,
- useBusinessAccounts,
-} from "../hooks/circuit.js";
-import {
- buildRequestErrorMessage,
- PartialButDefined,
- RecursivePartial,
- undefinedIfEmpty,
- validateIBAN,
- WithIntermediate,
-} from "../utils.js";
-import { ErrorBannerFloat } from "./BankFrame.js";
-import { ShowCashoutDetails } from "./BusinessAccount.js";
-import { handleNotOkResult } from "./HomePage.js";
-import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
-import { ErrorMessage, notifyInfo } from "../hooks/notification.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 {
- onRegister: () => void;
-}
-/**
- * Query account information and show QR code if there is pending withdrawal
- */
-export function AdminPage({ onRegister }: Props): VNode {
- const [account, setAccount] = useState<string | undefined>();
- const [showDetails, setShowDetails] = useState<string | undefined>();
- const [showCashouts, setShowCashouts] = useState<string | undefined>();
- const [updatePassword, setUpdatePassword] = useState<string | undefined>();
- const [removeAccount, setRemoveAccount] = useState<string | undefined>();
- const [showCashoutDetails, setShowCashoutDetails] = useState<
- string | undefined
- >();
-
- const [createAccount, setCreateAccount] = useState(false);
-
- const result = useBusinessAccounts({ account });
- const { i18n } = useTranslationContext();
-
- if (result.loading) return <div />;
- if (!result.ok) {
- return handleNotOkResult(i18n, onRegister)(result);
- }
-
- const { customers } = result.data;
-
- if (showCashoutDetails) {
- return (
- <ShowCashoutDetails
- id={showCashoutDetails}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onCancel={() => {
- setShowCashoutDetails(undefined);
- }}
- />
- );
- }
-
- if (showCashouts) {
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Cashout for account {showCashouts}</i18n.Translate>
- </h1>
- </div>
- <Cashouts
- account={showCashouts}
- onSelected={(id) => {
- setShowCashouts(id);
- setShowCashouts(undefined);
- }}
- />
- <p>
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Close`}
- onClick={async (e) => {
- e.preventDefault();
- setShowCashouts(undefined);
- }}
- />
- </p>
- </div>
- );
- }
-
- if (showDetails) {
- return (
- <ShowAccountDetails
- account={showDetails}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onChangePassword={() => {
- setUpdatePassword(showDetails);
- setShowDetails(undefined);
- }}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Account updated`);
- setShowDetails(undefined);
- }}
- onClear={() => {
- setShowDetails(undefined);
- }}
- />
- );
- }
- if (removeAccount) {
- return (
- <RemoveAccount
- account={removeAccount}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Account removed`);
- setRemoveAccount(undefined);
- }}
- onClear={() => {
- setRemoveAccount(undefined);
- }}
- />
- );
- }
- if (updatePassword) {
- return (
- <UpdateAccountPassword
- account={updatePassword}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Password changed`);
- setUpdatePassword(undefined);
- }}
- onClear={() => {
- setUpdatePassword(undefined);
- }}
- />
- );
- }
- if (createAccount) {
- return (
- <CreateNewAccount
- onClose={() => 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>
- <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>
-
- <AdminAccount onRegister={onRegister} />
- <section
- id="main"
- style={{ width: 600, marginLeft: "auto", marginRight: "auto" }}
- >
- {!customers.length ? (
- <div></div>
- ) : (
- <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>{i18n.str`Balance`}</th>
- <th>{i18n.str`Actions`}</th>
- </tr>
- </thead>
- <tbody>
- {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>
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setShowDetails(item.username);
- }}
- >
- {item.username}
- </a>
- </td>
- <td>{item.name}</td>
- <td>
- {!balance ? (
- i18n.str`unknown`
- ) : (
- <span class="amount">
- {balanceIsDebit ? <b>-</b> : null}
- <span class="value">{`${Amounts.stringifyValue(
- balance,
- )}`}</span>
- &nbsp;
- <span class="currency">{`${balance.currency}`}</span>
- </span>
- )}
- </td>
- <td>
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setUpdatePassword(item.username);
- }}
- >
- change password
- </a>
- &nbsp;
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setShowCashouts(item.username);
- }}
- >
- cashouts
- </a>
- &nbsp;
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setRemoveAccount(item.username);
- }}
- >
- remove
- </a>
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
- </article>
- )}
- </section>
- </Fragment>
- );
-}
-
-function AdminAccount({ onRegister }: { onRegister: () => void }): VNode {
- const { i18n } = useTranslationContext();
- const r = useBackendContext();
- const account = r.state.status === "loggedIn" ? r.state.username : "admin";
- const result = useAccountDetails(account);
-
- if (!result.ok) {
- return handleNotOkResult(i18n, onRegister)(result);
- }
- const { data } = result;
- const balance = Amounts.parseOrThrow(data.balance.amount);
- const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
- const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
- const limit = balanceIsDebit
- ? Amounts.sub(debitThreshold, balance).amount
- : Amounts.add(balance, debitThreshold).amount;
- if (!balance) return <Fragment />;
- return (
- <Fragment>
- <section id="assets">
- <div class="asset-summary">
- <h2>{i18n.str`Bank account balance`}</h2>
- {!balance ? (
- <div class="large-amount" style={{ color: "gray" }}>
- Waiting server response...
- </div>
- ) : (
- <div class="large-amount amount">
- {balanceIsDebit ? <b>-</b> : null}
- <span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
- &nbsp;
- <span class="currency">{`${balance.currency}`}</span>
- </div>
- )}
- </div>
- </section>
- <PaytoWireTransferForm
- focus
- limit={limit}
- onSuccess={() => {
- notifyInfo(i18n.str`Wire transfer created!`);
- }}
- />
- </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;
-}
-
-export function UpdateAccountPassword({
- account,
- onClear,
- onUpdateSuccess,
- onLoadNotOk,
-}: {
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
- onClear: () => void;
- onUpdateSuccess: () => void;
- account: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useBusinessAccountDetails(account);
- const { changePassword } = useAdminAccountAPI();
- const [password, setPassword] = useState<string | undefined>();
- const [repeat, setRepeat] = useState<string | undefined>();
- const [error, saveError] = useState<ErrorMessage | undefined>();
-
- 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 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>Update password for {account}</i18n.Translate>
- </h1>
- </div>
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
-
- <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
- <form class="pure-form">
- <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`Repeat 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) {
- if (error instanceof RequestError) {
- saveError(buildRequestErrorMessage(i18n, error.cause));
- } else {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
- }}
- />
- </div>
- </div>
- </p>
- </div>
- </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>New account</i18n.Translate>
- </h1>
- </div>
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
-
- <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
- <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) {
- if (error instanceof RequestError) {
- saveError(
- 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`Input data was invalid`
- : status === HttpStatusCode.Conflict
- ? i18n.str`At least one registration detail was not available`
- : undefined,
- }),
- );
- } else {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
- }}
- />
- </div>
- </div>
- </p>
- </div>
- </div>
- );
-}
-
-export function ShowAccountDetails({
- account,
- onClear,
- onUpdateSuccess,
- onLoadNotOk,
- onChangePassword,
-}: {
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
- onClear?: () => void;
- onChangePassword: () => void;
- onUpdateSuccess: () => void;
- account: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useBusinessAccountDetails(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.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);
- }
-
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Business account details</i18n.Translate>
- </h1>
- </div>
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
- <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
- <AccountForm
- template={result.data}
- purpose={update ? "update" : "show"}
- onChange={(a) => setSubmitAccount(a)}
- />
-
- <p class="buttons-account">
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- flexFlow: "wrap-reverse",
- }}
- >
- <div>
- {onClear ? (
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Close`}
- onClick={async (e) => {
- e.preventDefault();
- onClear();
- }}
- />
- ) : undefined}
- </div>
- <div style={{ display: "flex" }}>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={update && !submitAccount}
- type="submit"
- value={i18n.str`Change password`}
- onClick={async (e) => {
- e.preventDefault();
- onChangePassword();
- }}
- />
- </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) {
- if (error instanceof RequestError) {
- saveError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Forbidden
- ? i18n.str`The rights to change the account are not sufficient`
- : status === HttpStatusCode.NotFound
- ? i18n.str`The username was not found`
- : undefined,
- }),
- );
- } else {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
- }
- }}
- />
- </div>
- </div>
- </div>
- </p>
- </div>
- </div>
- );
-}
-
-function RemoveAccount({
- account,
- onClear,
- onUpdateSuccess,
- onLoadNotOk,
-}: {
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
- onClear: () => void;
- onUpdateSuccess: () => void;
- account: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useAccountDetails(account);
- const { deleteAccount } = useAdminAccountAPI();
- const [error, saveError] = useState<ErrorMessage | undefined>();
-
- 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);
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Remove account: {account}</i18n.Translate>
- </h1>
- </div>
- {!isBalanceEmpty && (
- <ErrorBannerFloat
- error={{
- title: i18n.str`Can't delete the account`,
- description: i18n.str`Balance is not empty`,
- }}
- onClear={() => saveError(undefined)}
- />
- )}
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
-
- <p>
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <div>
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Cancel`}
- onClick={async (e) => {
- e.preventDefault();
- onClear();
- }}
- />
- </div>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={!isBalanceEmpty}
- type="submit"
- value={i18n.str`Confirm`}
- onClick={async (e) => {
- e.preventDefault();
- try {
- const r = await deleteAccount(account);
- onUpdateSuccess();
- } catch (error) {
- if (error instanceof RequestError) {
- saveError(
- 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 {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
- }}
- />
- </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<
- RecursivePartial<typeof initial> | undefined
- >(undefined);
- const { i18n } = useTranslationContext();
-
- function updateForm(newForm: typeof initial): void {
- const parsed = !newForm.cashout_address
- ? undefined
- : parsePaytoUri(newForm.cashout_address);
-
- 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="pure-form">
- <fieldset>
- <label for="username">
- {i18n.str`Username`}
- {purpose === "create" && <b style={{ color: "red" }}>*</b>}
- </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`}
- {purpose === "create" && <b style={{ color: "red" }}>*</b>}
- </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>
- {purpose !== "create" && (
- <fieldset>
- <label>{i18n.str`Internal IBAN`}</label>
- <input
- disabled={true}
- 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`}
- {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
- </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`}
- {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
- </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`}
- {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
- </label>
- <input
- disabled={purpose === "show"}
- value={(form.cashout_address ?? "").substring("payto://iban/".length)}
- onChange={(e) => {
- form.cashout_address = "payto://iban/" + e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.cashout_address}
- isDirty={form.cashout_address !== undefined}
- />
- </fieldset>
- </form>
- );
-}
diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx
index dc61f1302..6ab6ba3e4 100644
--- a/packages/demobank-ui/src/pages/BankFrame.tsx
+++ b/packages/demobank-ui/src/pages/BankFrame.tsx
@@ -14,283 +14,362 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { Logger, TranslatedString } from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { ComponentChildren, Fragment, h, VNode } from "preact";
-import { StateUpdater, useEffect, useState } from "preact/hooks";
-import talerLogo from "../assets/logo-white.svg";
-import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js";
+import { Amounts, Logger, TranslatedString, parsePaytoUri } from "@gnu-taler/taler-util";
+import { notifyError, notifyException, useNotifications, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useEffect, useErrorBoundary, useState } from "preact/hooks";
+import logo from "../assets/logo-2021.svg";
+import { Attention } from "../components/Attention.js";
+import { CopyButton } from "../components/CopyButton.js";
+import { LangSelector } from "../components/LangSelector.js";
import { useBackendContext } from "../context/backend.js";
-import { useBusinessAccountDetails } from "../hooks/circuit.js";
-import { bankUiSettings } from "../settings.js";
+import { useAccountDetails } from "../hooks/access.js";
import { useSettings } from "../hooks/settings.js";
-import { ErrorMessage, onNotificationUpdate } from "../hooks/notification.js";
+import { bankUiSettings } from "../settings.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
-const IS_PUBLIC_ACCOUNT_ENABLED = false;
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
const versionText = VERSION
? GIT_HASH
- ? `Version ${VERSION} (${GIT_HASH.substring(0, 8)})`
+ ? <a href={`https://git.taler.net/wallet-core.git/tree/?id=${GIT_HASH}`} target="_blank" rel="noreferrer noopener">
+ Version {VERSION} ({GIT_HASH.substring(0, 8)})
+ </a>
: VERSION
: "";
-const logger = new Logger("BankFrame");
-
-function MaybeBusinessButton({
- account,
- onClick,
-}: {
- account: string;
- onClick: () => void;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useBusinessAccountDetails(account);
- if (!result.ok) return <Fragment />;
- return (
- <a
- href="#"
- class="pure-button pure-button-primary"
- onClick={(e) => {
- e.preventDefault();
- onClick();
- }}
- >{i18n.str`Business Profile`}</a>
- );
-}
export function BankFrame({
children,
- goToBusinessAccount,
+ account,
}: {
+ account?: string,
children: ComponentChildren;
- goToBusinessAccount?: () => void;
}): VNode {
const { i18n } = useTranslationContext();
const backend = useBackendContext();
const [settings, updateSettings] = useSettings();
+ const [open, setOpen] = useState(false)
- const demo_sites = [];
- for (const i in bankUiSettings.demoSites)
- demo_sites.push(
- <a href={bankUiSettings.demoSites[i][1]}>
- {bankUiSettings.demoSites[i][0]}
- </a>,
- );
+ const [error, resetError] = useErrorBoundary();
- return (
- <Fragment>
- <header
- class="demobar"
- style="display: flex; flex-direction: row; justify-content: space-between;"
- >
- <a href="#main" class="skip">{i18n.str`Skip to main content`}</a>
- <div style="max-width: 50em; margin-left: 2em; margin-right: 2em;">
- <h1>
- <span class="it">
- <a href="/">{bankUiSettings.bankName}</a>
- </span>
- </h1>
- {maybeDemoContent(
- <p>
- {IS_PUBLIC_ACCOUNT_ENABLED ? (
- <i18n.Translate>
- This part of the demo shows how a bank that supports Taler
- directly would work. In addition to using your own bank
- account, you can also see the transaction history of some{" "}
- <a href="/public-accounts">Public Accounts</a>.
- </i18n.Translate>
- ) : (
- <i18n.Translate>
- This part of the demo shows how a bank that supports Taler
- directly would work.
- </i18n.Translate>
- )}
- </p>,
- )}
- </div>
- </header>
- <div style="display:flex; flex-direction: column;" class="navcontainer">
- <nav class="demolist">
- {maybeDemoContent(<Fragment>{demo_sites}</Fragment>)}
- {backend.state.status === "loggedIn" ? (
- <Fragment>
- {goToBusinessAccount && !backend.state.isUserAdministrator ? (
- <MaybeBusinessButton
- account={backend.state.username}
- onClick={goToBusinessAccount}
- />
- ) : undefined}
-
- <LangSelector />
-
- <a
- href="#"
- class="pure-button logout-button"
- onClick={() => {
- backend.logOut();
- updateSettings("currentWithdrawalOperationId", undefined);
- }}
- >{i18n.str`Logout`}</a>
- </Fragment>
- ) : undefined}
- </nav>
- </div>
- <section id="main" class="content">
- <StatusBanner />
- {children}
- </section>
- <section id="footer" class="footer">
- <hr />
- <div>
- <p>
- You can learn more about GNU Taler on our{" "}
- <a href="https://taler.net">main website</a>.
- </p>
- </div>
- <div style="flex-grow:1" />
- <p>
- Copyright &copy; 2014&mdash;2022 Taler Systems SA. {versionText}{" "}
- <TestingTag />
- </p>
- </section>
- </Fragment>
- );
-}
+ useEffect(() => {
+ if (error) {
+ const desc = (error instanceof Error ? error.stack : String(error)) as TranslatedString
+ if (error instanceof Error) {
+ notifyException(i18n.str`Internal error, please report.`, error)
+ } else {
+ notifyError(i18n.str`Internal error, please report.`, String(error) as TranslatedString)
+ }
+ resetError()
+ }
+ }, [error])
-function maybeDemoContent(content: VNode): VNode {
- if (bankUiSettings.showDemoNav) {
- return content;
+ const demo_sites = [];
+ if (bankUiSettings.demoSites) {
+ for (const i in bankUiSettings.demoSites)
+ demo_sites.push(
+ <a href={bankUiSettings.demoSites[i][1]}>
+ {bankUiSettings.demoSites[i][0]}
+ </a>,
+ );
}
- return <Fragment />;
-}
-export function ErrorBannerFloat({
- error,
- onClear,
-}: {
- error: ErrorMessage;
- onClear?: () => void;
-}): VNode {
- return (
- <div
- style={{
- position: "fixed",
- top: 10,
- zIndex: 200,
- width: "90%",
- }}
- >
- <ErrorBanner error={error} onClear={onClear} />
- </div>
- );
-}
+ return (<div class="min-h-full flex flex-col m-0" style="min-height: 100vh;">
+ <div class="bg-indigo-600 pb-32">
+ <nav class="">
+ <div class="mx-auto max-w-7xl px-2 sm:px-4 lg:px-8">
+ <div class="relative flex h-16 items-center justify-between ">
+ <div class="flex items-center px-2 lg:px-0">
+ <div class="flex-shrink-0 bg-white rounded-lg">
+ <a href={bankUiSettings.iconLinkURL ?? "#"}>
+ <img
+ class="h-8 w-auto"
+ src={logo}
+ alt="Taler"
+ style={{ height: "1.5rem", margin: ".5rem" }}
+ />
+ </a>
+ </div>
+ {bankUiSettings.demoSites &&
+ <div class="hidden sm:block lg:ml-10 ">
+ <div class="flex space-x-4">
+ {/* <!-- Current: "bg-indigo-700 text-white", Default: "text-white hover:bg-indigo-500 hover:bg-opacity-75" --> */}
+ {bankUiSettings.demoSites.map(([name, url]) => {
+ return <a href={url} class="text-white hover:bg-indigo-500 hover:bg-opacity-75 rounded-md py-2 px-3 text-sm font-medium">{name}</a>
+ })}
+ </div>
+ </div>
+ }
+ </div>
-function ErrorBanner({
- error,
- onClear,
-}: {
- error: ErrorMessage;
- onClear?: () => void;
-}): VNode {
- return (
- <div
- class="informational informational-fail"
- style={{
- marginTop: 8,
- paddingLeft: 16,
- paddingRight: 16,
- }}
- >
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <p>
- <b>{error.title}</b>
- </p>
- <div style={{ marginTop: "auto", marginBottom: "auto" }}>
- {onClear && (
- <input
- type="button"
- class="pure-button"
- value="Clear"
- onClick={(e) => {
- e.preventDefault();
- onClear();
- }}
- />
- )}
+ <div class="flex">
+ <button type="button" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 p-1 text-indigo-200 hover:bg-indigo-500 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600" aria-controls="mobile-menu" aria-expanded="false"
+ onClick={(e) => {
+ setOpen(!open)
+ }}>
+ <span class="absolute -inset-0.5"></span>
+ <span class="sr-only">Open settings</span>
+ <svg class="block h-10 w-10" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
+ </svg>
+ </button>
+ </div>
+ </div>
</div>
- </div>
- <p>{error.description}</p>
- </div>
- );
-}
-function StatusBanner(): VNode | null {
- const [info, setInfo] = useState<TranslatedString>();
- const [error, setError] = useState<ErrorMessage>();
- useEffect(() => {
- return onNotificationUpdate((newValue) => {
- if (newValue === undefined) {
- setInfo(undefined);
- setError(undefined);
- } else {
- if (newValue.type === "error") {
- setError(newValue.error);
- } else {
- setInfo(newValue.info);
+ {open &&
+ <div class="relative z-10" aria-labelledby="slide-over-title" role="dialog" aria-modal="true"
+ onClick={() => {
+ setOpen(false)
+ }}>
+ <div class="fixed inset-0"></div>
+
+ <div class="fixed inset-0 overflow-hidden">
+ <div class="absolute inset-0 overflow-hidden">
+ <div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
+ <div class="pointer-events-auto w-screen max-w-md" >
+ <div class="flex h-full flex-col overflow-y-scroll bg-white py-6 shadow-xl" onClick={(e) => {
+ //do not trigger close if clicking inside the sidebar
+ e.stopPropagation();
+ }}>
+ <div class="px-4 sm:px-6" >
+ <div class="flex items-start justify-between" >
+ <h2 class="text-base font-semibold leading-6 text-gray-900" id="slide-over-title">
+ <i18n.Translate>Preferences</i18n.Translate>
+ </h2>
+ <div class="ml-3 flex h-7 items-center">
+ <button type="button" class="relative rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
+ onClick={(e) => {
+ setOpen(false)
+ }}
+
+ >
+ <span class="absolute -inset-2.5"></span>
+ <span class="sr-only">
+ <i18n.Translate>Close panel</i18n.Translate>
+ </span>
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="relative mt-6 flex-1 px-4 sm:px-6">
+ <nav class="flex flex-1 flex-col" aria-label="Sidebar">
+ <ul role="list" class="flex flex-1 flex-col gap-y-7">
+ <li>
+ <a href="#"
+ class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
+ onClick={() => {
+ backend.logOut();
+ setOpen(false)
+ updateSettings("currentWithdrawalOperationId", undefined);
+ }}
+ >
+ <svg class="h-6 w-6 shrink-0 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
+ </svg>
+ <i18n.Translate>Log out</i18n.Translate>
+ </a>
+ </li>
+ <li>
+ <LangSelector />
+ </li>
+ {bankUiSettings.demoSites &&
+ <li class="sm:hidden">
+ <div class="text-xs font-semibold leading-6 text-gray-400">
+ <i18n.Translate>Sites</i18n.Translate>
+ </div>
+ <ul role="list" class="-mx-2 mt-2 space-y-1">
+ {bankUiSettings.demoSites.map(([name, url]) => {
+ return <li>
+ <a href={url} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
+ <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">&gt;</span>
+ <span class="truncate">{name}</span>
+ </a>
+ </li>
+ })}
+ </ul>
+ </li>
+ }
+ <li>
+ <ul role="list" class="space-y-1">
+ <li class="mt-2">
+ <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>Show withdrawal confirmation</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={settings.showWithdrawalSuccess} 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={() => {
+ updateSettings("showWithdrawalSuccess", !settings.showWithdrawalSuccess);
+ }}>
+ <span aria-hidden="true" data-enabled={settings.showWithdrawalSuccess} 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>
+ </li>
+ <li class="mt-2">
+ <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>Show demo description</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={settings.showDemoDescription} 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={() => {
+ updateSettings("showDemoDescription", !settings.showDemoDescription);
+ }}>
+ <span aria-hidden="true" data-enabled={settings.showDemoDescription} 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>
+ </li>
+ <li class="mt-2">
+ <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>Show debug info</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={settings.showDebugInfo} 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={() => {
+ updateSettings("showDebugInfo", !settings.showDebugInfo);
+ }}>
+ <span aria-hidden="true" data-enabled={settings.showDebugInfo} 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>
+ </li>
+ <li class="mt-2">
+ <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>Show install wallet first</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={settings.showInstallWallet} 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={() => {
+ updateSettings("showInstallWallet", !settings.showInstallWallet);
+ }}>
+ <span aria-hidden="true" data-enabled={settings.showInstallWallet} 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>
+ </li>
+ <li class="mt-2">
+ <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>Use fast withdrawal</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={settings.fastWithdrawal} 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={() => {
+ updateSettings("fastWithdrawal", !settings.fastWithdrawal);
+ }}>
+ <span aria-hidden="true" data-enabled={settings.fastWithdrawal} 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>
+ </li>
+ </ul>
+ </li>
+ </ul>
+ </nav>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
}
- }
- });
- }, []);
- return (
- <div
- style={{
- position: "fixed",
- top: 10,
- zIndex: 200,
- width: "90%",
- }}
- >
- {!info ? undefined : (
- <div
- class="informational informational-ok"
- style={{ marginTop: 8, paddingLeft: 16, paddingRight: 16 }}
- >
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <p>
- <b>{info}</b>
- </p>
- <div>
- <input
- type="button"
- class="pure-button"
- value="Clear"
- onClick={async () => {
- setInfo(undefined);
- }}
- />
+ </nav >
+
+ {account &&
+ <header class="py-5 border-t border-indigo-300 border-opacity-25 bg-indigo-600 lg:border-t lg:border-indigo-400 lg:border-opacity-25">
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
+ <div class=" flex flex-wrap items-center justify-between sm:flex-nowrap">
+ <h3 class="text-2xl font-bold tracking-tight text-white"><WelcomeAccount account={account} /></h3>
+ <div>
+ <h3 class="text-2xl font-bold tracking-tight text-white"><AccountBalance account={account} /></h3>
+ </div>
</div>
</div>
+
+ </header>
+ }
+ </div >
+
+ <StatusBanner />
+ <main class="-mt-32 flex-1">
+ <div class="mx-auto max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
+ <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6">
+ {children}
</div>
- )}
- {!error ? undefined : (
- <ErrorBanner
- error={error}
- onClear={() => {
- setError(undefined);
- }}
- />
- )}
- </div>
+ </div>
+ </main>
+
+ <Footer />
+ </div >
+
);
}
+function MaybeShowDebugInfo({ info }: { info: any }): VNode {
+ const [settings] = useSettings()
+ if (settings.showDebugInfo) {
+ return <pre class="whitespace-break-spaces ">
+ {info}
+ </pre>
+ }
+ return <Fragment />
+}
+
+
+function StatusBanner(): VNode {
+ const notifs = useNotifications()
+ if (notifs.length === 0) return <Fragment />
+ return <div class="fixed z-20 w-full p-4"> {
+ notifs.map(n => {
+ switch (n.message.type) {
+ case "error":
+ return <Attention type="danger" title={n.message.title} onClose={() => {
+ n.remove()
+ }}>
+ {n.message.description &&
+ <div class="mt-2 text-sm text-red-700">
+ {n.message.description}
+ </div>
+ }
+ <MaybeShowDebugInfo info={n.message.debug} />
+ {/* <a href="#" class="text-gray-500">
+ show debug info
+ </a>
+ {n.message.debug &&
+ <div class="mt-2 text-sm text-red-700 font-mono break-all">
+ {n.message.debug}
+ </div>
+ } */}
+ </Attention>
+ case "info":
+ return <Attention type="success" title={n.message.title} onClose={() => {
+ n.remove();
+ }} />
+ }
+ })}
+ </div>
+
+}
+
function TestingTag(): VNode {
const testingUrl = localStorage.getItem("bank-base-url");
if (!testingUrl) return <Fragment />;
return (
- <span style={{ color: "gray" }}>
+ <p class="text-xs leading-5 text-gray-300">
Testing with {testingUrl}{" "}
<a
href=""
@@ -302,6 +381,58 @@ function TestingTag(): VNode {
>
stop testing
</a>
- </span>
+ </p>
+ );
+}
+
+function Footer() {
+ const { i18n } = useTranslationContext()
+ return (
+ <footer class="bottom-4 mb-4">
+ <div class="mt-8 mx-8 md:order-1 md:mt-0">
+ <div>
+ <p class="text-xs leading-5 text-gray-400">
+ <i18n.Translate>
+ Learn more about <a target="_blank" rel="noreferrer noopener" class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a>
+ </i18n.Translate>
+ </p>
+ </div>
+ <div style="flex-grow:1" />
+ <p class="text-xs leading-5 text-gray-400">
+ Copyright &copy; 2014&mdash;2023 Taler Systems SA. {versionText}{" "}
+ <TestingTag />
+ </p>
+ </div>
+ </footer>
);
}
+
+function WelcomeAccount({ account }: { account: string }): VNode {
+ const { i18n } = useTranslationContext();
+
+ const result = useAccountDetails(account);
+ if (!result.ok) return <div />
+
+ const payto = parsePaytoUri(result.data.payto_uri)
+ if (!payto) return <div />
+
+ const accountNumber = !payto.isKnown ? undefined : payto.targetType === "iban" ? payto.iban : payto.targetType === "x-taler-bank" ? payto.account : undefined;
+ return <i18n.Translate>
+ Welcome, {account} {accountNumber !== undefined ?
+ <span>
+ (<a href={result.data.payto_uri}>{accountNumber}</a> <CopyButton getContent={() => result.data.payto_uri} />)
+ </span>
+ : <Fragment />}!
+ </i18n.Translate>
+
+}
+
+function AccountBalance({ account }: { account: string }): VNode {
+ const result = useAccountDetails(account);
+ if (!result.ok) return <div />
+
+ return <RenderAmount
+ value={Amounts.parseOrThrow(result.data.balance.amount)}
+ negative={result.data.balance.credit_debit_indicator === "debit"}
+ />
+}
diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx
index 93a9bdfae..95144f086 100644
--- a/packages/demobank-ui/src/pages/HomePage.tsx
+++ b/packages/demobank-ui/src/pages/HomePage.tsx
@@ -17,6 +17,7 @@
import {
HttpStatusCode,
Logger,
+ TranslatedString,
parseWithdrawUri,
stringifyWithdrawUri,
} from "@gnu-taler/taler-util";
@@ -24,18 +25,18 @@ import {
ErrorType,
HttpResponse,
HttpResponsePaginated,
+ notify,
+ notifyError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { Loading } from "../components/Loading.js";
-import { useBackendContext } from "../context/backend.js";
import { getInitialBackendBaseURL } from "../hooks/backend.js";
-import { notifyError, notifyInfo } from "../hooks/notification.js";
import { useSettings } from "../hooks/settings.js";
-import { AccountPage } from "./AccountPage.js";
-import { AdminPage } from "./AdminPage.js";
+import { AccountPage } from "./AccountPage/index.js";
import { LoginForm } from "./LoginForm.js";
import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
+import { route } from "preact-router";
const logger = new Logger("AccountPage");
@@ -51,73 +52,66 @@ const logger = new Logger("AccountPage");
*/
export function HomePage({
onRegister,
- onPendingOperationFound,
+ account,
+ goToConfirmOperation,
+ goToBusinessAccount,
}: {
- onPendingOperationFound: (id: string) => void;
+ account: string,
onRegister: () => void;
+ goToBusinessAccount: () => void;
+ goToConfirmOperation: (id: string) => void;
}): VNode {
- const backend = useBackendContext();
- const [settings] = useSettings();
const { i18n } = useTranslationContext();
- if (backend.state.status === "loggedOut") {
- return <LoginForm onRegister={onRegister} />;
- }
-
- if (settings.currentWithdrawalOperationId) {
- onPendingOperationFound(settings.currentWithdrawalOperationId);
- return <Loading />;
- }
-
- if (backend.state.isUserAdministrator) {
- return <AdminPage onRegister={onRegister} />;
- }
-
return (
<AccountPage
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ account={account}
+ goToConfirmOperation={goToConfirmOperation}
+ goToBusinessAccount={goToBusinessAccount}
+ onLoadNotOk={handleNotOkResult(i18n)}
/>
);
}
export function WithdrawalOperationPage({
operationId,
- onLoadNotOk,
onContinue,
}: {
operationId: string;
- onLoadNotOk: () => void;
onContinue: () => void;
}): VNode {
//FIXME: libeufin sandbox should return show to create the integration api endpoint
//or return withdrawal uri from response
+ const baseUrl = getInitialBackendBaseURL()
const uri = stringifyWithdrawUri({
- bankIntegrationApiBaseUrl: `${getInitialBackendBaseURL()}/integration-api`,
+ bankIntegrationApiBaseUrl: `${baseUrl}/taler-integration`,
withdrawalOperationId: operationId,
});
const parsedUri = parseWithdrawUri(uri);
const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings();
if (!parsedUri) {
- notifyError({
- title: i18n.str`The Withdrawal URI is not valid: "${uri}"`,
- });
+ notifyError(
+ i18n.str`The Withdrawal URI is not valid`,
+ uri as TranslatedString
+ );
return <Loading />;
}
return (
<WithdrawalQRCode
withdrawUri={parsedUri}
- onContinue={onContinue}
- onLoadNotOk={onLoadNotOk}
+ onClose={() => {
+ updateSettings("currentWithdrawalOperationId", undefined)
+ onContinue()
+ }}
/>
);
}
export function handleNotOkResult(
i18n: ReturnType<typeof useTranslationContext>["i18n"],
- onRegister?: () => void,
): <T>(
result:
| HttpResponsePaginated<T, SandboxBackend.SandboxError>
@@ -125,53 +119,53 @@ export function handleNotOkResult(
) => VNode {
return function handleNotOkResult2<T>(
result:
- | HttpResponsePaginated<T, SandboxBackend.SandboxError>
- | HttpResponse<T, SandboxBackend.SandboxError>,
+ | HttpResponsePaginated<T, SandboxBackend.SandboxError | undefined>
+ | HttpResponse<T, SandboxBackend.SandboxError | undefined>,
): VNode {
if (result.loading) return <Loading />;
if (!result.ok) {
switch (result.type) {
case ErrorType.TIMEOUT: {
- notifyError({
- title: i18n.str`Request timeout, try again later.`,
- });
+ notifyError(i18n.str`Request timeout, try again later.`, undefined);
break;
}
case ErrorType.CLIENT: {
if (result.status === HttpStatusCode.Unauthorized) {
- notifyError({
- title: i18n.str`Wrong credentials`,
- });
- return <LoginForm onRegister={onRegister} />;
+ notifyError(i18n.str`Wrong credentials`, undefined);
+ return <LoginForm />;
}
const errorData = result.payload;
- notifyError({
- title: i18n.str`Could not load due to a client error`,
- description: errorData.error.description,
+ notify({
+ type: "error",
+ title: i18n.str`Could not load due to a request error`,
+ description: i18n.str`Request to url "${result.info.url}" returned ${result.info.status}`,
debug: JSON.stringify(result),
});
break;
}
case ErrorType.SERVER: {
- notifyError({
+ notify({
+ type: "error",
title: i18n.str`Server returned with error`,
- description: result.payload.error.description,
+ description: result.payload?.error?.description as TranslatedString,
debug: JSON.stringify(result.payload),
});
break;
}
case ErrorType.UNREADABLE: {
- notifyError({
+ notify({
+ type: "error",
title: i18n.str`Unexpected error.`,
- description: `Response from ${result.info?.url} is unreadable, http status: ${result.status}`,
+ description: i18n.str`Response from ${result.info?.url} is unreadable, http status: ${result.status}`,
debug: JSON.stringify(result),
});
break;
}
case ErrorType.UNEXPECTED: {
- notifyError({
+ notify({
+ type: "error",
title: i18n.str`Unexpected error.`,
- description: `Diagnostic from ${result.info?.url} is "${result.message}"`,
+ description: i18n.str`Diagnostic from ${result.info?.url} is "${result.message}"`,
debug: JSON.stringify(result),
});
break;
@@ -180,7 +174,7 @@ export function handleNotOkResult(
assertUnreachable(result);
}
}
-
+ // route("/")
return <div>error</div>;
}
return <div />;
diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx
index d2cb1bd8e..3ea94b899 100644
--- a/packages/demobank-ui/src/pages/LoginForm.tsx
+++ b/packages/demobank-ui/src/pages/LoginForm.tsx
@@ -14,199 +14,249 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
-import { HttpStatusCode } from "@gnu-taler/taler-util";
-import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { ErrorType, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
+import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { useBackendContext } from "../context/backend.js";
-import { useCredentialsChecker } from "../hooks/backend.js";
-import { ErrorMessage } from "../hooks/notification.js";
+import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js";
import { bankUiSettings } from "../settings.js";
import { undefinedIfEmpty } from "../utils.js";
-import { ErrorBannerFloat } from "./BankFrame.js";
-import { USERNAME_REGEX } from "./RegistrationPage.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
+import { doAutoFocus } from "./PaytoWireTransferForm.js";
+
/**
* Collect and submit login data.
*/
export function LoginForm({ onRegister }: { onRegister?: () => void }): VNode {
const backend = useBackendContext();
- const [username, setUsername] = useState<string | undefined>();
+ const currentUser = backend.state.status !== "loggedOut" ? backend.state.username : undefined
+ const [username, setUsername] = useState<string | undefined>(currentUser);
const [password, setPassword] = useState<string | undefined>();
const { i18n } = useTranslationContext();
- const testLogin = useCredentialsChecker();
- const [error, saveError] = useState<ErrorMessage | undefined>();
+ const { requestNewLoginToken, refreshLoginToken } = useCredentialsChecker();
+
+
+ /**
+ * Register form may be shown in the initialization step.
+ * If this is an error when usgin the app the registration
+ * callback is not set
+ */
+ const isSessionExpired = !onRegister
+
+ // useEffect(() => {
+ // if (backend.state.status === "loggedIn") {
+ // backend.expired()
+ // }
+ // },[])
const ref = useRef<HTMLInputElement>(null);
useEffect(function focusInput() {
+ //FIXME: show invalidate session and allow relogin
+ if (isSessionExpired) {
+ localStorage.removeItem("backend-state");
+ window.location.reload()
+ }
ref.current?.focus();
}, []);
+ const [busy, setBusy] = useState<Record<string, undefined>>()
const errors = undefinedIfEmpty({
username: !username
? i18n.str`Missing username`
- : !USERNAME_REGEX.test(username)
- ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
+ // : !USERNAME_REGEX.test(username)
+ // ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
: undefined,
password: !password ? i18n.str`Missing password` : undefined,
- });
+ }) ?? busy;
+
+ function saveError({ title, description, debug }: { title: TranslatedString, description?: TranslatedString, debug?: any }) {
+ notifyError(title, description, debug)
+ }
+
+ async function doLogout() {
+ backend.logOut()
+ }
+
+ async function doLogin() {
+ if (!username || !password) return;
+ setBusy({})
+ const result = await requestNewLoginToken(username, password);
+ if (result.valid) {
+ backend.logIn({ username, token: result.token });
+ } else {
+ const { cause } = result;
+ switch (cause.type) {
+ case ErrorType.CLIENT: {
+ if (cause.status === HttpStatusCode.Unauthorized) {
+ saveError({
+ title: i18n.str`Wrong credentials for "${username}"`,
+ });
+ } else
+ if (cause.status === HttpStatusCode.NotFound) {
+ saveError({
+ title: i18n.str`Account not found`,
+ });
+ } else {
+ saveError({
+ title: i18n.str`Could not load due to a request error`,
+ description: i18n.str`Request to url "${cause.info.url}" returned ${cause.info.status}`,
+ debug: JSON.stringify(cause.payload),
+ });
+ }
+ break;
+ }
+ case ErrorType.SERVER: {
+ saveError({
+ title: i18n.str`Server had a problem, try again later or report.`,
+ // description: cause.payload.error.description,
+ debug: JSON.stringify(cause.payload),
+ });
+ break;
+ }
+ case ErrorType.TIMEOUT: {
+ saveError({
+ title: i18n.str`Request timeout, try again later.`,
+ });
+ break;
+ }
+ case ErrorType.UNREADABLE: {
+ saveError({
+ title: i18n.str`Unexpected error.`,
+ description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}` as TranslatedString,
+ debug: JSON.stringify(cause),
+ });
+ break;
+ }
+ default: {
+ saveError({
+ title: i18n.str`Unexpected error, please report.`,
+ description: `Diagnostic from ${cause.info?.url} is "${cause.message}"` as TranslatedString,
+ debug: JSON.stringify(cause),
+ });
+ break;
+ }
+ }
+ // backend.logOut();
+ }
+ setPassword(undefined);
+ setBusy(undefined)
+ }
return (
- <Fragment>
- <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
- <div class="login-div">
- <form
- class="login-form"
- noValidate
+ <div class="flex min-h-full flex-col justify-center">
+
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
+ <form class="space-y-6" noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
- <div class="pure-form">
- <h2>{i18n.str`Please login!`}</h2>
- <p class="unameFieldLabel loginFieldLabel formFieldLabel">
- <label for="username">{i18n.str`Username:`}</label>
- </p>
- <input
- ref={ref}
- autoFocus
- type="text"
- name="username"
- id="username"
- value={username ?? ""}
- enterkeyhint="next"
- placeholder="Username"
- autocomplete="username"
- required
- onInput={(e): void => {
- setUsername(e.currentTarget.value);
+ <div>
+ <label for="username" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Username</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ ref={doAutoFocus}
+ type="text"
+ name="username"
+ id="username"
+ class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={username ?? ""}
+ disabled={isSessionExpired}
+ enterkeyhint="next"
+ placeholder="identification"
+ autocomplete="username"
+ required
+ onInput={(e): void => {
+ setUsername(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={username !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label for="password" class="block text-sm font-medium leading-6 text-gray-900">Password</label>
+ </div>
+ <div class="mt-2">
+ <input
+ type="password"
+ name="password"
+ id="password"
+ autocomplete="current-password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ enterkeyhint="send"
+ value={password ?? ""}
+ placeholder="Password"
+ required
+ onInput={(e): void => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </div>
+
+ {isSessionExpired ? <div class="flex justify-between">
+ <button type="submit"
+ class="rounded-md bg-white-600 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
+ onClick={(e) => {
+ e.preventDefault()
+ doLogout()
}}
- />
- <ShowInputErrorLabel
- message={errors?.username}
- isDirty={username !== undefined}
- />
- <p class="passFieldLabel loginFieldLabel formFieldLabel">
- <label for="password">{i18n.str`Password:`}</label>
- </p>
- <input
- type="password"
- name="password"
- id="password"
- autocomplete="current-password"
- enterkeyhint="send"
- value={password ?? ""}
- placeholder="Password"
- required
- onInput={(e): void => {
- setPassword(e.currentTarget.value);
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <button type="submit"
+ class="rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 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={!!errors}
+ onClick={(e) => {
+ e.preventDefault()
+ doLogin()
}}
- />
- <ShowInputErrorLabel
- message={errors?.password}
- isDirty={password !== undefined}
- />
- <br />
- <button
- type="submit"
- class="pure-button pure-button-primary"
+ >
+ <i18n.Translate>Renew session</i18n.Translate>
+ </button>
+ </div> : <div>
+ <button type="submit"
+ class="flex w-full justify-center rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 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={!!errors}
- onClick={async (e) => {
- e.preventDefault();
- if (!username || !password) return;
- const testResult = await testLogin(username, password);
- if (testResult.valid) {
- backend.logIn({ username, password });
- } else {
- if (testResult.requestError) {
- const { cause } = testResult;
- switch (cause.type) {
- case ErrorType.CLIENT: {
- if (cause.status === HttpStatusCode.Unauthorized) {
- saveError({
- title: i18n.str`Wrong credentials for "${username}"`,
- });
- }
- if (cause.status === HttpStatusCode.NotFound) {
- saveError({
- title: i18n.str`Account not found`,
- });
- } else {
- saveError({
- title: i18n.str`Could not load due to a client error`,
- description: cause.payload.error.description,
- debug: JSON.stringify(cause.payload),
- });
- }
- break;
- }
- case ErrorType.SERVER: {
- saveError({
- title: i18n.str`Server had a problem, try again later or report.`,
- description: cause.payload.error.description,
- debug: JSON.stringify(cause.payload),
- });
- break;
- }
- case ErrorType.TIMEOUT: {
- saveError({
- title: i18n.str`Request timeout, try again later.`,
- });
- break;
- }
- case ErrorType.UNREADABLE: {
- saveError({
- title: i18n.str`Unexpected error.`,
- description: `Response from ${cause.info?.url} is unreadable, http status: ${cause.status}`,
- debug: JSON.stringify(cause),
- });
- break;
- }
- default: {
- saveError({
- title: i18n.str`Unexpected error, please report.`,
- description: `Diagnostic from ${cause.info?.url} is "${cause.message}"`,
- debug: JSON.stringify(cause),
- });
- break;
- }
- }
- } else {
- saveError({
- title: i18n.str`Unexpected error, please report.`,
- debug: JSON.stringify(testResult.error),
- });
- }
- backend.logOut();
- }
- setUsername(undefined);
- setPassword(undefined);
+ onClick={(e) => {
+ e.preventDefault()
+ doLogin()
}}
>
- {i18n.str`Login`}
+ <i18n.Translate>Log in</i18n.Translate>
</button>
-
- {bankUiSettings.allowRegistrations && onRegister ? (
- <button
- class="pure-button pure-button-secondary btn-cancel"
- onClick={(e) => {
- e.preventDefault();
- onRegister();
- }}
- >
- {i18n.str`Register`}
- </button>
- ) : (
- <div />
- )}
- </div>
+ </div>}
</form>
+
+ {bankUiSettings.allowRegistrations && onRegister &&
+ <p class="mt-10 text-center text-sm text-gray-500 border-t">
+ <button type="submit"
+ class="flex mt-4 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
+ onClick={(e) => {
+ e.preventDefault()
+ onRegister()
+ }}
+ >
+ <i18n.Translate>Register</i18n.Translate>
+ </button>
+ </p>
+ }
</div>
- </Fragment>
+ </div>
);
}
diff --git a/packages/demobank-ui/src/pages/OperationState/index.ts b/packages/demobank-ui/src/pages/OperationState/index.ts
new file mode 100644
index 000000000..b347fd942
--- /dev/null
+++ b/packages/demobank-ui/src/pages/OperationState/index.ts
@@ -0,0 +1,122 @@
+/*
+ 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 { AbsoluteTime, AmountJson, WithdrawUriResult } from "@gnu-taler/taler-util";
+import { HttpError, utils } from "@gnu-taler/web-util/browser";
+import { ErrorLoading } from "../../components/ErrorLoading.js";
+import { Loading } from "../../components/Loading.js";
+import { useComponentState } from "./state.js";
+import { AbortedView, ConfirmedView, InvalidPaytoView, InvalidReserveView, InvalidWithdrawalView, NeedConfirmationView, ReadyView } from "./views.js";
+
+export interface Props {
+ currency: string;
+ onClose: () => void;
+}
+
+export type State = State.Loading |
+ State.LoadingError |
+ State.Ready |
+ State.Aborted |
+ State.Confirmed |
+ State.InvalidPayto |
+ State.InvalidWithdrawal |
+ State.InvalidReserve |
+ State.NeedConfirmation;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface LoadingError {
+ status: "loading-error";
+ error: HttpError<SandboxBackend.SandboxError>;
+ }
+
+ /**
+ * Need to open the wallet
+ */
+ export interface Ready {
+ status: "ready";
+ error: undefined;
+ uri: WithdrawUriResult,
+ onClose: () => void;
+ onAbort: () => void;
+ }
+
+ export interface InvalidPayto {
+ status: "invalid-payto",
+ error: undefined;
+ payto: string | null;
+ onClose: () => void;
+ }
+ export interface InvalidWithdrawal {
+ status: "invalid-withdrawal",
+ error: undefined;
+ onClose: () => void;
+ uri: string,
+ }
+ export interface InvalidReserve {
+ status: "invalid-reserve",
+ error: undefined;
+ onClose: () => void;
+ reserve: string | null;
+ }
+ export interface NeedConfirmation {
+ status: "need-confirmation",
+ onAbort: () => void;
+ onConfirm: () => void;
+ error: undefined;
+ busy: boolean,
+ }
+ export interface Aborted {
+ status: "aborted",
+ error: undefined;
+ onClose: () => void;
+ }
+ export interface Confirmed {
+ status: "confirmed",
+ error: undefined;
+ onClose: () => void;
+ }
+
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ "invalid-payto": InvalidPaytoView,
+ "invalid-withdrawal": InvalidWithdrawalView,
+ "invalid-reserve": InvalidReserveView,
+ "need-confirmation": NeedConfirmationView,
+ "aborted": AbortedView,
+ "confirmed": ConfirmedView,
+ "loading-error": ErrorLoading,
+ ready: ReadyView,
+};
+
+export const OperationState = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts
new file mode 100644
index 000000000..4be680377
--- /dev/null
+++ b/packages/demobank-ui/src/pages/OperationState/state.ts
@@ -0,0 +1,265 @@
+/*
+ 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 { Amounts, HttpStatusCode, TranslatedString, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util";
+import { RequestError, notify, notifyError, notifyInfo, useTranslationContext, utils } from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { useAccessAPI, useAccessAnonAPI, useWithdrawalDetails } from "../../hooks/access.js";
+import { getInitialBackendBaseURL } from "../../hooks/backend.js";
+import { useSettings } from "../../hooks/settings.js";
+import { buildRequestErrorMessage } from "../../utils.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({ currency, onClose }: Props): utils.RecursiveState<State> {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings()
+ const { createWithdrawal } = useAccessAPI();
+ const { abortWithdrawal, confirmWithdrawal } = useAccessAnonAPI();
+ const [busy, setBusy] = useState<Record<string, undefined>>()
+
+ const amount = settings.maxWithdrawalAmount
+
+ async function doSilentStart() {
+ //FIXME: if amount is not enough use balance
+ const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`)
+
+ try {
+ const result = await createWithdrawal({
+ amount: Amounts.stringify(parsedAmount),
+ });
+ const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
+ if (!uri) {
+ return notifyError(
+ i18n.str`Server responded with an invalid withdraw URI`,
+ i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`);
+ } else {
+ updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId)
+ }
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The operation was rejected due to insufficient funds`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+
+ const withdrawalOperationId = settings.currentWithdrawalOperationId
+ useEffect(() => {
+ if (withdrawalOperationId === undefined) {
+ doSilentStart()
+ }
+ }, [settings.fastWithdrawal, amount])
+
+ const baseUrl = getInitialBackendBaseURL()
+
+ if (!withdrawalOperationId) {
+ return {
+ status: "loading",
+ error: undefined
+ }
+ }
+
+ const wid = withdrawalOperationId
+
+ async function doAbort() {
+ try {
+ setBusy({})
+ await abortWithdrawal(wid);
+ onClose();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`The reserve operation has been confirmed previously and can't be aborted`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ setBusy(undefined)
+ }
+
+ async function doConfirm() {
+ try {
+ setBusy({})
+ await confirmWithdrawal(wid);
+ if (!settings.showWithdrawalSuccess) {
+ notifyInfo(i18n.str`Wire transfer completed!`)
+ }
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`The withdrawal has been aborted previously and can't be confirmed`
+ : status === HttpStatusCode.UnprocessableEntity
+ ? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ setBusy(undefined)
+ }
+ const bankIntegrationApiBaseUrl = `${baseUrl}/taler-integration`
+ const uri = stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl,
+ withdrawalOperationId,
+ });
+ const parsedUri = parseWithdrawUri(uri);
+ if (!parsedUri) {
+ return {
+ status: "invalid-withdrawal",
+ error: undefined,
+ uri,
+ onClose,
+ }
+ }
+
+ return (): utils.RecursiveState<State> => {
+ const result = useWithdrawalDetails(withdrawalOperationId);
+ const shouldCreateNewOperation = !result.ok && !result.loading && result.info.status === HttpStatusCode.NotFound
+
+ useEffect(() => {
+ if (shouldCreateNewOperation) {
+ doSilentStart()
+ }
+ }, [])
+ if (!result.ok) {
+ if (result.loading) {
+ return {
+ status: "loading",
+ error: undefined
+ }
+ }
+ if (result.info.status === HttpStatusCode.NotFound) {
+ return {
+ status: "loading",
+ error: undefined,
+ }
+ }
+ return {
+ status: "loading-error",
+ error: result
+ }
+ }
+ const { data } = result;
+ if (data.aborted) {
+ return {
+ status: "aborted",
+ error: undefined,
+ onClose: async () => {
+ updateSettings("currentWithdrawalOperationId", undefined)
+ onClose()
+ },
+ }
+ }
+
+ if (data.confirmation_done) {
+ if (!settings.showWithdrawalSuccess) {
+ updateSettings("currentWithdrawalOperationId", undefined)
+ onClose()
+ }
+ return {
+ status: "confirmed",
+ error: undefined,
+ onClose: async () => {
+ updateSettings("currentWithdrawalOperationId", undefined)
+ onClose()
+ },
+ }
+ }
+
+ if (!data.selection_done) {
+ return {
+ status: "ready",
+ error: undefined,
+ uri: parsedUri,
+ onClose: async () => {
+ await doAbort()
+ updateSettings("currentWithdrawalOperationId", undefined)
+ onClose()
+ },
+ onAbort: doAbort,
+ }
+ }
+
+ if (!data.selected_reserve_pub) {
+ return {
+ status: "invalid-reserve",
+ error: undefined,
+ reserve: data.selected_reserve_pub,
+ onClose,
+ }
+ }
+
+ const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account)
+
+ if (!account) {
+ return {
+ status: "invalid-payto",
+ error: undefined,
+ payto: data.selected_exchange_account,
+ onClose,
+ }
+ }
+
+
+ // goToConfirmOperation(withdrawalOperationId)
+ return {
+ status: "need-confirmation",
+ error: undefined,
+ onAbort: async () => {
+ await doAbort()
+ updateSettings("currentWithdrawalOperationId", undefined)
+ onClose()
+ },
+ busy: !!busy,
+ onConfirm: doConfirm
+ }
+ }
+
+}
diff --git a/packages/demobank-ui/src/pages/OperationState/stories.tsx b/packages/demobank-ui/src/pages/OperationState/stories.tsx
new file mode 100644
index 000000000..03917a8fb
--- /dev/null
+++ b/packages/demobank-ui/src/pages/OperationState/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { ReadyView } from "./views.js";
+
+export default {
+ title: "operation status page",
+};
+
+export const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/demobank-ui/src/pages/OperationState/test.ts b/packages/demobank-ui/src/pages/OperationState/test.ts
new file mode 100644
index 000000000..f4d6cf4b2
--- /dev/null
+++ b/packages/demobank-ui/src/pages/OperationState/test.ts
@@ -0,0 +1,32 @@
+/*
+ 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/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { SwrMockEnvironment } from "@gnu-taler/web-util/testing";
+import { expect } from "chai";
+import { CASHOUT_API_EXAMPLE } from "../../endpoints.js";
+import { Props } from "./index.js";
+import { useComponentState } from "./state.js";
+
+describe("Withdrawal operation states", () => {
+ it("should do some tests", async () => {
+ });
+});
diff --git a/packages/demobank-ui/src/pages/OperationState/views.tsx b/packages/demobank-ui/src/pages/OperationState/views.tsx
new file mode 100644
index 000000000..2cb7385db
--- /dev/null
+++ b/packages/demobank-ui/src/pages/OperationState/views.tsx
@@ -0,0 +1,376 @@
+/*
+ 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 { stringifyWithdrawUri } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useMemo, useState } from "preact/hooks";
+import { QR } from "../../components/QR.js";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { useSettings } from "../../hooks/settings.js";
+import { undefinedIfEmpty } from "../../utils.js";
+import { State } from "./index.js";
+
+export function InvalidPaytoView({ payto, onClose }: State.InvalidPayto) {
+ return (
+ <div>Payto from server is not valid &quot;{payto}&quot;</div>
+ );
+}
+export function InvalidWithdrawalView({ uri, onClose }: State.InvalidWithdrawal) {
+ return (
+ <div>Withdrawal uri from server is not valid &quot;{uri}&quot;</div>
+ );
+}
+export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) {
+ return (
+ <div>Reserve from server is not valid &quot;{reserve}&quot;</div>
+ );
+}
+
+export function NeedConfirmationView({ error, onAbort, onConfirm, busy }: State.NeedConfirmation) {
+ const { i18n } = useTranslationContext()
+
+ const captchaNumbers = useMemo(() => {
+ return {
+ a: Math.floor(Math.random() * 10),
+ b: Math.floor(Math.random() * 10),
+ };
+ }, []);
+ const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
+ const answer = parseInt(captchaAnswer ?? "", 10);
+ const errors = undefinedIfEmpty({
+ answer: !captchaAnswer
+ ? i18n.str`Answer the question before continue`
+ : Number.isNaN(answer)
+ ? i18n.str`The answer should be a number`
+ : answer !== captchaNumbers.a + captchaNumbers.b
+ ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
+ : undefined,
+ }) ?? (busy ? {} as Record<string, undefined> : undefined);
+
+ return (
+ <div class="bg-white shadow sm:rounded-lg">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold text-gray-900">
+ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
+ </h3>
+ <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 sm:grid-cols-4 sm:gap-x-3">
+
+ <label class={"relative sm:col-span-2 flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-noneborder-indigo-600 ring-2 ring-indigo-600"}>
+ <input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" />
+ <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>challenge response test</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600" 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>
+
+
+ <label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
+ <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
+ <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>
+ <span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>not available</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600 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>
+
+ <label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
+ <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
+ <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>one time password</i18n.Translate>
+ </span>
+ <span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>not available</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600 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 class="mt-3 text-sm leading-6">
+
+ <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">
+ <label for="withdraw-amount">{i18n.str`What is`}&nbsp;
+ <em>
+ {captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
+ </em>
+ ?
+ </label>
+ <div class="mt-2">
+ <div class="relative rounded-md shadow-sm">
+ <input
+ type="text"
+ // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ aria-describedby="answer"
+ autoFocus
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={captchaAnswer ?? ""}
+ required
+
+ name="answer"
+ id="answer"
+ autocomplete="off"
+ onChange={(e): void => {
+ setCaptchaAnswer(e.currentTarget.value)
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} />
+ </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">
+ <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onAbort}
+ >
+ <i18n.Translate>Cancel</i18n.Translate></button>
+ <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={!!errors}
+ onClick={(e) => {
+ e.preventDefault()
+ onConfirm()
+ }}
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </button>
+ </div>
+
+ </form>
+ </div>
+ <div class="px-4 mt-4 ">
+ {/* <div class="w-full">
+ <div class="px-4 sm:px-0 text-sm">
+ <p><i18n.Translate>Wire transfer details</i18n.Translate></p>
+ </div>
+ <div class="mt-6 border-t border-gray-100">
+ <dl class="divide-y divide-gray-100">
+ {((): VNode => {
+ switch (details.account.targetType) {
+ case "iban": {
+ const p = details.account as PaytoUriIBAN
+ const name = p.params["receiver-name"]
+ return <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd>
+ </div>
+ {name &&
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
+ </div>
+ }
+ </Fragment>
+ }
+ case "x-taler-bank": {
+ const p = details.account as PaytoUriTalerBank
+ const name = p.params["receiver-name"]
+ return <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd>
+ </div>
+ {name &&
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
+ </div>
+ }
+ </Fragment>
+ }
+ default:
+ return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd>
+ </div>
+
+ }
+ })()}
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Withdrawal identification</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0 break-words">{details.reserve}</dd>
+ </div>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">To be added</dd>
+ // {/* Amounts.stringifyValue(details.amount)
+ </div>
+ </dl>
+ </div>
+ </div> */}
+
+ </div>
+ </div>
+ </div>
+
+ );
+}
+export function AbortedView({ error, onClose }: State.Aborted) {
+ return (
+ <div>aborted</div>
+ );
+}
+
+export function ConfirmedView({ error, onClose }: State.Confirmed) {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings()
+ return (
+ <Fragment>
+
+ <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white p-4 text-left shadow-xl transition-all ">
+
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
+ <svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
+ </svg>
+ </div>
+ <div class="mt-3 text-center sm:mt-5">
+ <h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">
+ <i18n.Translate>Withdrawal confirmed</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="mt-4">
+ <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>Do not show this again</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={!settings.showWithdrawalSuccess} 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={() => {
+ updateSettings("showWithdrawalSuccess", !settings.showWithdrawalSuccess);
+ }}>
+ <span aria-hidden="true" data-enabled={!settings.showWithdrawalSuccess} 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="mt-5 sm:mt-6">
+ <button type="button"
+ 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"
+ onClick={async (e) => {
+ e.preventDefault();
+ onClose()
+ }}>
+ <i18n.Translate>Close</i18n.Translate>
+ </button>
+ </div>
+ </Fragment>
+
+ );
+}
+
+export function ReadyView({ uri, onClose }: State.Ready): VNode<{}> {
+ const { i18n } = useTranslationContext();
+
+ useEffect(() => {
+ //Taler Wallet WebExtension is listening to headers response and tab updates.
+ //In the SPA there is no header response with the Taler URI so
+ //this hack manually triggers the tab update after the QR is in the DOM.
+ // WebExtension will be using
+ // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated
+ document.title = `${document.title} ${uri.withdrawalOperationId}`;
+ }, []);
+ const talerWithdrawUri = stringifyWithdrawUri(uri);
+ return <Fragment>
+ <div class="flex justify-end mt-4">
+ <button type="button"
+ class="inline-flex items-center 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-500"
+ onClick={() => {
+ onClose()
+ }}
+ >
+ Cancel
+ </button>
+ </div>
+
+ <div class="bg-white shadow sm:rounded-lg mt-4">
+ <div class="p-4">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>On this device</i18n.Translate>
+ </h3>
+ <div class="mt-2 sm:flex sm:items-start sm:justify-between">
+ <div class="max-w-xl text-sm text-gray-500">
+ <p>
+ <i18n.Translate>If you are using a desktop browser you can open the popup now or click the link if you have the "Inject Taler support" option enabled.</i18n.Translate>
+ </p>
+ </div>
+ <div class="mt-5 sm:ml-6 sm:mt-0 sm:flex sm:flex-shrink-0 sm:items-center">
+ <a href={talerWithdrawUri}
+ class="inline-flex items-center 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>Start</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="bg-white shadow sm:rounded-lg mt-2">
+ <div class="p-4">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>On a mobile phone</i18n.Translate>
+ </h3>
+ <div class="mt-2 sm:flex sm:items-start sm:justify-between">
+ <div class="max-w-xl text-sm text-gray-500">
+ <p>
+ <i18n.Translate>Scan the QR code with your mobile device.</i18n.Translate>
+ </p>
+ </div>
+ </div>
+ <div class="mt-2 max-w-md ml-auto mr-auto">
+ <QR text={talerWithdrawUri} />
+ </div>
+ </div>
+ </div>
+
+ </Fragment>
+
+}
diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx
index 3552da7b4..f60ba3270 100644
--- a/packages/demobank-ui/src/pages/PaymentOptions.tsx
+++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx
@@ -15,10 +15,9 @@
*/
import { AmountJson } from "@gnu-taler/taler-util";
-import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { h, VNode } from "preact";
import { useState } from "preact/hooks";
-import { notifyInfo } from "../hooks/notification.js";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
import { useSettings } from "../hooks/settings.js";
@@ -27,60 +26,97 @@ import { useSettings } from "../hooks/settings.js";
* Let the user choose a payment option,
* then specify the details trigger the action.
*/
-export function PaymentOptions({ limit }: { limit: AmountJson }): VNode {
+export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJson, goToConfirmOperation: (id: string) => void }): VNode {
const { i18n } = useTranslationContext();
- const [settings, updateSettings] = useSettings();
+ const [settings] = useSettings();
- const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">(
- "charge-wallet",
- );
+ const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>();
return (
- <article>
- <div class="payments">
- <div class="tab">
- <button
- class={tab === "charge-wallet" ? "tablinks active" : "tablinks"}
- onClick={(): void => {
- setTab("charge-wallet");
- }}
- >
- {i18n.str`Withdraw `}
- </button>
- <button
- class={tab === "wire-transfer" ? "tablinks active" : "tablinks"}
- onClick={(): void => {
- setTab("wire-transfer");
- }}
- >
- {i18n.str`Wire transfer`}
- </button>
+ <div class="mt-2">
+
+ <fieldset>
+ <legend class="px-4 text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Send money to</i18n.Translate>
+ </legend>
+
+ <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
+ {/* <!-- Active: "border-indigo-600 ring-2 ring-indigo-600", Not Active: "border-gray-300" --> */}
+ <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (tab === "charge-wallet" ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
+ <input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onClick={() => {
+ setTab("charge-wallet")
+ }} />
+ <span class="flex flex-1">
+ <div class="text-4xl mr-4 my-auto">&#x1F4B5;</div>
+ <span class="flex flex-col">
+ <span id="project-type-0-label" class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>a <b>Taler</b> wallet</i18n.Translate>
+ </span>
+ <span id="project-type-0-description-0" class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>Withdraw digital money into your mobile wallet or browser extension</i18n.Translate>
+ </span>
+ {!!settings.currentWithdrawalOperationId &&
+ <span class="inline-flex items-center gap-x-1.5 w-fit rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">
+ <svg class="h-1.5 w-1.5 fill-green-500" viewBox="0 0 6 6" aria-hidden="true">
+ <circle cx="3" cy="3" r="3" />
+ </svg>
+ <i18n.Translate>operation ready</i18n.Translate>
+ </span>
+ }
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600" style={{ visibility: tab === "charge-wallet" ? "visible" : "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>
+
+
+ <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (tab === "wire-transfer" ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
+ <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onClick={() => {
+ setTab("wire-transfer")
+ }} />
+ <span class="flex flex-1">
+ <div class="text-4xl mr-4 my-auto">&#x2194;</div>
+ <span class="flex flex-col">
+ <span id="project-type-1-label" class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>another bank account</i18n.Translate>
+ </span>
+ <span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>Make a wire transfer to an account which you know the bank account number</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600" style={{ visibility: tab === "wire-transfer" ? "visible" : "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>
{tab === "charge-wallet" && (
- <div id="charge-wallet" class="tabcontent active">
- <h3>{i18n.str`Obtain digital cash`}</h3>
- <WalletWithdrawForm
- focus
- limit={limit}
- onSuccess={(id) => {
- updateSettings("currentWithdrawalOperationId", id);
- }}
- />
- </div>
+ <WalletWithdrawForm
+ focus
+ limit={limit}
+ goToConfirmOperation={goToConfirmOperation}
+ onCancel={() => {
+ setTab(undefined)
+ }}
+ />
)}
{tab === "wire-transfer" && (
- <div id="wire-transfer" class="tabcontent active">
- <h3>{i18n.str`Transfer to bank account`}</h3>
- <PaytoWireTransferForm
- focus
- limit={limit}
- onSuccess={() => {
- notifyInfo(i18n.str`Wire transfer created!`);
- }}
- />
- </div>
+ <PaytoWireTransferForm
+ focus
+ title={i18n.str`Transfer details`}
+ limit={limit}
+ onSuccess={() => {
+ notifyInfo(i18n.str`Wire transfer created!`);
+ setTab(undefined)
+ }}
+ onCancel={() => {
+ setTab(undefined)
+ }}
+ />
)}
- </div>
- </article>
- );
+
+ </fieldset>
+ </div>
+ )
}
diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
index d8c1644b1..52dbd4ff6 100644
--- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
+++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -17,42 +17,51 @@
import {
AmountJson,
Amounts,
- buildPayto,
HttpStatusCode,
Logger,
+ TranslatedString,
+ buildPayto,
parsePaytoUri,
- stringifyPaytoUri,
+ stringifyPaytoUri
} from "@gnu-taler/taler-util";
import {
RequestError,
+ notify,
+ notifyError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
+import { h, VNode, Fragment, Ref } from "preact";
import { useEffect, useRef, useState } from "preact/hooks";
-import { notifyError } from "../hooks/notification.js";
+import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { useAccessAPI } from "../hooks/access.js";
import {
buildRequestErrorMessage,
undefinedIfEmpty,
validateIBAN,
} from "../utils.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
+import { useConfigState } from "../hooks/config.js";
+import { useConfigContext } from "../context/config.js";
const logger = new Logger("PaytoWireTransferForm");
export function PaytoWireTransferForm({
focus,
+ title,
onSuccess,
+ onCancel,
limit,
}: {
+ title: TranslatedString,
focus?: boolean;
onSuccess: () => void;
+ onCancel: (() => void) | undefined;
limit: AmountJson;
}): VNode {
const [isRawPayto, setIsRawPayto] = useState(false);
- const [iban, setIban] = useState<string | undefined>(undefined);
- const [subject, setSubject] = useState<string | undefined>(undefined);
- const [amount, setAmount] = useState<string | undefined>(undefined);
+ // FIXME: remove this
+ const [iban, setIban] = useState<string | undefined>();
+ const [subject, setSubject] = useState<string | undefined>();
+ const [amount, setAmount] = useState<string | undefined>();
const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
undefined,
@@ -70,295 +79,372 @@ export function PaytoWireTransferForm({
const errorsWire = undefinedIfEmpty({
iban: !iban
- ? i18n.str`Missing IBAN`
+ ? i18n.str`required`
: !IBAN_REGEX.test(iban)
- ? i18n.str`IBAN should have just uppercased letters and numbers`
- : validateIBAN(iban, i18n),
- subject: !subject ? i18n.str`Missing subject` : undefined,
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : validateIBAN(iban, i18n),
+ subject: !subject ? i18n.str`required` : undefined,
amount: !trimmedAmountStr
- ? i18n.str`Missing amount`
+ ? i18n.str`required`
: !parsedAmount
- ? i18n.str`Amount is not valid`
- : Amounts.isZero(parsedAmount)
- ? i18n.str`Should be greater than 0`
- : Amounts.cmp(limit, parsedAmount) === -1
- ? i18n.str`balance is not enough`
- : undefined,
+ ? i18n.str`not valid`
+ : Amounts.isZero(parsedAmount)
+ ? i18n.str`should be greater than 0`
+ : Amounts.cmp(limit, parsedAmount) === -1
+ ? i18n.str`balance is not enough`
+ : undefined,
});
const { createTransaction } = useAccessAPI();
- if (!isRawPayto)
- return (
- <div>
- <form
- class="pure-form"
- name="wire-transfer-form"
- onSubmit={(e) => {
- e.preventDefault();
- }}
- autoCapitalize="none"
- autoCorrect="off"
- >
- <label for="iban">{i18n.str`Receiver IBAN:`}</label>&nbsp;
- <input
- ref={ref}
- type="text"
- id="iban"
- name="iban"
- value={iban ?? ""}
- placeholder="CC0123456789"
- required
- pattern={ibanRegex}
- onInput={(e): void => {
- setIban(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errorsWire?.iban}
- isDirty={iban !== undefined}
- />
- <label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp;
- <input
- type="text"
- name="subject"
- id="subject"
- placeholder="subject"
- value={subject ?? ""}
- required
- onInput={(e): void => {
- setSubject(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errorsWire?.subject}
- isDirty={subject !== undefined}
- />
- <label for="amount">{i18n.str`Amount:`}</label>&nbsp;
- <div style={{ width: "max-content", display: "flex" }}>
- <input
- type="text"
- readonly
- class="currency-indicator"
- size={limit.currency.length}
- maxLength={limit.currency.length}
- tabIndex={-1}
- style={{
- borderTopRightRadius: 0,
- borderBottomRightRadius: 0,
- borderRight: 0,
- }}
- value={limit.currency}
- />
- <input
- type="number"
- name="amount"
- id="amount"
- placeholder="amount"
- required
- style={{
- borderTopLeftRadius: 0,
- borderBottomLeftRadius: 0,
- borderLeft: 0,
- width: 150,
- }}
- value={amount ?? ""}
- onInput={(e): void => {
- setAmount(e.currentTarget.value);
- }}
- />
- </div>
- <ShowInputErrorLabel
- message={errorsWire?.amount}
- isDirty={amount !== undefined}
- />
- <p style={{ display: "flex", justifyContent: "space-between" }}>
- <input
- type="submit"
- class="pure-button pure-button-primary"
- disabled={!!errorsWire}
- value="Send"
- onClick={async (e) => {
- e.preventDefault();
- if (!(iban && subject && amount)) {
- return;
- }
- const ibanPayto = buildPayto("iban", iban, undefined);
- ibanPayto.params.message = encodeURIComponent(subject);
- const paytoUri = stringifyPaytoUri(ibanPayto);
-
- try {
- await createTransaction({
- paytoUri,
- amount: `${limit.currency}:${amount}`,
- });
- onSuccess();
- setAmount(undefined);
- setIban(undefined);
- setSubject(undefined);
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.BadRequest
- ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
- }}
- />
- <input
- type="button"
- class="pure-button"
- value="Clear"
- onClick={async (e) => {
- e.preventDefault();
- setAmount(undefined);
- setIban(undefined);
- setSubject(undefined);
- }}
- />
- </p>
- </form>
- <p>
- <a
- href="#"
- onClick={(e) => {
- setIsRawPayto(true);
- e.preventDefault();
- }}
- >
- {i18n.str`Want to try the raw payto://-format?`}
- </a>
- </p>
- </div>
- );
-
const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
const errorsPayto = undefinedIfEmpty({
rawPaytoInput: !rawPaytoInput
? i18n.str`required`
: !parsed
- ? i18n.str`does not follow the pattern`
- : !parsed.params.amount
- ? i18n.str`use the "amount" parameter to specify the amount to be transferred`
- : Amounts.parse(parsed.params.amount) === undefined
- ? i18n.str`the amount is not valid`
- : !parsed.params.message
- ? i18n.str`use the "message" parameter to specify a reference text for the transfer`
- : !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),
+ ? i18n.str`does not follow the pattern`
+ : !parsed.isKnown || parsed.targetType !== "iban"
+ ? i18n.str`only "IBAN" target are supported`
+ : !parsed.params.amount
+ ? i18n.str`use the "amount" parameter to specify the amount to be transferred`
+ : Amounts.parse(parsed.params.amount) === undefined
+ ? i18n.str`the amount is not valid`
+ : !parsed.params.message
+ ? i18n.str`use the "message" parameter to specify a reference text for the transfer`
+ : !IBAN_REGEX.test(parsed.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : validateIBAN(parsed.iban, i18n),
});
- return (
- <div>
- <p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p>
- <form
- class="pure-form"
- name="payto-form"
- onSubmit={(e) => {
- e.preventDefault();
- }}
- autoCapitalize="none"
- autoCorrect="off"
- >
- <p>
- <label for="address">{i18n.str`payto URI:`}</label>&nbsp;
- <input
- name="address"
- type="text"
- size={50}
- ref={ref}
- id="address"
- value={rawPaytoInput ?? ""}
- required
- placeholder={i18n.str`payto address`}
- // pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`}
- onInput={(e): void => {
- rawPaytoInputSetter(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errorsPayto?.rawPaytoInput}
- isDirty={rawPaytoInput !== undefined}
- />
- <br />
- <div style={{ fontSize: "small", marginTop: 4 }}>
- Hint:
- <code>
- payto://iban/[receiver-iban]?message=[subject]&amount=[
- {limit.currency}
- :X.Y]
- </code>
- </div>
- </p>
- <p>
- <input
- class="pure-button pure-button-primary"
- type="button"
- disabled={!!errorsPayto}
- value={i18n.str`Send`}
- onClick={async () => {
- if (!rawPaytoInput) {
- logger.error("Didn't get any raw Payto string!");
- return;
+ async function doSend() {
+ let payto_uri: string | undefined;
+
+ if (rawPaytoInput) {
+ payto_uri = rawPaytoInput
+ } else {
+ if (!iban || !subject) return;
+ const ibanPayto = buildPayto("iban", iban, undefined);
+ ibanPayto.params.message = encodeURIComponent(subject);
+ payto_uri = stringifyPaytoUri(ibanPayto);
+ }
+
+ try {
+ await createTransaction({
+ payto_uri,
+ amount: `${limit.currency}:${amount}`,
+ });
+ onSuccess();
+ setAmount(undefined);
+ setIban(undefined);
+ setSubject(undefined);
+ rawPaytoInputSetter(undefined)
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.BadRequest
+ ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.`
+ : 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">
+ {/**
+ * FIXME: Scan a qr code
+ */}
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ {title}
+ </h2>
+ <div>
+ <div class="px-4 mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-1 sm:gap-x-4">
+ <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (!isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
+ <input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => {
+ if (parsed && parsed.isKnown && parsed.targetType === "iban") {
+ setIban(parsed.iban)
+ const amount = Amounts.parse(parsed.params["amount"])
+ if (amount) {
+ setAmount(Amounts.stringifyValue(amount))
+ }
+ const subject = parsed.params["subject"]
+ if (subject) {
+ setSubject(subject)
+ }
}
+ setIsRawPayto(false)
+ }} />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Using a form</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
- try {
- await createTransaction({
- paytoUri: rawPaytoInput,
- });
- onSuccess();
- rawPaytoInputSetter(undefined);
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.BadRequest
- ? i18n.str`The request was invalid or the payto://-URI used unacceptable features.`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
+ <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}>
+ <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => {
+ if (iban) {
+ const payto = buildPayto("iban", iban, undefined)
+ if (parsedAmount) {
+ payto.params["amount"] = Amounts.stringify(parsedAmount)
+ }
+ if (subject) {
+ payto.params["message"] = subject
}
+ rawPaytoInputSetter(stringifyPaytoUri(payto))
}
- }}
- />
- </p>
- <p>
- <a
- href="/account"
- onClick={() => {
- setIsRawPayto(false);
- }}
+ setIsRawPayto(true)
+ }} />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Import payto:// URI</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+ </div>
+ </div>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 w-fit mx-auto"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={e => {
+ e.preventDefault()
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ {!isRawPayto ?
+ <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+
+ <div class="sm:col-span-5">
+ <label for="iban" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Recipient`}</label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="iban"
+ id="iban"
+ value={iban ?? ""}
+ placeholder="CC0123456789"
+ autocomplete="off"
+ required
+ pattern={ibanRegex}
+ onInput={(e): void => {
+ setIban(e.currentTarget.value.toUpperCase());
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.iban}
+ isDirty={iban !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate>IBAN of the recipient's account</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label for="subject" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Transfer subject`}</label>
+ <div class="mt-2">
+ <input
+ 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 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="subject"
+ id="subject"
+ autocomplete="off"
+ placeholder="subject"
+ value={subject ?? ""}
+ required
+ onInput={(e): void => {
+ setSubject(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.subject}
+ isDirty={subject !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >some text to identify the transfer</p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label for="amount" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`Amount`}</label>
+ <InputAmount
+ name="amount"
+ left
+ currency={limit.currency}
+ value={trimmedAmountStr}
+ onChange={(d) => {
+ setAmount(d)
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.amount}
+ isDirty={subject !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500" >amount to transfer</p>
+ </div>
+
+ </div> :
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6 w-full">
+ <div class="sm:col-span-6">
+ <label for="address" class="block text-sm font-medium leading-6 text-gray-900">{i18n.str`payto URI:`}</label>
+ <div class="mt-2">
+ <textarea
+ ref={focus ? doAutoFocus : undefined}
+ name="address"
+ id="address"
+ type="textarea"
+ rows={3}
+ class="block overflow-hidden w-64 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={rawPaytoInput ?? ""}
+ required
+ placeholder={i18n.str`payto://iban/[receiver-iban]?message=[subject]&amount=[${limit.currency}:X.Y]`}
+ onInput={(e): void => {
+ rawPaytoInputSetter(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsPayto?.rawPaytoInput}
+ isDirty={rawPaytoInput !== undefined}
+ />
+ </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">
+ {onCancel ?
+ <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onCancel}
>
- {i18n.str`Use wire-transfer form?`}
- </a>
- </p>
- </form>
+ <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={isRawPayto ? !!errorsPayto : !!errorsWire}
+ onClick={(e) => {
+ e.preventDefault()
+ doSend()
+ }}
+ >
+ <i18n.Translate>Send</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div >
+ )
+
+}
+
+/**
+ * Show the element when the load ended
+ * @param element
+ */
+export function doAutoFocus(element: HTMLElement | null) {
+ if (element) {
+ setTimeout(() => {
+ element.focus()
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center"
+ })
+ }, 100)
+ }
+}
+
+const FRAC_SEPARATOR = "."
+
+export function InputAmount(
+ {
+ currency,
+ name,
+ value,
+ error,
+ left,
+ onChange,
+ }: {
+ error?: string;
+ currency: string;
+ name: string;
+ left?: boolean | undefined,
+ value: string | undefined;
+ onChange?: (s: string) => void;
+ },
+ ref: Ref<HTMLInputElement>,
+): VNode {
+ const cfg = useConfigContext()
+ return (
+ <div class="mt-2">
+ <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600">
+ <div
+ class="pointer-events-none inset-y-0 flex items-center px-3"
+ >
+ <span class="text-gray-500 sm:text-sm">{currency}</span>
+ </div>
+ <input
+ type="number"
+ data-left={left}
+ class="text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"
+ placeholder="0.00" aria-describedby="price-currency"
+ ref={ref}
+ name={name}
+ id={name}
+ autocomplete="off"
+ value={value ?? ""}
+ disabled={!onChange}
+ onInput={(e) => {
+ if (!onChange) return;
+ const l = e.currentTarget.value.length
+ const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR)
+ if (sep_pos !== -1 && l - sep_pos - 1 > cfg.currency_fraction_limit) {
+ e.currentTarget.value = e.currentTarget.value.substring(0, sep_pos + cfg.currency_fraction_limit + 1)
+ }
+ onChange(e.currentTarget.value);
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
</div>
);
}
+
+export function RenderAmount({ value, negative }: { value: AmountJson, negative?: boolean }): VNode {
+ const cfg = useConfigContext()
+ const str = Amounts.stringifyValue(value)
+ const sep_pos = str.indexOf(FRAC_SEPARATOR)
+ if (sep_pos !== -1 && str.length - sep_pos - 1 > cfg.currency_fraction_digits) {
+ const limit = sep_pos + cfg.currency_fraction_digits + 1
+ const normal = str.substring(0, limit)
+ const small = str.substring(limit)
+ return <span class="whitespace-nowrap">
+ {negative ? "-" : undefined}
+ {value.currency} {normal} <sup class="-ml-2">{small}</sup>
+ </span>
+ }
+ return <span class="whitespace-nowrap">
+ {negative ? "-" : undefined}
+ {value.currency} {str}
+ </span>
+} \ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx
index 03bdb78b7..680368919 100644
--- a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx
+++ b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx
@@ -36,8 +36,8 @@ export function PublicHistoriesPage({}: Props): VNode {
const result = usePublicAccounts();
const [showAccount, setShowAccount] = useState(
- result.ok && result.data.publicAccounts.length > 0
- ? result.data.publicAccounts[0].accountLabel
+ result.ok && result.data.public_accounts.length > 0
+ ? result.data.public_accounts[0].account_name
: undefined,
);
@@ -51,9 +51,9 @@ export function PublicHistoriesPage({}: Props): VNode {
const accountsBar = [];
// Ask story of all the public accounts.
- for (const account of data.publicAccounts) {
- logger.trace("Asking transactions for", account.accountLabel);
- const isSelected = account.accountLabel == showAccount;
+ for (const account of data.public_accounts) {
+ logger.trace("Asking transactions for", account.account_name);
+ const isSelected = account.account_name == showAccount;
accountsBar.push(
<li
class={
@@ -65,13 +65,13 @@ export function PublicHistoriesPage({}: Props): VNode {
<a
href="#"
class="pure-menu-link"
- onClick={() => setShowAccount(account.accountLabel)}
+ onClick={() => setShowAccount(account.account_name)}
>
- {account.accountLabel}
+ {account.account_name}
</a>
</li>,
);
- txs[account.accountLabel] = <Transactions account={account.accountLabel} />;
+ txs[account.account_name] = <Transactions account={account.account_name} />;
}
return (
diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx
index c27984569..e07525ab4 100644
--- a/packages/demobank-ui/src/pages/QrCodeSection.tsx
+++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx
@@ -17,17 +17,19 @@
import {
HttpStatusCode,
stringifyWithdrawUri,
+ TranslatedString,
WithdrawUriResult,
} from "@gnu-taler/taler-util";
import {
+ notify,
+ notifyError,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { h, VNode } from "preact";
+import { Fragment, h, VNode } from "preact";
import { useEffect } from "preact/hooks";
import { QR } from "../components/QR.js";
import { useAccessAnonAPI } from "../hooks/access.js";
-import { notifyError } from "../hooks/notification.js";
import { buildRequestErrorMessage } from "../utils.js";
export function QrCodeSection({
@@ -49,47 +51,87 @@ export function QrCodeSection({
const talerWithdrawUri = stringifyWithdrawUri(withdrawUri);
const { abortWithdrawal } = useAccessAnonAPI();
+
+ async function doAbort() {
+ try {
+ await abortWithdrawal(withdrawUri.withdrawalOperationId);
+ onAborted();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`The reserve operation has been confirmed previously and can't be aborted`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+
return (
- <section id="main" class="content">
- <h1 class="nav">{i18n.str`Charge your GNU Taler wallet`}</h1>
- <article>
- <div class="qr-div ">
- <a href={talerWithdrawUri} class="pure-button pure-button-primary">
- <i18n.Translate>Continue with GNU Taler</i18n.Translate>
- </a>
- <p>{i18n.str`Or scan this QR code with your mobile to receive the coin in another device:`}</p>
- <QR text={talerWithdrawUri} />
- <a
- class="pure-button btn-cancel"
- onClick={async (e) => {
- e.preventDefault();
- try {
- await abortWithdrawal(withdrawUri.withdrawalOperationId);
- onAborted();
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Conflict
- ? i18n.str`The reserve operation has been confirmed previously and can't be aborted`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
- }}
- >{i18n.str`Cancel`}</a>
+ <Fragment>
+ <div class="bg-white shadow-xl sm:rounded-lg">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>If you have a Taler wallet installed in this device</i18n.Translate>
+ </h3>
+ <div class="mt-4 mb-4 text-sm text-gray-500">
+ <p><i18n.Translate>
+ You will see the details of the operation in your wallet including the fees (if applies).
+ If you still don't have one you can install it from <a class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net/en/wallet.html">here</a>.
+ </i18n.Translate></p>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 pt-2 mt-2 ">
+ <button type="button"
+ // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={doAbort}
+ >
+ Cancel
+ </button>
+ <a href={talerWithdrawUri}
+ class="inline-flex items-center 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>Withdraw</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ </div>
+
+ <div class="bg-white shadow-xl sm:rounded-lg mt-8">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Or if you have the wallet in another device</i18n.Translate>
+ </h3>
+ <div class="mt-4 max-w-xl text-sm text-gray-500">
+ <i18n.Translate>Scan the QR below to start the withdrawal</i18n.Translate>
+ </div>
+ <div class="mt-2 max-w-md ml-auto mr-auto">
+ <QR text={talerWithdrawUri} />
+ </div>
</div>
- </article>
- </section>
+ <div class="flex items-center justify-center gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <button type="button"
+ // class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md px-3 py-2 text-sm font-semibold text-black shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={doAbort}
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+
+ </Fragment>
);
}
+
+
diff --git a/packages/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx
index ded48564f..9ac93bb34 100644
--- a/packages/demobank-ui/src/pages/RegistrationPage.tsx
+++ b/packages/demobank-ui/src/pages/RegistrationPage.tsx
@@ -13,26 +13,31 @@
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, Logger } from "@gnu-taler/taler-util";
+import { HttpStatusCode, Logger, TranslatedString } from "@gnu-taler/taler-util";
import {
RequestError,
+ notify,
+ notifyError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js";
import { useTestingAPI } from "../hooks/access.js";
-import { notifyError } from "../hooks/notification.js";
import { bankUiSettings } from "../settings.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
+import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
+import { getRandomPassword, getRandomUsername } from "./rnd.js";
+import { useCredentialsChecker } from "../hooks/useCredentialsChecker.js";
const logger = new Logger("RegistrationPage");
export function RegistrationPage({
onComplete,
+ onCancel
}: {
onComplete: () => void;
+ onCancel: () => void;
}): VNode {
const { i18n } = useTranslationContext();
if (!bankUiSettings.allowRegistrations) {
@@ -40,168 +45,357 @@ export function RegistrationPage({
<p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
);
}
- return <RegistrationForm onComplete={onComplete} />;
+ return <RegistrationForm onComplete={onComplete} onCancel={onCancel} />;
}
-export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9]*$/;
+export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9-]*$/;
+export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/;
+export const EMAIL_REGEX = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
/**
* Collect and submit registration data.
*/
-function RegistrationForm({ onComplete }: { onComplete: () => void }): VNode {
+function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, onCancel: () => void }): VNode {
const backend = useBackendContext();
const [username, setUsername] = useState<string | undefined>();
+ const [name, setName] = useState<string | undefined>();
const [password, setPassword] = useState<string | undefined>();
+ const [phone, setPhone] = useState<string | undefined>();
+ const [email, setEmail] = useState<string | undefined>();
const [repeatPassword, setRepeatPassword] = useState<string | undefined>();
+ const { requestNewLoginToken } = useCredentialsChecker()
const { register } = useTestingAPI();
const { i18n } = useTranslationContext();
const errors = undefinedIfEmpty({
+ // name: !name
+ // ? i18n.str`Missing name`
+ // : undefined,
username: !username
? i18n.str`Missing username`
: !USERNAME_REGEX.test(username)
- ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
- : undefined,
+ ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
+ : undefined,
+ phone: !phone
+ ? undefined
+ : !PHONE_REGEX.test(phone)
+ ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
+ : undefined,
+ email: !email
+ ? undefined
+ : !EMAIL_REGEX.test(email)
+ ? i18n.str`Use letters and numbers only, and start with a lowercase letter`
+ : undefined,
password: !password ? i18n.str`Missing password` : undefined,
repeatPassword: !repeatPassword
? i18n.str`Missing password`
: repeatPassword !== password
- ? i18n.str`Passwords don't match`
- : undefined,
+ ? i18n.str`Passwords don't match`
+ : undefined,
});
+ async function doRegistrationStep() {
+ if (!username || !password) return;
+ try {
+ await register({ name: name ?? "", username, password });
+ const resp = await requestNewLoginToken(username, password)
+ setUsername(undefined);
+ if (resp.valid) {
+ backend.logIn({ username, token: resp.token });
+ }
+ onComplete();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`That username is already taken`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ setPassword(undefined);
+ setRepeatPassword(undefined);
+ }
+
+ async function delay(ms: number): Promise<void> {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve(undefined);
+ }, ms)
+ })
+ }
+ async function doRandomRegistration(tries: number = 3) {
+ const user = getRandomUsername();
+ const pass = getRandomPassword();
+ try {
+ setUsername(undefined);
+ setPassword(undefined);
+ setRepeatPassword(undefined);
+ const username = `_${user.first}-${user.second}_`
+ await register({ username, name: `${user.first} ${user.second}`, password: pass });
+ const resp = await requestNewLoginToken(username, pass)
+ if (resp.valid) {
+ backend.logIn({ username, token: resp.token });
+ }
+ onComplete();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ if (tries > 0) {
+ await delay(200)
+ await doRandomRegistration(tries - 1)
+ } else {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`Could not create a random user`
+ : undefined,
+ }),
+ );
+ }
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+
return (
<Fragment>
- <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1>
- <article>
- <div class="register-div">
- <form
- class="register-form"
- noValidate
+ <h1 class="nav"></h1>
+
+ <div class="flex min-h-full flex-col justify-center">
+ <div class="sm:mx-auto sm:w-full sm:max-w-sm">
+ <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Account registration`}</h2>
+ </div>
+
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
+ <form class="space-y-6" noValidate
onSubmit={(e) => {
e.preventDefault();
}}
autoCapitalize="none"
autoCorrect="off"
>
- <div class="pure-form">
- <h2>{i18n.str`Please register!`}</h2>
- <p class="unameFieldLabel registerFieldLabel formFieldLabel">
- <label for="register-un">{i18n.str`Username:`}</label>
- </p>
- <input
- id="register-un"
- name="register-un"
- type="text"
- placeholder="Username"
- autocomplete="username"
- value={username ?? ""}
- onInput={(e): void => {
- setUsername(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.username}
- isDirty={username !== undefined}
- />
- <p class="unameFieldLabel registerFieldLabel formFieldLabel">
- <label for="register-pw">{i18n.str`Password:`}</label>
- </p>
- <input
- type="password"
- name="register-pw"
- id="register-pw"
- placeholder="Password"
- autocomplete="new-password"
- value={password ?? ""}
- required
- onInput={(e): void => {
- setPassword(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.password}
- isDirty={password !== undefined}
- />
- <p class="unameFieldLabel registerFieldLabel formFieldLabel">
- <label for="register-repeat">{i18n.str`Repeat Password:`}</label>
- </p>
- <input
- type="password"
- style={{ marginBottom: 8 }}
- name="register-repeat"
- id="register-repeat"
- autocomplete="new-password"
- placeholder="Same password"
- value={repeatPassword ?? ""}
- required
- onInput={(e): void => {
- setRepeatPassword(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.repeatPassword}
- isDirty={repeatPassword !== undefined}
- />
- <br />
- <button
- class="pure-button pure-button-primary btn-register"
- type="submit"
- disabled={!!errors}
- onClick={async (e) => {
- e.preventDefault();
-
- if (!username || !password) return;
- try {
- const credentials = { username, password };
- await register(credentials);
- setUsername(undefined);
- setPassword(undefined);
- setRepeatPassword(undefined);
- backend.logIn(credentials);
- onComplete();
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Conflict
- ? i18n.str`That username is already taken`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
+ <div>
+ <label for="username" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Username</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="username"
+ id="username"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={username ?? ""}
+ enterkeyhint="next"
+ placeholder="identification"
+ autocomplete="username"
+ required
+ onInput={(e): void => {
+ setUsername(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={username !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label for="password" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Password</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ </div>
+ <div class="mt-2">
+ <input
+ type="password"
+ name="password"
+ id="password"
+ autocomplete="current-password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ enterkeyhint="send"
+ value={password ?? ""}
+ placeholder="Password"
+ required
+ onInput={(e): void => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between">
+ <label for="register-repeat" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Repeat password</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ </div>
+ <div class="mt-2">
+ <input
+ type="password"
+ name="register-repeat"
+ id="register-repeat"
+ autocomplete="current-password"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ enterkeyhint="send"
+ value={repeatPassword ?? ""}
+ placeholder="Same password"
+ required
+ onInput={(e): void => {
+ setRepeatPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeatPassword}
+ isDirty={repeatPassword !== undefined}
+ />
+ </div>
+ </div>
+
+ <div>
+ <label for="name" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Name</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="name"
+ id="name"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={name ?? ""}
+ enterkeyhint="next"
+ placeholder="your name"
+ autocomplete="name"
+ required
+ onInput={(e): void => {
+ setName(e.currentTarget.value);
+ }}
+ />
+ {/* <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={name !== undefined}
+ /> */}
+ </div>
+ </div>
+
+ {/* <div>
+ <label for="phone" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Phone</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="phone"
+ id="phone"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={phone ?? ""}
+ enterkeyhint="next"
+ placeholder="your phone"
+ autocomplete="none"
+ onInput={(e): void => {
+ setPhone(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.phone}
+ isDirty={phone !== undefined}
+ />
+ </div>
+ </div>
+ <div>
+ <label for="email" class="block text-sm font-medium leading-6 text-gray-900">
+ <i18n.Translate>Email</i18n.Translate>
+ </label>
+ <div class="mt-2">
+ <input
+ autoFocus
+ type="text"
+ name="email"
+ id="email"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={email ?? ""}
+ enterkeyhint="next"
+ placeholder="your email"
+ autocomplete="email"
+ onInput={(e): void => {
+ setEmail(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.email}
+ isDirty={email !== undefined}
+ />
+ </div>
+ </div> */}
+
+ <div class="flex w-full justify-between">
+ <button type="submit"
+ class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
+ onClick={(e) => {
+ e.preventDefault()
+ onCancel()
}}
>
- {i18n.str`Register`}
+ <i18n.Translate>Cancel</i18n.Translate>
</button>
- {/* FIXME: should use a different color */}
- <button
- class="pure-button pure-button-secondary btn-cancel"
+ <button type="submit"
+ class=" rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 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={!!errors}
onClick={(e) => {
- e.preventDefault();
- setUsername(undefined);
- setPassword(undefined);
- setRepeatPassword(undefined);
- onComplete();
+ e.preventDefault()
+ doRegistrationStep()
}}
>
- {i18n.str`Cancel`}
+ <i18n.Translate>Register</i18n.Translate>
</button>
</div>
+
</form>
+
+ {bankUiSettings.allowRandomAccountCreation &&
+ <p class="mt-10 text-center text-sm text-gray-500 border-t">
+ <button type="submit"
+ class="flex mt-4 w-full justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600"
+ onClick={(e) => {
+ e.preventDefault()
+ doRandomRegistration()
+ }}
+ >
+ <i18n.Translate>Create a random user</i18n.Translate>
+ </button>
+ </p>
+ }
</div>
- </article>
+ </div>
+
</Fragment>
);
}
diff --git a/packages/demobank-ui/src/pages/Routing.tsx b/packages/demobank-ui/src/pages/Routing.tsx
deleted file mode 100644
index f176c73db..000000000
--- a/packages/demobank-ui/src/pages/Routing.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- 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 { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { createHashHistory } from "history";
-import { VNode, h } from "preact";
-import { Route, Router, route } from "preact-router";
-import { useEffect, useMemo, useState } from "preact/hooks";
-import { BankFrame } from "./BankFrame.js";
-import { BusinessAccount } from "./BusinessAccount.js";
-import { HomePage, WithdrawalOperationPage } from "./HomePage.js";
-import { PublicHistoriesPage } from "./PublicHistoriesPage.js";
-import { RegistrationPage } from "./RegistrationPage.js";
-
-export function Routing(): VNode {
- const history = createHashHistory();
-
- return (
- <BankFrame
- goToBusinessAccount={() => {
- route("/business");
- }}
- >
- <Router history={history}>
- <Route
- path="/operation/:wopid"
- component={({ wopid }: { wopid: string }) => (
- <WithdrawalOperationPage
- operationId={wopid}
- onContinue={() => {
- route("/account");
- }}
- onLoadNotOk={() => {
- route("/account");
- }}
- />
- )}
- />
- <Route
- path="/public-accounts"
- component={() => <PublicHistoriesPage />}
- />
- <Route
- path="/register"
- component={() => (
- <RegistrationPage
- onComplete={() => {
- route("/account");
- }}
- />
- )}
- />
- <Route
- path="/account"
- component={() => (
- <HomePage
- onPendingOperationFound={(wopid) => {
- route(`/operation/${wopid}`);
- }}
- onRegister={() => {
- route("/register");
- }}
- />
- )}
- />
- <Route
- path="/business"
- component={() => (
- <BusinessAccount
- onClose={() => {
- route("/account");
- }}
- onRegister={() => {
- route("/register");
- }}
- onLoadNotOk={() => {
- route("/account");
- }}
- />
- )}
- />
- <Route default component={Redirect} to="/account" />
- </Router>
- </BankFrame>
- );
-}
-
-function Redirect({ to }: { to: string }): VNode {
- useEffect(() => {
- route(to, true);
- }, []);
- return <div>being redirected to {to}</div>;
-}
-
-export function assertUnreachable(x: never): never {
- throw new Error("Didn't expect to get here");
-}
diff --git a/packages/demobank-ui/src/pages/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx
new file mode 100644
index 000000000..6acf0361e
--- /dev/null
+++ b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx
@@ -0,0 +1,167 @@
+import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
+import { useState } from "preact/hooks";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { buildRequestErrorMessage } from "../utils.js";
+import { AccountForm } from "./admin/AccountForm.js";
+
+export function ShowAccountDetails({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+ onChangePassword,
+}: {
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ onClear?: () => void;
+ onChangePassword: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useBusinessAccountDetails(account);
+ const { updateAccount } = useAdminAccountAPI();
+ const [update, setUpdate] = useState(false);
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+
+ 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);
+ }
+
+ async function doUpdate() {
+ 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) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The rights to change the account are not sufficient`
+ : status === HttpStatusCode.NotFound
+ ? i18n.str`The username was not found`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+ }
+
+ 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">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ {update ?
+ <i18n.Translate>Update account</i18n.Translate>
+ :
+ <i18n.Translate>Account details</i18n.Translate>
+ }
+ </h2>
+ <div class="mt-4">
+ <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>change the account details</i18n.Translate>
+ </span>
+ </span>
+ <button type="button" data-enabled={!update} class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 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={() => {
+ setUpdate(!update)
+ }}>
+ <span aria-hidden="true" data-enabled={!update} class="translate-x-5 data-[enabled=true]: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>
+ <AccountForm
+ template={result.data}
+ purpose={update ? "update" : "show"}
+ onChange={(a) => setSubmitAccount(a)}
+ >
+
+ </AccountForm>
+
+ <p class="buttons-account">
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ flexFlow: "wrap-reverse",
+ }}
+ >
+ <div>
+ {onClear ? (
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ ) : undefined}
+ </div>
+ <div style={{ display: "flex" }}>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={update && !submitAccount}
+ type="submit"
+ value={i18n.str`Change password`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onChangePassword();
+ }}
+ />
+ </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();
+ doUpdate()
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx
new file mode 100644
index 000000000..46f4fe0ef
--- /dev/null
+++ b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx
@@ -0,0 +1,177 @@
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
+import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
+import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
+import { doAutoFocus } from "./PaytoWireTransferForm.js";
+
+export function UpdateAccountPassword({
+ account,
+ onCancel,
+ onUpdateSuccess,
+ onLoadNotOk,
+ focus,
+}: {
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ onCancel: () => void;
+ focus?: boolean,
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useBusinessAccountDetails(account);
+ const { changePassword } = useAdminAccountAPI();
+ const [password, setPassword] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+
+ 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 errors = undefinedIfEmpty({
+ password: !password ? i18n.str`required` : undefined,
+ repeat: !repeat
+ ? i18n.str`required`
+ : password !== repeat
+ ? i18n.str`password doesn't match`
+ : undefined,
+ });
+
+ async function doChangePassword() {
+ if (!!errors || !password) return;
+ try {
+ const r = await changePassword(account, {
+ new_password: password,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(buildRequestErrorMessage(i18n, error.cause));
+ } 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>Update password for 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`New password`}
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="password"
+ 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?.password && password !== undefined}
+ value={password ?? ""}
+ onChange={(e) => {
+ setPassword(e.currentTarget.value)
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ {/* <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate>user </i18n.Translate>
+ </p> */}
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="repeat"
+ >
+ {i18n.str`Type it again`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="password"
+ 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="repeat"
+ id="repeat"
+ data-error={!!errors?.repeat && repeat !== undefined}
+ value={repeat ?? ""}
+ onChange={(e) => {
+ setRepeat(e.currentTarget.value)
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeat}
+ isDirty={repeat !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate>repeat the same password</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-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={!!errors}
+ onClick={(e) => {
+ e.preventDefault()
+ doChangePassword()
+ }}
+ >
+ <i18n.Translate>Change</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+
+ );
+} \ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx
index 4c4a38e57..da299b1c8 100644
--- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx
+++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx
@@ -19,40 +19,49 @@ import {
Amounts,
HttpStatusCode,
Logger,
+ TranslatedString,
+ WithdrawUriResult,
parseWithdrawUri,
} from "@gnu-taler/taler-util";
import {
RequestError,
+ notify,
+ notifyError,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { Ref, VNode, h } from "preact";
+import { Fragment, VNode, h } from "preact";
+import { forwardRef } from "preact/compat";
import { useEffect, useRef, useState } from "preact/hooks";
import { useAccessAPI } from "../hooks/access.js";
-import { notifyError } from "../hooks/notification.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
-import { forwardRef } from "preact/compat";
+import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js";
+import { useSettings } from "../hooks/settings.js";
+import { OperationState } from "./OperationState/index.js";
+import { Attention } from "../components/Attention.js";
const logger = new Logger("WalletWithdrawForm");
-const RefAmount = forwardRef(Amount);
+const RefAmount = forwardRef(InputAmount);
-export function WalletWithdrawForm({
- focus,
- limit,
- onSuccess,
-}: {
+
+function OldWithdrawalForm({ goToConfirmOperation, limit, onCancel, focus }: {
limit: AmountJson;
focus?: boolean;
- onSuccess: (operationId: string) => void;
+ goToConfirmOperation: (operationId: string) => void;
+ onCancel: () => void;
}): VNode {
const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings()
+
const { createWithdrawal } = useAccessAPI();
+ const [amountStr, setAmountStr] = useState<string | undefined>(`${settings.maxWithdrawalAmount}`);
- const [amountStr, setAmountStr] = useState<string | undefined>("5.00");
- const ref = useRef<HTMLInputElement>(null);
- useEffect(() => {
- if (focus) ref.current?.focus();
- }, [focus]);
+ if (!!settings.currentWithdrawalOperationId) {
+ return <Attention type="warning" title={i18n.str`There is an operation already`}>
+ <i18n.Translate>
+ To complete or cancel the operation click <a class="font-semibold text-yellow-700 hover:text-yellow-600" href={`#/operation/${settings.currentWithdrawalOperationId}`}>here</a>
+ </i18n.Translate>
+ </Attention>
+ }
const trimmedAmountStr = amountStr?.trim();
@@ -65,142 +74,186 @@ export function WalletWithdrawForm({
trimmedAmountStr == null
? i18n.str`required`
: !parsedAmount
- ? i18n.str`invalid`
- : Amounts.cmp(limit, parsedAmount) === -1
- ? i18n.str`balance is not enough`
- : undefined,
+ ? i18n.str`invalid`
+ : Amounts.cmp(limit, parsedAmount) === -1
+ ? i18n.str`balance is not enough`
+ : undefined,
});
- return (
- <form
- id="reserve-form"
- class="pure-form"
- name="tform"
- onSubmit={(e) => {
- e.preventDefault();
- }}
- autoCapitalize="none"
- autoCorrect="off"
- >
- <p>
- <label for="withdraw-amount">{i18n.str`Amount to withdraw:`}</label>
- &nbsp;
- <RefAmount
- currency={limit.currency}
- value={amountStr}
- onChange={(v) => {
- setAmountStr(v);
- }}
- error={errors?.amount}
- ref={ref}
- />
- </p>
- <p>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary"
- type="submit"
- disabled={!!errors}
- value={i18n.str`Withdraw`}
- onClick={async (e) => {
- e.preventDefault();
- if (!parsedAmount) return;
- try {
- const result = await createWithdrawal({
- amount: Amounts.stringify(parsedAmount),
- });
- const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
- if (!uri) {
- return notifyError({
- title: i18n.str`Server responded with an invalid withdraw URI`,
- description: i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`,
- });
- } else {
- onSuccess(uri.withdrawalOperationId);
- }
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Forbidden
- ? i18n.str`The operation was rejected due to insufficient funds`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
- }
- }
+ async function doStart() {
+ if (!parsedAmount) return;
+ try {
+ const result = await createWithdrawal({
+ amount: Amounts.stringify(parsedAmount),
+ });
+ const uri = parseWithdrawUri(result.data.taler_withdraw_uri);
+ if (!uri) {
+ return notifyError(
+ i18n.str`Server responded with an invalid withdraw URI`,
+ i18n.str`Withdraw URI: ${result.data.taler_withdraw_uri}`);
+ } else {
+ updateSettings("currentWithdrawalOperationId", uri.withdrawalOperationId)
+ goToConfirmOperation(uri.withdrawalOperationId);
+ }
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The operation was rejected due to insufficient funds`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+
+ return <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2 mt-4"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={e => {
+ e.preventDefault()
+ }}
+ >
+ <div class="px-4 py-6 ">
+ <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label for="withdraw-amount">{i18n.str`Amount`}</label>
+ <RefAmount
+ currency={limit.currency}
+ value={amountStr}
+ name="withdraw-amount"
+ onChange={(v) => {
+ setAmountStr(v);
}}
+ error={errors?.amount}
+ ref={focus ? doAutoFocus : undefined}
/>
</div>
- </p>
- </form>
- );
+ </div>
+ <div class="mt-4">
+ <div class="sm:inline">
+
+ <button type="button"
+ class=" inline-flex px-6 py-4 text-sm items-center rounded-l-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("50.00")
+ }}
+ >
+ 50.00
+ </button>
+ <button type="button"
+ class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-r-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("25.00")
+ }}
+ >
+
+ 25.00
+ </button>
+ </div>
+ <div class="mt-4 sm:inline">
+ <button type="button"
+ class=" -ml-px -mr-px inline-flex px-6 py-4 text-sm items-center rounded-l-md sm:rounded-none bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("10.00")
+ }}
+ >
+ 10.00
+ </button>
+ <button type="button"
+ class=" inline-flex px-6 py-4 text-sm items-center rounded-r-md bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10"
+ onClick={(e) => {
+ e.preventDefault();
+ setAmountStr("5.00")
+ }}
+ >
+ 5.00
+ </button>
+ </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">
+ <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate></button>
+ <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={isRawPayto ? !!errorsPayto : !!errorsWire}
+ onClick={(e) => {
+ e.preventDefault()
+ doStart()
+ }}
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </button>
+ </div>
+
+ </form>
}
-export function Amount(
- {
- currency,
- value,
- error,
- onChange,
- }: {
- error?: string;
- currency: string;
- value: string | undefined;
- onChange?: (s: string) => void;
- },
- ref: Ref<HTMLInputElement>,
-): VNode {
- return (
- <div style={{ width: "max-content" }}>
- <div>
- <input
- type="text"
- readonly
- class="currency-indicator"
- size={currency.length}
- maxLength={currency.length}
- tabIndex={-1}
- style={{
- borderTopRightRadius: 0,
- borderBottomRightRadius: 0,
- borderRight: 0,
- }}
- value={currency}
+
+export function WalletWithdrawForm({
+ focus,
+ limit,
+ onCancel,
+ goToConfirmOperation,
+}: {
+ limit: AmountJson;
+ focus?: boolean;
+ goToConfirmOperation: (operationId: string) => void;
+ onCancel: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings()
+
+ 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>Prepare your wallet</i18n.Translate></h2>
+ <p class="mt-1 text-sm text-gray-500">
+ <i18n.Translate>After using your wallet you will need to confirm or cancel the operation on this site.</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="col-span-2">
+ {settings.showInstallWallet &&
+ <Attention title={i18n.str`You need a GNU Taler Wallet`} onClose={() => {
+ updateSettings("showInstallWallet", false);
+ }}>
+ <i18n.Translate>
+ If you don't have one yet you can follow the instruction <a target="_blank" rel="noreferrer noopener" class="font-semibold text-blue-700 hover:text-blue-600" href="https://taler.net/en/wallet.html">here</a>
+ </i18n.Translate>
+ </Attention>
+ }
+
+ {!settings.fastWithdrawal ?
+ <OldWithdrawalForm
+ focus={focus}
+ limit={limit}
+ onCancel={onCancel}
+ goToConfirmOperation={goToConfirmOperation}
/>
- <input
- type="number"
- ref={ref}
- name="amount"
- id="amount"
- placeholder="0"
- style={{
- borderTopLeftRadius: 0,
- borderBottomLeftRadius: 0,
- borderLeft: 0,
- width: 150,
- color: "black",
- }}
- value={value ?? ""}
- disabled={!onChange}
- onInput={(e): void => {
- if (onChange) {
- onChange(e.currentTarget.value);
- }
- }}
+ :
+ <OperationState
+ currency={limit.currency}
+ onClose={onCancel}
/>
- </div>
- <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+ }
</div>
+ </div>
);
}
+
diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
index cdb612155..ddcd2492d 100644
--- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
+++ b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -15,26 +15,41 @@
*/
import {
+ AmountJson,
+ Amounts,
HttpStatusCode,
Logger,
- WithdrawUriResult,
+ PaytoUri,
+ PaytoUriIBAN,
+ PaytoUriTalerBank,
+ TranslatedString,
+ WithdrawUriResult
} from "@gnu-taler/taler-util";
import {
RequestError,
+ notify,
+ notifyError,
+ notifyInfo,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useMemo, useState } from "preact/hooks";
+import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
import { useAccessAnonAPI } from "../hooks/access.js";
-import { notifyError } from "../hooks/notification.js";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
+import { useSettings } from "../hooks/settings.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
const logger = new Logger("WithdrawalConfirmationQuestion");
interface Props {
onAborted: () => void;
withdrawUri: WithdrawUriResult;
+ details: {
+ account: PaytoUri,
+ reserve: string,
+ amount: AmountJson,
+ }
}
/**
* Additional authentication required to complete the operation.
@@ -42,9 +57,11 @@ interface Props {
*/
export function WithdrawalConfirmationQuestion({
onAborted,
+ details,
withdrawUri,
}: Props): VNode {
const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = useSettings()
const captchaNumbers = useMemo(() => {
return {
@@ -56,139 +73,263 @@ export function WithdrawalConfirmationQuestion({
const { confirmWithdrawal, abortWithdrawal } = useAccessAnonAPI();
const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>();
const answer = parseInt(captchaAnswer ?? "", 10);
+ const [busy, setBusy] = useState<Record<string, undefined>>()
const errors = undefinedIfEmpty({
answer: !captchaAnswer
? i18n.str`Answer the question before continue`
: Number.isNaN(answer)
- ? i18n.str`The answer should be a number`
- : answer !== captchaNumbers.a + captchaNumbers.b
- ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
- : undefined,
- });
+ ? i18n.str`The answer should be a number`
+ : answer !== captchaNumbers.a + captchaNumbers.b
+ ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`
+ : undefined,
+ }) ?? busy;
+
+ async function doTransfer() {
+ try {
+ setBusy({})
+ await confirmWithdrawal(
+ withdrawUri.withdrawalOperationId,
+ );
+ if (!settings.showWithdrawalSuccess) {
+ notifyInfo(i18n.str`Wire transfer completed!`)
+ }
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`The withdrawal has been aborted previously and can't be confirmed`
+ : status === HttpStatusCode.UnprocessableEntity
+ ? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ setBusy(undefined)
+ }
+
+ async function doCancel() {
+ try {
+ setBusy({})
+ await abortWithdrawal(withdrawUri.withdrawalOperationId);
+ onAborted();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Conflict
+ ? i18n.str`The reserve operation has been confirmed previously and can't be aborted`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ setBusy(undefined)
+ }
+
return (
<Fragment>
- <h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1>
- <article>
- <div class="challenge-div">
- <form
- class="challenge-form"
- noValidate
- onSubmit={(e) => {
- e.preventDefault();
- }}
- autoCapitalize="none"
- autoCorrect="off"
- >
- <div class="pure-form" id="captcha" name="capcha-form">
- <h2>{i18n.str`Authorize withdrawal by solving challenge`}</h2>
- <p>
- <label for="answer">
- {i18n.str`What is`}&nbsp;
- <em>
- {captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
- </em>
- ?&nbsp;
- </label>
- &nbsp;
- <input
- name="answer"
- id="answer"
- value={captchaAnswer ?? ""}
- type="text"
- autoFocus
- required
- onInput={(e): void => {
- setCaptchaAnswer(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.answer}
- isDirty={captchaAnswer !== undefined}
- />
- </p>
- <p>
- <button
- type="submit"
- class="pure-button pure-button-primary btn-confirm"
- disabled={!!errors}
- onClick={async (e) => {
- e.preventDefault();
- try {
- await confirmWithdrawal(
- withdrawUri.withdrawalOperationId,
- );
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Conflict
- ? i18n.str`The withdrawal has been aborted previously and can't be confirmed`
- : status === HttpStatusCode.UnprocessableEntity
- ? i18n.str`The withdraw operation cannot be confirmed because no exchange and reserve public key selection happened before`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
+ <div class="bg-white shadow sm:rounded-lg">
+ <div class="px-4 py-5 sm:p-6">
+ <h3 class="text-base font-semibold text-gray-900">
+ <i18n.Translate>Confirm the withdrawal operation</i18n.Translate>
+ </h3>
+ <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 sm:grid-cols-3 sm:gap-x-3">
+
+ <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-noneborder-indigo-600 ring-2 ring-indigo-600"}>
+ <input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" />
+ <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>challenge response test</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600" 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>
+
+
+ <label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
+ <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
+ <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>
+ <span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>not available</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600 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>
+
+ <label class="relative flex cursor-pointer rounded-lg border bg-gray-100 p-4 shadow-sm focus:outline-none border-gray-300">
+ <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" />
+ <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>one time password</i18n.Translate>
+ </span>
+ <span id="project-type-1-description-0" class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>not available</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ <svg class="h-5 w-5 text-indigo-600 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 class="mt-3 text-sm leading-6">
+
+ <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 text-gray-900"><i18n.Translate>Answer the next question to authorize the wire transfer.</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">
+ <label for="withdraw-amount">{i18n.str`What is`}&nbsp;
+ <em>
+ {captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b}
+ </em>
+ ?
+ </label>
+ <div class="mt-2">
+ <div class="relative rounded-md shadow-sm">
+ <input
+ type="text"
+ // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ aria-describedby="answer"
+ autoFocus
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ value={captchaAnswer ?? ""}
+ required
+
+ name="answer"
+ id="answer"
+ autocomplete="off"
+ onChange={(e): void => {
+ setCaptchaAnswer(e.currentTarget.value)
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} />
+ </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">
+ <button type="button" class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={doCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate></button>
+ <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={!!errors}
+ onClick={(e) => {
+ e.preventDefault()
+ doTransfer()
+ }}
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </button>
+ </div>
+
+ </form>
+ </div>
+ </div>
+ <div class="px-4 mt-4 ">
+ <div class="w-full">
+ <div class="px-4 sm:px-0 text-sm">
+ <p><i18n.Translate>Wire transfer details</i18n.Translate></p>
+ </div>
+ <div class="mt-6 border-t border-gray-100">
+ <dl class="divide-y divide-gray-100">
+ {((): VNode => {
+ switch (details.account.targetType) {
+ case "iban": {
+ const p = details.account as PaytoUriIBAN
+ const name = p.params["receiver-name"]
+ return <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd>
+ </div>
+ {name &&
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
+ </div>
+ }
+ </Fragment>
}
- }
- }}
- >
- {i18n.str`Confirm`}
- </button>
- &nbsp;
- <button
- class="pure-button pure-button-secondary btn-cancel"
- onClick={async (e) => {
- e.preventDefault();
- try {
- await abortWithdrawal(withdrawUri.withdrawalOperationId);
- onAborted();
- } catch (error) {
- if (error instanceof RequestError) {
- notifyError(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Conflict
- ? i18n.str`The reserve operation has been confirmed previously and can't be aborted`
- : undefined,
- }),
- );
- } else {
- notifyError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
+ case "x-taler-bank": {
+ const p = details.account as PaytoUriTalerBank
+ const name = p.params["receiver-name"]
+ return <Fragment>
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd>
+ </div>
+ {name &&
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd>
+ </div>
+ }
+ </Fragment>
}
+ default:
+ return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd>
+ </div>
+
}
- }}
- >
- {i18n.str`Cancel`}
- </button>
- </p>
+ })()}
+ <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
+ <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount value={details.amount} />
+ </dd>
+ </div>
+ </dl>
+ </div>
</div>
- </form>
- <div class="hint">
- <p>
- <i18n.Translate>
- A this point, a <b>real</b> bank would ask for an additional
- authentication proof (PIN/TAN, one time password, ..), instead
- of a simple calculation.
- </i18n.Translate>
- </p>
+
</div>
</div>
- </article>
+ </div>
+
</Fragment>
);
}
diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
index 80fdac3c8..91c5da718 100644
--- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
+++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
@@ -15,15 +15,16 @@
*/
import {
+ Amounts,
HttpStatusCode,
Logger,
WithdrawUriResult,
+ parsePaytoUri
} from "@gnu-taler/taler-util";
-import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { ErrorType, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { Loading } from "../components/Loading.js";
import { useWithdrawalDetails } from "../hooks/access.js";
-import { notifyInfo } from "../hooks/notification.js";
import { useSettings } from "../hooks/settings.js";
import { handleNotOkResult } from "./HomePage.js";
import { QrCodeSection } from "./QrCodeSection.js";
@@ -33,8 +34,7 @@ const logger = new Logger("WithdrawalQRCode");
interface Props {
withdrawUri: WithdrawUriResult;
- onContinue: () => void;
- onLoadNotOk: () => void;
+ onClose: () => void;
}
/**
* Offer the QR code (and a clickable taler://-link) to
@@ -43,27 +43,15 @@ interface Props {
*/
export function WithdrawalQRCode({
withdrawUri,
- onContinue,
- onLoadNotOk,
+ onClose,
}: Props): VNode {
- const [settings, updateSettings] = useSettings();
- function clearCurrentWithdrawal(): void {
- updateSettings("currentWithdrawalOperationId", undefined);
- onContinue();
- }
const { i18n } = useTranslationContext();
const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
+
if (!result.ok) {
if (result.loading) {
return <Loading />;
}
- if (
- result.type === ErrorType.CLIENT &&
- result.status === HttpStatusCode.NotFound
- ) {
- return <div>operation not found</div>;
- }
- onLoadNotOk();
return handleNotOkResult(i18n)(result);
}
const { data } = result;
@@ -84,12 +72,11 @@ export function WithdrawalQRCode({
</i18n.Translate>
</p>
<a class="pure-button pure-button-primary"
- style={{float:"right"}}
+ style={{ float: "right" }}
onClick={async (e) => {
e.preventDefault();
- clearCurrentWithdrawal()
- onContinue()
- }}>
+ onClose()
+ }}>
{i18n.str`Continue`}
</a>
@@ -98,57 +85,77 @@ export function WithdrawalQRCode({
}
if (data.confirmation_done) {
- return <section id="main" class="content">
- <h1 class="nav">{i18n.str`Operation completed`}</h1>
+ return <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
+ <div>
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
+ <svg class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
+ </svg>
+ </div>
+ <div class="mt-3 text-center sm:mt-5">
+ <h3 class="text-base font-semibold leading-6 text-gray-900" id="modal-title">
+ <i18n.Translate>Withdrawal confirmed</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ The wire transfer to the Taler operator has been initiated. You will soon receive the requested amount in your Taler wallet.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="mt-5 sm:mt-6">
+ <button type="button"
+ 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"
+ onClick={async (e) => {
+ e.preventDefault();
+ onClose()
+ }}>
+ <i18n.Translate>Done</i18n.Translate>
+ </button>
+ </div>
+ </div>
- <section id="assets" style={{maxWidth: 400, marginLeft: "auto", marginRight:"auto"}}>
- <p>
- <i18n.Translate>
- The wire transfer to the GNU Taler Exchange bank's account is completed, now the
- exchange will send the requested amount into your GNU Taler wallet.
- </i18n.Translate>
- </p>
- <p>
- <i18n.Translate>
- You can close this page now or continue to the account page.
- </i18n.Translate>
- </p>
- <div style={{textAlign:"center"}}>
- <a class="pure-button pure-button-primary"
- onClick={async (e) => {
- e.preventDefault();
- clearCurrentWithdrawal()
- onContinue()
- }}>
- {i18n.str`Continue`}
- </a>
- </div>
- </section>
- </section>
}
-
if (!data.selection_done) {
return (
<QrCodeSection
withdrawUri={withdrawUri}
onAborted={() => {
notifyInfo(i18n.str`Operation canceled`);
- clearCurrentWithdrawal()
- onContinue()
- }}
+ onClose()
+ }}
/>
);
}
+ if (!data.selected_reserve_pub) {
+ return <div>
+ the exchange is selcted but no reserve pub
+ </div>
+ }
+
+ const account = !data.selected_exchange_account ? undefined : parsePaytoUri(data.selected_exchange_account)
+
+ if (!account) {
+ return <div>
+ the exchange is selcted but no account
+ </div>
+ }
return (
<WithdrawalConfirmationQuestion
withdrawUri={withdrawUri}
+ details={{
+ account,
+ reserve: data.selected_reserve_pub,
+ amount: Amounts.parseOrThrow(data.amount)
+ }}
onAborted={() => {
notifyInfo(i18n.str`Operation canceled`);
- clearCurrentWithdrawal()
- onContinue()
- }}
+ onClose()
+ }}
/>
);
-} \ No newline at end of file
+}
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>
+ );
+}
diff --git a/packages/demobank-ui/src/pages/BusinessAccount.tsx b/packages/demobank-ui/src/pages/business/Home.tsx
index d9aa8fa36..1a84effcd 100644
--- a/packages/demobank-ui/src/pages/BusinessAccount.tsx
+++ b/packages/demobank-ui/src/pages/business/Home.tsx
@@ -17,65 +17,63 @@ import {
AmountJson,
Amounts,
HttpStatusCode,
- TranslatedString,
+ TranslatedString
} from "@gnu-taler/taler-util";
import {
HttpResponse,
HttpResponsePaginated,
RequestError,
+ notify,
+ notifyError,
+ notifyInfo,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
-import { StateUpdater, useEffect, useState } from "preact/hooks";
-import { Cashouts } from "../components/Cashouts/index.js";
-import { useBackendContext } from "../context/backend.js";
-import { useAccountDetails } from "../hooks/access.js";
+import { useEffect, useState } from "preact/hooks";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { useAccountDetails } from "../../hooks/access.js";
import {
useCashoutDetails,
useCircuitAccountAPI,
useEstimator,
useRatiosAndFeeConfig,
-} from "../hooks/circuit.js";
+} from "../../hooks/circuit.js";
import {
TanChannel,
buildRequestErrorMessage,
undefinedIfEmpty,
-} from "../utils.js";
-import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js";
-import { ErrorBannerFloat } from "./BankFrame.js";
-import { LoginForm } from "./LoginForm.js";
-import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
-import { handleNotOkResult } from "./HomePage.js";
-import { ErrorMessage, notifyInfo } from "../hooks/notification.js";
-import { Amount } from "./WalletWithdrawForm.js";
+} from "../../utils.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { InputAmount } from "../PaytoWireTransferForm.js";
+import { ShowAccountDetails } from "../ShowAccountDetails.js";
+import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
interface Props {
+ account: string,
onClose: () => void;
onRegister: () => void;
onLoadNotOk: () => void;
}
export function BusinessAccount({
onClose,
+ account,
onLoadNotOk,
onRegister,
}: Props): VNode {
const { i18n } = useTranslationContext();
- const backend = useBackendContext();
const [updatePassword, setUpdatePassword] = useState(false);
const [newCashout, setNewcashout] = useState(false);
const [showCashoutDetails, setShowCashoutDetails] = useState<
string | undefined
>();
- if (backend.state.status === "loggedOut") {
- return <LoginForm onRegister={onRegister} />;
- }
if (newCashout) {
return (
<CreateCashout
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ account={account}
+ onLoadNotOk={handleNotOkResult(i18n)}
onCancel={() => {
setNewcashout(false);
}}
@@ -93,7 +91,7 @@ export function BusinessAccount({
return (
<ShowCashoutDetails
id={showCashoutDetails}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onLoadNotOk={handleNotOkResult(i18n)}
onCancel={() => {
setShowCashoutDetails(undefined);
}}
@@ -103,13 +101,13 @@ export function BusinessAccount({
if (updatePassword) {
return (
<UpdateAccountPassword
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ account={account}
+ onLoadNotOk={handleNotOkResult(i18n)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Password changed`);
setUpdatePassword(false);
}}
- onClear={() => {
+ onCancel={() => {
setUpdatePassword(false);
}}
/>
@@ -118,8 +116,8 @@ export function BusinessAccount({
return (
<div>
<ShowAccountDetails
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ account={account}
+ onLoadNotOk={handleNotOkResult(i18n)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`);
}}
@@ -132,7 +130,7 @@ export function BusinessAccount({
<div class="active">
<h3>{i18n.str`Latest cashouts`}</h3>
<Cashouts
- account={backend.state.username}
+ account={account}
onSelected={(id) => {
setShowCashoutDetails(id);
}}
@@ -201,13 +199,13 @@ function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse<
(result.data.name !== oldResult.name ||
result.data.version !== oldResult.version ||
result.data.ratios_and_fees.buy_at_ratio !==
- oldResult.ratios_and_fees.buy_at_ratio ||
+ oldResult.ratios_and_fees.buy_at_ratio ||
result.data.ratios_and_fees.buy_in_fee !==
- oldResult.ratios_and_fees.buy_in_fee ||
+ oldResult.ratios_and_fees.buy_in_fee ||
result.data.ratios_and_fees.sell_at_ratio !==
- oldResult.ratios_and_fees.sell_at_ratio ||
+ oldResult.ratios_and_fees.sell_at_ratio ||
result.data.ratios_and_fees.sell_out_fee !==
- oldResult.ratios_and_fees.sell_out_fee ||
+ oldResult.ratios_and_fees.sell_out_fee ||
result.data.fiat_currency !== oldResult.fiat_currency);
return {
@@ -225,7 +223,6 @@ function CreateCashout({
const { i18n } = useTranslationContext();
const ratiosResult = useRatiosAndFeeConfig();
const result = useAccountDetails(account);
- const [error, saveError] = useState<ErrorMessage | undefined>();
const {
estimateByCredit: calculateFromCredit,
estimateByDebit: calculateFromDebit,
@@ -238,9 +235,10 @@ function CreateCashout({
const config = ratiosResult.data;
const balance = Amounts.parseOrThrow(result.data.balance.amount);
- const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
- const zero = Amounts.zeroOfCurrency(balance.currency);
const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
+
+ const debitThreshold = Amounts.parseOrThrow(result.data.debit_threshold);
+ const zero = Amounts.zeroOfCurrency(balance.currency);
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
@@ -251,15 +249,14 @@ function CreateCashout({
const sellFee = !config.ratios_and_fees.sell_out_fee
? zero
: Amounts.parseOrThrow(
- `${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
- );
+ `${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
+ );
const fiatCurrency = config.fiat_currency;
if (!sellRate || sellRate < 0) return <div>error rate</div>;
const amount = Amounts.parseOrThrow(
- `${!form.isDebit ? fiatCurrency : balance.currency}:${
- !form.amount ? "0" : form.amount
+ `${!form.isDebit ? fiatCurrency : balance.currency}:${!form.amount ? "0" : form.amount
}`,
);
@@ -268,32 +265,32 @@ function CreateCashout({
calculateFromDebit(amount, sellFee, sellRate)
.then((r) => {
setCalc(r);
- saveError(undefined);
})
.catch((error) => {
- saveError(
+ notify(
error instanceof RequestError
? buildRequestErrorMessage(i18n, error.cause)
: {
- title: i18n.str`Could not estimate the cashout`,
- description: error.message,
- },
+ type: "error",
+ title: i18n.str`Could not estimate the cashout`,
+ description: error.message as TranslatedString
+ },
);
});
} else {
calculateFromCredit(amount, sellFee, sellRate)
.then((r) => {
setCalc(r);
- saveError(undefined);
})
.catch((error) => {
- saveError(
+ notify(
error instanceof RequestError
? buildRequestErrorMessage(i18n, error.cause)
: {
- title: i18n.str`Could not estimate the cashout`,
- description: error.message,
- },
+ type: "error",
+ title: i18n.str`Could not estimate the cashout`,
+ description: error.message,
+ },
);
});
}
@@ -308,22 +305,19 @@ function CreateCashout({
amount: !form.amount
? i18n.str`required`
: !amount
- ? i18n.str`could not be parsed`
- : Amounts.cmp(limit, calc.debit) === -1
- ? i18n.str`balance is not enough`
- : Amounts.cmp(calc.beforeFee, sellFee) === -1
- ? i18n.str`the total amount to transfer does not cover the fees`
- : Amounts.isZero(calc.credit)
- ? i18n.str`the total transfer at destination will be zero`
- : undefined,
+ ? i18n.str`could not be parsed`
+ : Amounts.cmp(limit, calc.debit) === -1
+ ? i18n.str`balance is not enough`
+ : Amounts.cmp(calc.beforeFee, sellFee) === -1
+ ? i18n.str`the total amount to transfer does not cover the fees`
+ : Amounts.isZero(calc.credit)
+ ? i18n.str`the total transfer at destination will be zero`
+ : undefined,
channel: !form.channel ? i18n.str`required` : undefined,
});
return (
<div>
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
<h1>New cashout</h1>
<form class="pure-form">
<fieldset>
@@ -341,13 +335,15 @@ function CreateCashout({
/>
</fieldset>
<fieldset>
- <label>
+ <label for="amount">
{form.isDebit
? i18n.str`Amount to send`
: i18n.str`Amount to receive`}
+
</label>
<div style={{ display: "flex" }}>
- <Amount
+ <InputAmount
+ name="amount"
currency={amount.currency}
value={form.amount}
onChange={(v) => {
@@ -362,7 +358,6 @@ function CreateCashout({
type="checkbox"
name="asd"
onChange={(e): void => {
- console.log("asdasd", form.isDebit);
form.isDebit = !form.isDebit;
updateForm(structuredClone(form));
}}
@@ -376,24 +371,27 @@ function CreateCashout({
<input value={sellRate} disabled />
</fieldset>
<fieldset>
- <label>{i18n.str`Balance now`}</label>
- <Amount
+ <label for="balance-now">{i18n.str`Balance now`}</label>
+ <InputAmount
+ name="banace-now"
currency={balance.currency}
value={Amounts.stringifyValue(balance)}
/>
</fieldset>
<fieldset>
- <label
+ <label for="total-cost"
style={{ fontWeight: "bold", color: "red" }}
>{i18n.str`Total cost`}</label>
- <Amount
+ <InputAmount
+ name="total-cost"
currency={balance.currency}
value={Amounts.stringifyValue(calc.debit)}
/>
</fieldset>
<fieldset>
- <label>{i18n.str`Balance after`}</label>
- <Amount
+ <label for="balance-after">{i18n.str`Balance after`}</label>
+ <InputAmount
+ name="balance-after"
currency={balance.currency}
value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
/>
@@ -401,16 +399,18 @@ function CreateCashout({
{Amounts.isZero(sellFee) ? undefined : (
<Fragment>
<fieldset>
- <label>{i18n.str`Amount after conversion`}</label>
- <Amount
+ <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label>
+ <InputAmount
+ name="amount-conversion"
currency={fiatCurrency}
value={Amounts.stringifyValue(calc.beforeFee)}
/>
</fieldset>
<fieldset>
- <label>{i18n.str`Cashout fee`}</label>
- <Amount
+ <label form="cashout-fee">{i18n.str`Cashout fee`}</label>
+ <InputAmount
+ name="cashout-fee"
currency={fiatCurrency}
value={Amounts.stringifyValue(sellFee)}
/>
@@ -418,10 +418,11 @@ function CreateCashout({
</Fragment>
)}
<fieldset>
- <label
+ <label for="total"
style={{ fontWeight: "bold", color: "green" }}
>{i18n.str`Total cashout transfer`}</label>
- <Amount
+ <InputAmount
+ name="total"
currency={fiatCurrency}
value={Amounts.stringifyValue(calc.credit)}
/>
@@ -511,18 +512,18 @@ function CreateCashout({
onComplete(res.data.uuid);
} catch (error) {
if (error instanceof RequestError) {
- saveError(
+ notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.BadRequest
? i18n.str`The exchange rate was incorrectly applied`
: status === HttpStatusCode.Forbidden
- ? i18n.str`A institutional user tried the operation`
- : status === HttpStatusCode.Conflict
- ? i18n.str`Need a contact data where to send the TAN`
- : status === HttpStatusCode.PreconditionFailed
- ? i18n.str`The account does not have sufficient funds`
- : undefined,
+ ? i18n.str`A institutional user tried the operation`
+ : status === HttpStatusCode.Conflict
+ ? i18n.str`Need a contact data where to send the TAN`
+ : status === HttpStatusCode.PreconditionFailed
+ ? i18n.str`The account does not have sufficient funds`
+ : undefined,
onServerError: (status) =>
status === HttpStatusCode.ServiceUnavailable
? i18n.str`The bank does not support the TAN channel for this operation`
@@ -530,13 +531,12 @@ function CreateCashout({
}),
);
} else {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
}
}
}}
@@ -565,7 +565,6 @@ export function ShowCashoutDetails({
const result = useCashoutDetails(id);
const { abortCashout, confirmCashout } = useCircuitAccountAPI();
const [code, setCode] = useState<string | undefined>(undefined);
- const [error, saveError] = useState<ErrorMessage | undefined>();
if (!result.ok) return onLoadNotOk(result);
const errors = undefinedIfEmpty({
code: !code ? i18n.str`required` : undefined,
@@ -574,9 +573,6 @@ export function ShowCashoutDetails({
return (
<div>
<h1>Cashout details {id}</h1>
- {error && (
- <ErrorBannerFloat error={error} onClear={() => saveError(undefined)} />
- )}
<form class="pure-form">
<fieldset>
<label>
@@ -661,24 +657,23 @@ export function ShowCashoutDetails({
onCancel();
} catch (error) {
if (error instanceof RequestError) {
- saveError(
+ notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.NotFound
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
: status === HttpStatusCode.PreconditionFailed
- ? i18n.str`Cashout was already confimed`
- : undefined,
+ ? i18n.str`Cashout was already confimed`
+ : undefined,
}),
);
} else {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
}
}
}}
@@ -699,28 +694,27 @@ export function ShowCashoutDetails({
});
} catch (error) {
if (error instanceof RequestError) {
- saveError(
+ notify(
buildRequestErrorMessage(i18n, error.cause, {
onClientError: (status) =>
status === HttpStatusCode.NotFound
? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
: status === HttpStatusCode.PreconditionFailed
- ? i18n.str`Cashout was already confimed`
- : status === HttpStatusCode.Conflict
- ? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
- : status === HttpStatusCode.Forbidden
- ? i18n.str`Invalid code`
- : undefined,
+ ? i18n.str`Cashout was already confimed`
+ : status === HttpStatusCode.Conflict
+ ? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
+ : status === HttpStatusCode.Forbidden
+ ? i18n.str`Invalid code`
+ : undefined,
}),
);
} else {
- saveError({
- title: i18n.str`Operation failed, please report`,
- description:
- error instanceof Error
- ? error.message
- : JSON.stringify(error),
- });
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
}
}
}}
diff --git a/packages/demobank-ui/src/pages/rnd.ts b/packages/demobank-ui/src/pages/rnd.ts
new file mode 100644
index 000000000..32c3a934f
--- /dev/null
+++ b/packages/demobank-ui/src/pages/rnd.ts
@@ -0,0 +1,2895 @@
+import { createEddsaKeyPair, encodeCrock, getRandomBytes } from "@gnu-taler/taler-util"
+import { bankUiSettings } from "../settings.js"
+
+
+const noun = [
+ "people",
+ "history",
+ "way",
+ "art",
+ "world",
+ "information",
+ "map",
+ "two",
+ "family",
+ "government",
+ "health",
+ "system",
+ "computer",
+ "meat",
+ "year",
+ "thanks",
+ "music",
+ "person",
+ "reading",
+ "method",
+ "data",
+ "food",
+ "understanding",
+ "theory",
+ "law",
+ "bird",
+ "literature",
+ "problem",
+ "software",
+ "control",
+ "knowledge",
+ "power",
+ "ability",
+ "economics",
+ "love",
+ "internet",
+ "television",
+ "science",
+ "library",
+ "nature",
+ "fact",
+ "product",
+ "idea",
+ "temperature",
+ "investment",
+ "area",
+ "society",
+ "activity",
+ "story",
+ "industry",
+ "media",
+ "thing",
+ "oven",
+ "community",
+ "definition",
+ "safety",
+ "quality",
+ "development",
+ "language",
+ "management",
+ "player",
+ "variety",
+ "video",
+ "week",
+ "security",
+ "country",
+ "exam",
+ "movie",
+ "organization",
+ "equipment",
+ "physics",
+ "analysis",
+ "policy",
+ "series",
+ "thought",
+ "basis",
+ "boyfriend",
+ "direction",
+ "strategy",
+ "technology",
+ "army",
+ "camera",
+ "freedom",
+ "paper",
+ "environment",
+ "child",
+ "instance",
+ "month",
+ "truth",
+ "marketing",
+ "university",
+ "writing",
+ "article",
+ "department",
+ "difference",
+ "goal",
+ "news",
+ "audience",
+ "fishing",
+ "growth",
+ "income",
+ "marriage",
+ "user",
+ "combination",
+ "failure",
+ "meaning",
+ "medicine",
+ "philosophy",
+ "teacher",
+ "communication",
+ "night",
+ "chemistry",
+ "disease",
+ "disk",
+ "energy",
+ "nation",
+ "road",
+ "role",
+ "soup",
+ "advertising",
+ "location",
+ "success",
+ "addition",
+ "apartment",
+ "education",
+ "math",
+ "moment",
+ "painting",
+ "politics",
+ "attention",
+ "decision",
+ "event",
+ "property",
+ "shopping",
+ "student",
+ "wood",
+ "competition",
+ "distribution",
+ "entertainment",
+ "office",
+ "population",
+ "president",
+ "unit",
+ "category",
+ "cigarette",
+ "context",
+ "introduction",
+ "opportunity",
+ "performance",
+ "driver",
+ "flight",
+ "length",
+ "magazine",
+ "newspaper",
+ "relationship",
+ "teaching",
+ "cell",
+ "dealer",
+ "finding",
+ "lake",
+ "member",
+ "message",
+ "phone",
+ "scene",
+ "appearance",
+ "association",
+ "concept",
+ "customer",
+ "death",
+ "discussion",
+ "housing",
+ "inflation",
+ "insurance",
+ "mood",
+ "woman",
+ "advice",
+ "blood",
+ "effort",
+ "expression",
+ "importance",
+ "opinion",
+ "payment",
+ "reality",
+ "responsibility",
+ "situation",
+ "skill",
+ "statement",
+ "wealth",
+ "application",
+ "city",
+ "county",
+ "depth",
+ "estate",
+ "foundation",
+ "grandmother",
+ "heart",
+ "perspective",
+ "photo",
+ "recipe",
+ "studio",
+ "topic",
+ "collection",
+ "depression",
+ "imagination",
+ "passion",
+ "percentage",
+ "resource",
+ "setting",
+ "ad",
+ "agency",
+ "college",
+ "connection",
+ "criticism",
+ "debt",
+ "description",
+ "memory",
+ "patience",
+ "secretary",
+ "solution",
+ "administration",
+ "aspect",
+ "attitude",
+ "director",
+ "personality",
+ "psychology",
+ "recommendation",
+ "response",
+ "selection",
+ "storage",
+ "version",
+ "alcohol",
+ "argument",
+ "complaint",
+ "contract",
+ "emphasis",
+ "highway",
+ "loss",
+ "membership",
+ "possession",
+ "preparation",
+ "steak",
+ "union",
+ "agreement",
+ "cancer",
+ "currency",
+ "employment",
+ "engineering",
+ "entry",
+ "interaction",
+ "mixture",
+ "preference",
+ "region",
+ "republic",
+ "tradition",
+ "virus",
+ "actor",
+ "classroom",
+ "delivery",
+ "device",
+ "difficulty",
+ "drama",
+ "election",
+ "engine",
+ "football",
+ "guidance",
+ "hotel",
+ "owner",
+ "priority",
+ "protection",
+ "suggestion",
+ "tension",
+ "variation",
+ "anxiety",
+ "atmosphere",
+ "awareness",
+ "bath",
+ "bread",
+ "candidate",
+ "climate",
+ "comparison",
+ "confusion",
+ "construction",
+ "elevator",
+ "emotion",
+ "employee",
+ "employer",
+ "guest",
+ "height",
+ "leadership",
+ "mall",
+ "manager",
+ "operation",
+ "recording",
+ "sample",
+ "transportation",
+ "charity",
+ "cousin",
+ "disaster",
+ "editor",
+ "efficiency",
+ "excitement",
+ "extent",
+ "feedback",
+ "guitar",
+ "homework",
+ "leader",
+ "mom",
+ "outcome",
+ "permission",
+ "presentation",
+ "promotion",
+ "reflection",
+ "refrigerator",
+ "resolution",
+ "revenue",
+ "session",
+ "singer",
+ "tennis",
+ "basket",
+ "bonus",
+ "cabinet",
+ "childhood",
+ "church",
+ "clothes",
+ "coffee",
+ "dinner",
+ "drawing",
+ "hair",
+ "hearing",
+ "initiative",
+ "judgment",
+ "lab",
+ "measurement",
+ "mode",
+ "mud",
+ "orange",
+ "poetry",
+ "police",
+ "possibility",
+ "procedure",
+ "queen",
+ "ratio",
+ "relation",
+ "restaurant",
+ "satisfaction",
+ "sector",
+ "signature",
+ "significance",
+ "song",
+ "tooth",
+ "town",
+ "vehicle",
+ "volume",
+ "wife",
+ "accident",
+ "airport",
+ "appointment",
+ "arrival",
+ "assumption",
+ "baseball",
+ "chapter",
+ "committee",
+ "conversation",
+ "database",
+ "enthusiasm",
+ "error",
+ "explanation",
+ "farmer",
+ "gate",
+ "girl",
+ "hall",
+ "historian",
+ "hospital",
+ "injury",
+ "instruction",
+ "maintenance",
+ "manufacturer",
+ "meal",
+ "perception",
+ "pie",
+ "poem",
+ "presence",
+ "proposal",
+ "reception",
+ "replacement",
+ "revolution",
+ "river",
+ "son",
+ "speech",
+ "tea",
+ "village",
+ "warning",
+ "winner",
+ "worker",
+ "writer",
+ "assistance",
+ "breath",
+ "buyer",
+ "chest",
+ "chocolate",
+ "conclusion",
+ "contribution",
+ "cookie",
+ "courage",
+ "dad",
+ "desk",
+ "drawer",
+ "establishment",
+ "examination",
+ "garbage",
+ "grocery",
+ "honey",
+ "impression",
+ "improvement",
+ "independence",
+ "insect",
+ "inspection",
+ "inspector",
+ "king",
+ "ladder",
+ "menu",
+ "penalty",
+ "piano",
+ "potato",
+ "profession",
+ "professor",
+ "quantity",
+ "reaction",
+ "requirement",
+ "salad",
+ "sister",
+ "supermarket",
+ "tongue",
+ "weakness",
+ "wedding",
+ "affair",
+ "ambition",
+ "analyst",
+ "apple",
+ "assignment",
+ "assistant",
+ "bathroom",
+ "bedroom",
+ "beer",
+ "birthday",
+ "celebration",
+ "championship",
+ "cheek",
+ "client",
+ "consequence",
+ "departure",
+ "diamond",
+ "dirt",
+ "ear",
+ "fortune",
+ "friendship",
+ "funeral",
+ "gene",
+ "girlfriend",
+ "hat",
+ "indication",
+ "intention",
+ "lady",
+ "midnight",
+ "negotiation",
+ "obligation",
+ "passenger",
+ "pizza",
+ "platform",
+ "poet",
+ "pollution",
+ "recognition",
+ "reputation",
+ "shirt",
+ "sir",
+ "speaker",
+ "stranger",
+ "surgery",
+ "sympathy",
+ "tale",
+ "throat",
+ "trainer",
+ "uncle",
+ "youth",
+ "time",
+ "work",
+ "film",
+ "water",
+ "money",
+ "example",
+ "while",
+ "business",
+ "study",
+ "game",
+ "life",
+ "form",
+ "air",
+ "day",
+ "place",
+ "number",
+ "part",
+ "field",
+ "fish",
+ "back",
+ "process",
+ "heat",
+ "hand",
+ "experience",
+ "job",
+ "book",
+ "end",
+ "point",
+ "type",
+ "home",
+ "economy",
+ "value",
+ "body",
+ "market",
+ "guide",
+ "interest",
+ "state",
+ "radio",
+ "course",
+ "company",
+ "price",
+ "size",
+ "card",
+ "list",
+ "mind",
+ "trade",
+ "line",
+ "care",
+ "group",
+ "risk",
+ "word",
+ "fat",
+ "force",
+ "key",
+ "light",
+ "training",
+ "name",
+ "school",
+ "top",
+ "amount",
+ "level",
+ "order",
+ "practice",
+ "research",
+ "sense",
+ "service",
+ "piece",
+ "web",
+ "boss",
+ "sport",
+ "fun",
+ "house",
+ "page",
+ "term",
+ "test",
+ "answer",
+ "sound",
+ "focus",
+ "matter",
+ "kind",
+ "soil",
+ "board",
+ "oil",
+ "picture",
+ "access",
+ "garden",
+ "range",
+ "rate",
+ "reason",
+ "future",
+ "site",
+ "demand",
+ "exercise",
+ "image",
+ "case",
+ "cause",
+ "coast",
+ "action",
+ "age",
+ "bad",
+ "boat",
+ "record",
+ "result",
+ "section",
+ "building",
+ "mouse",
+ "cash",
+ "class",
+ "nothing",
+ "period",
+ "plan",
+ "store",
+ "tax",
+ "side",
+ "subject",
+ "space",
+ "rule",
+ "stock",
+ "weather",
+ "chance",
+ "figure",
+ "man",
+ "model",
+ "source",
+ "beginning",
+ "earth",
+ "program",
+ "chicken",
+ "design",
+ "feature",
+ "head",
+ "material",
+ "purpose",
+ "question",
+ "rock",
+ "salt",
+ "act",
+ "birth",
+ "car",
+ "dog",
+ "object",
+ "scale",
+ "sun",
+ "note",
+ "profit",
+ "rent",
+ "speed",
+ "style",
+ "war",
+ "bank",
+ "craft",
+ "half",
+ "inside",
+ "outside",
+ "standard",
+ "bus",
+ "exchange",
+ "eye",
+ "fire",
+ "position",
+ "pressure",
+ "stress",
+ "advantage",
+ "benefit",
+ "box",
+ "frame",
+ "issue",
+ "step",
+ "cycle",
+ "face",
+ "item",
+ "metal",
+ "paint",
+ "review",
+ "room",
+ "screen",
+ "structure",
+ "view",
+ "account",
+ "ball",
+ "discipline",
+ "medium",
+ "share",
+ "balance",
+ "bit",
+ "black",
+ "bottom",
+ "choice",
+ "gift",
+ "impact",
+ "machine",
+ "shape",
+ "tool",
+ "wind",
+ "address",
+ "average",
+ "career",
+ "culture",
+ "morning",
+ "pot",
+ "sign",
+ "table",
+ "task",
+ "condition",
+ "contact",
+ "credit",
+ "egg",
+ "hope",
+ "ice",
+ "network",
+ "north",
+ "square",
+ "attempt",
+ "date",
+ "effect",
+ "link",
+ "post",
+ "star",
+ "voice",
+ "capital",
+ "challenge",
+ "friend",
+ "self",
+ "shot",
+ "brush",
+ "couple",
+ "debate",
+ "exit",
+ "front",
+ "function",
+ "lack",
+ "living",
+ "plant",
+ "plastic",
+ "spot",
+ "summer",
+ "taste",
+ "theme",
+ "track",
+ "wing",
+ "brain",
+ "button",
+ "click",
+ "desire",
+ "foot",
+ "gas",
+ "influence",
+ "notice",
+ "rain",
+ "wall",
+ "base",
+ "damage",
+ "distance",
+ "feeling",
+ "pair",
+ "savings",
+ "staff",
+ "sugar",
+ "target",
+ "text",
+ "animal",
+ "author",
+ "budget",
+ "discount",
+ "file",
+ "ground",
+ "lesson",
+ "minute",
+ "officer",
+ "phase",
+ "reference",
+ "register",
+ "sky",
+ "stage",
+ "stick",
+ "title",
+ "trouble",
+ "bowl",
+ "bridge",
+ "campaign",
+ "character",
+ "club",
+ "edge",
+ "evidence",
+ "fan",
+ "letter",
+ "lock",
+ "maximum",
+ "novel",
+ "option",
+ "pack",
+ "park",
+ "plenty",
+ "quarter",
+ "skin",
+ "sort",
+ "weight",
+ "baby",
+ "background",
+ "carry",
+ "dish",
+ "factor",
+ "fruit",
+ "glass",
+ "joint",
+ "master",
+ "muscle",
+ "red",
+ "strength",
+ "traffic",
+ "trip",
+ "vegetable",
+ "appeal",
+ "chart",
+ "gear",
+ "ideal",
+ "kitchen",
+ "land",
+ "log",
+ "mother",
+ "net",
+ "party",
+ "principle",
+ "relative",
+ "sale",
+ "season",
+ "signal",
+ "spirit",
+ "street",
+ "tree",
+ "wave",
+ "belt",
+ "bench",
+ "commission",
+ "copy",
+ "drop",
+ "minimum",
+ "path",
+ "progress",
+ "project",
+ "sea",
+ "south",
+ "status",
+ "stuff",
+ "ticket",
+ "tour",
+ "angle",
+ "blue",
+ "breakfast",
+ "confidence",
+ "daughter",
+ "degree",
+ "doctor",
+ "dot",
+ "dream",
+ "duty",
+ "essay",
+ "father",
+ "fee",
+ "finance",
+ "hour",
+ "juice",
+ "limit",
+ "luck",
+ "milk",
+ "mouth",
+ "peace",
+ "pipe",
+ "seat",
+ "stable",
+ "storm",
+ "substance",
+ "team",
+ "trick",
+ "afternoon",
+ "bat",
+ "beach",
+ "blank",
+ "catch",
+ "chain",
+ "consideration",
+ "cream",
+ "crew",
+ "detail",
+ "gold",
+ "interview",
+ "kid",
+ "mark",
+ "match",
+ "mission",
+ "pain",
+ "pleasure",
+ "score",
+ "screw",
+ "sex",
+ "shop",
+ "shower",
+ "suit",
+ "tone",
+ "window",
+ "agent",
+ "band",
+ "block",
+ "bone",
+ "calendar",
+ "cap",
+ "coat",
+ "contest",
+ "corner",
+ "court",
+ "cup",
+ "district",
+ "door",
+ "east",
+ "finger",
+ "garage",
+ "guarantee",
+ "hole",
+ "hook",
+ "implement",
+ "layer",
+ "lecture",
+ "lie",
+ "manner",
+ "meeting",
+ "nose",
+ "parking",
+ "partner",
+ "profile",
+ "respect",
+ "rice",
+ "routine",
+ "schedule",
+ "swimming",
+ "telephone",
+ "tip",
+ "winter",
+ "airline",
+ "bag",
+ "battle",
+ "bed",
+ "bill",
+ "bother",
+ "cake",
+ "code",
+ "curve",
+ "designer",
+ "dimension",
+ "dress",
+ "ease",
+ "emergency",
+ "evening",
+ "extension",
+ "farm",
+ "fight",
+ "gap",
+ "grade",
+ "holiday",
+ "horror",
+ "horse",
+ "host",
+ "husband",
+ "loan",
+ "mistake",
+ "mountain",
+ "nail",
+ "noise",
+ "occasion",
+ "package",
+ "patient",
+ "pause",
+ "phrase",
+ "proof",
+ "race",
+ "relief",
+ "sand",
+ "sentence",
+ "shoulder",
+ "smoke",
+ "stomach",
+ "string",
+ "tourist",
+ "towel",
+ "vacation",
+ "west",
+ "wheel",
+ "wine",
+ "arm",
+ "aside",
+ "associate",
+ "bet",
+ "blow",
+ "border",
+ "branch",
+ "breast",
+ "brother",
+ "buddy",
+ "bunch",
+ "chip",
+ "coach",
+ "cross",
+ "document",
+ "draft",
+ "dust",
+ "expert",
+ "floor",
+ "god",
+ "golf",
+ "habit",
+ "iron",
+ "judge",
+ "knife",
+ "landscape",
+ "league",
+ "mail",
+ "mess",
+ "native",
+ "opening",
+ "parent",
+ "pattern",
+ "pin",
+ "pool",
+ "pound",
+ "request",
+ "salary",
+ "shame",
+ "shelter",
+ "shoe",
+ "silver",
+ "tackle",
+ "tank",
+ "trust",
+ "assist",
+ "bake",
+ "bar",
+ "bell",
+ "bike",
+ "blame",
+ "boy",
+ "brick",
+ "chair",
+ "closet",
+ "clue",
+ "collar",
+ "comment",
+ "conference",
+ "devil",
+ "diet",
+ "fear",
+ "fuel",
+ "glove",
+ "jacket",
+ "lunch",
+ "monitor",
+ "mortgage",
+ "nurse",
+ "pace",
+ "panic",
+ "peak",
+ "plane",
+ "reward",
+ "row",
+ "sandwich",
+ "shock",
+ "spite",
+ "spray",
+ "surprise",
+ "till",
+ "transition",
+ "weekend",
+ "welcome",
+ "yard",
+ "alarm",
+ "bend",
+ "bicycle",
+ "bite",
+ "blind",
+ "bottle",
+ "cable",
+ "candle",
+ "clerk",
+ "cloud",
+ "concert",
+ "counter",
+ "flower",
+ "grandfather",
+ "harm",
+ "knee",
+ "lawyer",
+ "leather",
+ "load",
+ "mirror",
+ "neck",
+ "pension",
+ "plate",
+ "purple",
+ "ruin",
+ "ship",
+ "skirt",
+ "slice",
+ "snow",
+ "specialist",
+ "stroke",
+ "switch",
+ "trash",
+ "tune",
+ "zone",
+ "anger",
+ "award",
+ "bid",
+ "bitter",
+ "boot",
+ "bug",
+ "camp",
+ "candy",
+ "carpet",
+ "cat",
+ "champion",
+ "channel",
+ "clock",
+ "comfort",
+ "cow",
+ "crack",
+ "engineer",
+ "entrance",
+ "fault",
+ "grass",
+ "guy",
+ "hell",
+ "highlight",
+ "incident",
+ "island",
+ "joke",
+ "jury",
+ "leg",
+ "lip",
+ "mate",
+ "motor",
+ "nerve",
+ "passage",
+ "pen",
+ "pride",
+ "priest",
+ "prize",
+ "promise",
+ "resident",
+ "resort",
+ "ring",
+ "roof",
+ "rope",
+ "sail",
+ "scheme",
+ "script",
+ "sock",
+ "station",
+ "toe",
+ "tower",
+ "truck",
+ "witness",
+ "a",
+ "you",
+ "it",
+ "can",
+ "will",
+ "if",
+ "one",
+ "many",
+ "most",
+ "other",
+ "use",
+ "make",
+ "good",
+ "look",
+ "help",
+ "go",
+ "great",
+ "being",
+ "few",
+ "might",
+ "still",
+ "public",
+ "read",
+ "keep",
+ "start",
+ "give",
+ "human",
+ "local",
+ "general",
+ "she",
+ "specific",
+ "long",
+ "play",
+ "feel",
+ "high",
+ "tonight",
+ "put",
+ "common",
+ "set",
+ "change",
+ "simple",
+ "past",
+ "big",
+ "possible",
+ "particular",
+ "today",
+ "major",
+ "personal",
+ "current",
+ "national",
+ "cut",
+ "natural",
+ "physical",
+ "show",
+ "try",
+ "check",
+ "second",
+ "call",
+ "move",
+ "pay",
+ "let",
+ "increase",
+ "single",
+ "individual",
+ "turn",
+ "ask",
+ "buy",
+ "guard",
+ "hold",
+ "main",
+ "offer",
+ "potential",
+ "professional",
+ "international",
+ "travel",
+ "cook",
+ "alternative",
+ "following",
+ "special",
+ "working",
+ "whole",
+ "dance",
+ "excuse",
+ "cold",
+ "commercial",
+ "low",
+ "purchase",
+ "deal",
+ "primary",
+ "worth",
+ "fall",
+ "necessary",
+ "positive",
+ "produce",
+ "search",
+ "present",
+ "spend",
+ "talk",
+ "creative",
+ "tell",
+ "cost",
+ "drive",
+ "green",
+ "support",
+ "glad",
+ "remove",
+ "return",
+ "run",
+ "complex",
+ "due",
+ "effective",
+ "middle",
+ "regular",
+ "reserve",
+ "independent",
+ "leave",
+ "original",
+ "reach",
+ "rest",
+ "serve",
+ "watch",
+ "beautiful",
+ "charge",
+ "active",
+ "break",
+ "negative",
+ "safe",
+ "stay",
+ "visit",
+ "visual",
+ "affect",
+ "cover",
+ "report",
+ "rise",
+ "walk",
+ "white",
+ "beyond",
+ "junior",
+ "pick",
+ "unique",
+ "anything",
+ "classic",
+ "final",
+ "lift",
+ "mix",
+ "private",
+ "stop",
+ "teach",
+ "western",
+ "concern",
+ "familiar",
+ "fly",
+ "official",
+ "broad",
+ "comfortable",
+ "gain",
+ "maybe",
+ "rich",
+ "save",
+ "stand",
+ "young",
+ "fail",
+ "heavy",
+ "hello",
+ "lead",
+ "listen",
+ "valuable",
+ "worry",
+ "handle",
+ "leading",
+ "meet",
+ "release",
+ "sell",
+ "finish",
+ "normal",
+ "press",
+ "ride",
+ "secret",
+ "spread",
+ "spring",
+ "tough",
+ "wait",
+ "brown",
+ "deep",
+ "display",
+ "flow",
+ "hit",
+ "objective",
+ "shoot",
+ "touch",
+ "cancel",
+ "chemical",
+ "cry",
+ "dump",
+ "extreme",
+ "push",
+ "conflict",
+ "eat",
+ "fill",
+ "formal",
+ "jump",
+ "kick",
+ "opposite",
+ "pass",
+ "pitch",
+ "remote",
+ "total",
+ "treat",
+ "vast",
+ "abuse",
+ "beat",
+ "burn",
+ "deposit",
+ "print",
+ "raise",
+ "sleep",
+ "somewhere",
+ "advance",
+ "anywhere",
+ "consist",
+ "dark",
+ "double",
+ "draw",
+ "equal",
+ "fix",
+ "hire",
+ "internal",
+ "join",
+ "kill",
+ "sensitive",
+ "tap",
+ "win",
+ "attack",
+ "claim",
+ "constant",
+ "drag",
+ "drink",
+ "guess",
+ "minor",
+ "pull",
+ "raw",
+ "soft",
+ "solid",
+ "wear",
+ "weird",
+ "wonder",
+ "annual",
+ "count",
+ "dead",
+ "doubt",
+ "feed",
+ "forever",
+ "impress",
+ "nobody",
+ "repeat",
+ "round",
+ "sing",
+ "slide",
+ "strip",
+ "whereas",
+ "wish",
+ "combine",
+ "command",
+ "dig",
+ "divide",
+ "equivalent",
+ "hang",
+ "hunt",
+ "initial",
+ "march",
+ "mention",
+ "smell",
+ "spiritual",
+ "survey",
+ "tie",
+ "adult",
+ "brief",
+ "crazy",
+ "escape",
+ "gather",
+ "hate",
+ "prior",
+ "repair",
+ "rough",
+ "sad",
+ "scratch",
+ "sick",
+ "strike",
+ "employ",
+ "external",
+ "hurt",
+ "illegal",
+ "laugh",
+ "lay",
+ "mobile",
+ "nasty",
+ "ordinary",
+ "respond",
+ "royal",
+ "senior",
+ "split",
+ "strain",
+ "struggle",
+ "swim",
+ "train",
+ "upper",
+ "wash",
+ "yellow",
+ "convert",
+ "crash",
+ "dependent",
+ "fold",
+ "funny",
+ "grab",
+ "hide",
+ "miss",
+ "permit",
+ "quote",
+ "recover",
+ "resolve",
+ "roll",
+ "sink",
+ "slip",
+ "spare",
+ "suspect",
+ "sweet",
+ "swing",
+ "twist",
+ "upstairs",
+ "usual",
+ "abroad",
+ "brave",
+ "calm",
+ "concentrate",
+ "estimate",
+ "grand",
+ "male",
+ "mine",
+ "prompt",
+ "quiet",
+ "refuse",
+ "regret",
+ "reveal",
+ "rush",
+ "shake",
+ "shift",
+ "shine",
+ "steal",
+ "suck",
+ "surround",
+ "anybody",
+ "bear",
+ "brilliant",
+ "dare",
+ "dear",
+ "delay",
+ "drunk",
+ "female",
+ "hurry",
+ "inevitable",
+ "invite",
+ "kiss",
+ "neat",
+ "pop",
+ "punch",
+ "quit",
+ "reply",
+ "representative",
+ "resist",
+ "rip",
+ "rub",
+ "silly",
+ "smile",
+ "spell",
+ "stretch",
+ "stupid",
+ "tear",
+ "temporary",
+ "tomorrow",
+ "wake",
+ "wrap",
+ "yesterday"
+]
+
+const adj = [
+ "abandoned",
+ "able",
+ "absolute",
+ "adorable",
+ "adventurous",
+ "academic",
+ "acceptable",
+ "acclaimed",
+ "accomplished",
+ "accurate",
+ "aching",
+ "acidic",
+ "acrobatic",
+ "active",
+ "actual",
+ "adept",
+ "admirable",
+ "admired",
+ "adolescent",
+ "adorable",
+ "adored",
+ "advanced",
+ "afraid",
+ "affectionate",
+ "aged",
+ "aggravating",
+ "aggressive",
+ "agile",
+ "agitated",
+ "agonizing",
+ "agreeable",
+ "ajar",
+ "alarmed",
+ "alarming",
+ "alert",
+ "alienated",
+ "alive",
+ "all",
+ "altruistic",
+ "amazing",
+ "ambitious",
+ "ample",
+ "amused",
+ "amusing",
+ "anchored",
+ "ancient",
+ "angelic",
+ "angry",
+ "anguished",
+ "animated",
+ "annual",
+ "another",
+ "antique",
+ "anxious",
+ "any",
+ "apprehensive",
+ "appropriate",
+ "apt",
+ "arctic",
+ "arid",
+ "aromatic",
+ "artistic",
+ "ashamed",
+ "assured",
+ "astonishing",
+ "athletic",
+ "attached",
+ "attentive",
+ "attractive",
+ "austere",
+ "authentic",
+ "authorized",
+ "automatic",
+ "avaricious",
+ "average",
+ "aware",
+ "awesome",
+ "awful",
+ "awkward",
+ "babyish",
+ "bad",
+ "back",
+ "baggy",
+ "bare",
+ "barren",
+ "basic",
+ "beautiful",
+ "belated",
+ "beloved",
+ "beneficial",
+ "better",
+ "best",
+ "bewitched",
+ "big",
+ "big-hearted",
+ "biodegradable",
+ "bite-sized",
+ "bitter",
+ "black",
+ "black-and-white",
+ "bland",
+ "blank",
+ "blaring",
+ "bleak",
+ "blind",
+ "blissful",
+ "blond",
+ "blue",
+ "blushing",
+ "bogus",
+ "boiling",
+ "bold",
+ "bony",
+ "boring",
+ "bossy",
+ "both",
+ "bouncy",
+ "bountiful",
+ "bowed",
+ "brave",
+ "breakable",
+ "brief",
+ "bright",
+ "brilliant",
+ "brisk",
+ "broken",
+ "bronze",
+ "brown",
+ "bruised",
+ "bubbly",
+ "bulky",
+ "bumpy",
+ "buoyant",
+ "burdensome",
+ "burly",
+ "bustling",
+ "busy",
+ "buttery",
+ "buzzing",
+ "calculating",
+ "calm",
+ "candid",
+ "canine",
+ "capital",
+ "carefree",
+ "careful",
+ "careless",
+ "caring",
+ "cautious",
+ "cavernous",
+ "celebrated",
+ "charming",
+ "cheap",
+ "cheerful",
+ "cheery",
+ "chief",
+ "chilly",
+ "chubby",
+ "circular",
+ "classic",
+ "clean",
+ "clear",
+ "clear-cut",
+ "clever",
+ "close",
+ "closed",
+ "cloudy",
+ "clueless",
+ "clumsy",
+ "cluttered",
+ "coarse",
+ "cold",
+ "colorful",
+ "colorless",
+ "colossal",
+ "comfortable",
+ "common",
+ "compassionate",
+ "competent",
+ "complete",
+ "complex",
+ "complicated",
+ "composed",
+ "concerned",
+ "concrete",
+ "confused",
+ "conscious",
+ "considerate",
+ "constant",
+ "content",
+ "conventional",
+ "cooked",
+ "cool",
+ "cooperative",
+ "coordinated",
+ "corny",
+ "corrupt",
+ "costly",
+ "courageous",
+ "courteous",
+ "crafty",
+ "crazy",
+ "creamy",
+ "creative",
+ "creepy",
+ "criminal",
+ "crisp",
+ "critical",
+ "crooked",
+ "crowded",
+ "cruel",
+ "crushing",
+ "cuddly",
+ "cultivated",
+ "cultured",
+ "cumbersome",
+ "curly",
+ "curvy",
+ "cute",
+ "cylindrical",
+ "damaged",
+ "damp",
+ "dangerous",
+ "dapper",
+ "daring",
+ "darling",
+ "dark",
+ "dazzling",
+ "dead",
+ "deadly",
+ "deafening",
+ "dear",
+ "dearest",
+ "decent",
+ "decimal",
+ "decisive",
+ "deep",
+ "defenseless",
+ "defensive",
+ "defiant",
+ "deficient",
+ "definite",
+ "definitive",
+ "delayed",
+ "delectable",
+ "delicious",
+ "delightful",
+ "delirious",
+ "demanding",
+ "dense",
+ "dental",
+ "dependable",
+ "dependent",
+ "descriptive",
+ "deserted",
+ "detailed",
+ "determined",
+ "devoted",
+ "different",
+ "difficult",
+ "digital",
+ "diligent",
+ "dim",
+ "dimpled",
+ "dimwitted",
+ "direct",
+ "disastrous",
+ "discrete",
+ "disfigured",
+ "disgusting",
+ "disloyal",
+ "dismal",
+ "distant",
+ "downright",
+ "dreary",
+ "dirty",
+ "disguised",
+ "dishonest",
+ "dismal",
+ "distant",
+ "distinct",
+ "distorted",
+ "dizzy",
+ "dopey",
+ "doting",
+ "double",
+ "downright",
+ "drab",
+ "drafty",
+ "dramatic",
+ "dreary",
+ "droopy",
+ "dry",
+ "dual",
+ "dull",
+ "dutiful",
+ "each",
+ "eager",
+ "earnest",
+ "early",
+ "easy",
+ "easy-going",
+ "ecstatic",
+ "edible",
+ "educated",
+ "elaborate",
+ "elastic",
+ "elated",
+ "elderly",
+ "electric",
+ "elegant",
+ "elementary",
+ "elliptical",
+ "embarrassed",
+ "embellished",
+ "eminent",
+ "emotional",
+ "empty",
+ "enchanted",
+ "enchanting",
+ "energetic",
+ "enlightened",
+ "enormous",
+ "enraged",
+ "entire",
+ "envious",
+ "equal",
+ "equatorial",
+ "essential",
+ "esteemed",
+ "ethical",
+ "euphoric",
+ "even",
+ "evergreen",
+ "everlasting",
+ "every",
+ "evil",
+ "exalted",
+ "excellent",
+ "exemplary",
+ "exhausted",
+ "excitable",
+ "excited",
+ "exciting",
+ "exotic",
+ "expensive",
+ "experienced",
+ "expert",
+ "extraneous",
+ "extroverted",
+ "extra-large",
+ "extra-small",
+ "fabulous",
+ "failing",
+ "faint",
+ "fair",
+ "faithful",
+ "fake",
+ "false",
+ "familiar",
+ "famous",
+ "fancy",
+ "fantastic",
+ "far",
+ "faraway",
+ "far-flung",
+ "far-off",
+ "fast",
+ "fat",
+ "fatal",
+ "fatherly",
+ "favorable",
+ "favorite",
+ "fearful",
+ "fearless",
+ "feisty",
+ "feline",
+ "female",
+ "feminine",
+ "few",
+ "fickle",
+ "filthy",
+ "fine",
+ "finished",
+ "firm",
+ "first",
+ "firsthand",
+ "fitting",
+ "fixed",
+ "flaky",
+ "flamboyant",
+ "flashy",
+ "flat",
+ "flawed",
+ "flawless",
+ "flickering",
+ "flimsy",
+ "flippant",
+ "flowery",
+ "fluffy",
+ "fluid",
+ "flustered",
+ "focused",
+ "fond",
+ "foolhardy",
+ "foolish",
+ "forceful",
+ "forked",
+ "formal",
+ "forsaken",
+ "forthright",
+ "fortunate",
+ "fragrant",
+ "frail",
+ "frank",
+ "frayed",
+ "free",
+ "French",
+ "fresh",
+ "frequent",
+ "friendly",
+ "frightened",
+ "frightening",
+ "frigid",
+ "frilly",
+ "frizzy",
+ "frivolous",
+ "front",
+ "frosty",
+ "frozen",
+ "frugal",
+ "fruitful",
+ "full",
+ "fumbling",
+ "functional",
+ "funny",
+ "fussy",
+ "fuzzy",
+ "gargantuan",
+ "gaseous",
+ "general",
+ "generous",
+ "gentle",
+ "genuine",
+ "giant",
+ "giddy",
+ "gigantic",
+ "gifted",
+ "giving",
+ "glamorous",
+ "glaring",
+ "glass",
+ "gleaming",
+ "gleeful",
+ "glistening",
+ "glittering",
+ "gloomy",
+ "glorious",
+ "glossy",
+ "glum",
+ "golden",
+ "good",
+ "good-natured",
+ "gorgeous",
+ "graceful",
+ "gracious",
+ "grand",
+ "grandiose",
+ "granular",
+ "grateful",
+ "grave",
+ "gray",
+ "great",
+ "greedy",
+ "green",
+ "gregarious",
+ "grim",
+ "grimy",
+ "gripping",
+ "grizzled",
+ "gross",
+ "grotesque",
+ "grouchy",
+ "grounded",
+ "growing",
+ "growling",
+ "grown",
+ "grubby",
+ "gruesome",
+ "grumpy",
+ "guilty",
+ "gullible",
+ "gummy",
+ "hairy",
+ "half",
+ "handmade",
+ "handsome",
+ "handy",
+ "happy",
+ "happy-go-lucky",
+ "hard",
+ "hard-to-find",
+ "harmful",
+ "harmless",
+ "harmonious",
+ "harsh",
+ "hasty",
+ "hateful",
+ "haunting",
+ "healthy",
+ "heartfelt",
+ "hearty",
+ "heavenly",
+ "heavy",
+ "hefty",
+ "helpful",
+ "helpless",
+ "hidden",
+ "hideous",
+ "high",
+ "high-level",
+ "hilarious",
+ "hoarse",
+ "hollow",
+ "homely",
+ "honest",
+ "honorable",
+ "honored",
+ "hopeful",
+ "horrible",
+ "hospitable",
+ "hot",
+ "huge",
+ "humble",
+ "humiliating",
+ "humming",
+ "humongous",
+ "hungry",
+ "hurtful",
+ "husky",
+ "icky",
+ "icy",
+ "ideal",
+ "idealistic",
+ "identical",
+ "idle",
+ "idiotic",
+ "idolized",
+ "ignorant",
+ "ill",
+ "illegal",
+ "ill-fated",
+ "ill-informed",
+ "illiterate",
+ "illustrious",
+ "imaginary",
+ "imaginative",
+ "immaculate",
+ "immaterial",
+ "immediate",
+ "immense",
+ "impassioned",
+ "impeccable",
+ "impartial",
+ "imperfect",
+ "imperturbable",
+ "impish",
+ "impolite",
+ "important",
+ "impossible",
+ "impractical",
+ "impressionable",
+ "impressive",
+ "improbable",
+ "impure",
+ "inborn",
+ "incomparable",
+ "incompatible",
+ "incomplete",
+ "inconsequential",
+ "incredible",
+ "indelible",
+ "inexperienced",
+ "indolent",
+ "infamous",
+ "infantile",
+ "infatuated",
+ "inferior",
+ "infinite",
+ "informal",
+ "innocent",
+ "insecure",
+ "insidious",
+ "insignificant",
+ "insistent",
+ "instructive",
+ "insubstantial",
+ "intelligent",
+ "intent",
+ "intentional",
+ "interesting",
+ "internal",
+ "international",
+ "intrepid",
+ "ironclad",
+ "irresponsible",
+ "irritating",
+ "itchy",
+ "jaded",
+ "jagged",
+ "jam-packed",
+ "jaunty",
+ "jealous",
+ "jittery",
+ "joint",
+ "jolly",
+ "jovial",
+ "joyful",
+ "joyous",
+ "jubilant",
+ "judicious",
+ "juicy",
+ "jumbo",
+ "junior",
+ "jumpy",
+ "juvenile",
+ "kaleidoscopic",
+ "keen",
+ "key",
+ "kind",
+ "kindhearted",
+ "kindly",
+ "klutzy",
+ "knobby",
+ "knotty",
+ "knowledgeable",
+ "knowing",
+ "known",
+ "kooky",
+ "kosher",
+ "lame",
+ "lanky",
+ "large",
+ "last",
+ "lasting",
+ "late",
+ "lavish",
+ "lawful",
+ "lazy",
+ "leading",
+ "lean",
+ "leafy",
+ "left",
+ "legal",
+ "legitimate",
+ "light",
+ "lighthearted",
+ "likable",
+ "likely",
+ "limited",
+ "limp",
+ "limping",
+ "linear",
+ "lined",
+ "liquid",
+ "little",
+ "live",
+ "lively",
+ "livid",
+ "loathsome",
+ "lone",
+ "lonely",
+ "long",
+ "long-term",
+ "loose",
+ "lopsided",
+ "lost",
+ "loud",
+ "lovable",
+ "lovely",
+ "loving",
+ "low",
+ "loyal",
+ "lucky",
+ "lumbering",
+ "luminous",
+ "lumpy",
+ "lustrous",
+ "luxurious",
+ "mad",
+ "made-up",
+ "magnificent",
+ "majestic",
+ "major",
+ "male",
+ "mammoth",
+ "married",
+ "marvelous",
+ "masculine",
+ "massive",
+ "mature",
+ "meager",
+ "mealy",
+ "mean",
+ "measly",
+ "meaty",
+ "medical",
+ "mediocre",
+ "medium",
+ "meek",
+ "mellow",
+ "melodic",
+ "memorable",
+ "menacing",
+ "merry",
+ "messy",
+ "metallic",
+ "mild",
+ "milky",
+ "mindless",
+ "miniature",
+ "minor",
+ "minty",
+ "miserable",
+ "miserly",
+ "misguided",
+ "misty",
+ "mixed",
+ "modern",
+ "modest",
+ "moist",
+ "monstrous",
+ "monthly",
+ "monumental",
+ "moral",
+ "mortified",
+ "motherly",
+ "motionless",
+ "mountainous",
+ "muddy",
+ "muffled",
+ "multicolored",
+ "mundane",
+ "murky",
+ "mushy",
+ "musty",
+ "muted",
+ "mysterious",
+ "naive",
+ "narrow",
+ "nasty",
+ "natural",
+ "naughty",
+ "nautical",
+ "near",
+ "neat",
+ "necessary",
+ "needy",
+ "negative",
+ "neglected",
+ "negligible",
+ "neighboring",
+ "nervous",
+ "new",
+ "next",
+ "nice",
+ "nifty",
+ "nimble",
+ "nippy",
+ "nocturnal",
+ "noisy",
+ "nonstop",
+ "normal",
+ "notable",
+ "noted",
+ "noteworthy",
+ "novel",
+ "noxious",
+ "numb",
+ "nutritious",
+ "nutty",
+ "obedient",
+ "obese",
+ "oblong",
+ "oily",
+ "oblong",
+ "obvious",
+ "occasional",
+ "odd",
+ "oddball",
+ "offbeat",
+ "offensive",
+ "official",
+ "old",
+ "old-fashioned",
+ "only",
+ "open",
+ "optimal",
+ "optimistic",
+ "opulent",
+ "orange",
+ "orderly",
+ "organic",
+ "ornate",
+ "ornery",
+ "ordinary",
+ "original",
+ "other",
+ "our",
+ "outlying",
+ "outgoing",
+ "outlandish",
+ "outrageous",
+ "outstanding",
+ "oval",
+ "overcooked",
+ "overdue",
+ "overjoyed",
+ "overlooked",
+ "palatable",
+ "pale",
+ "paltry",
+ "parallel",
+ "parched",
+ "partial",
+ "passionate",
+ "past",
+ "pastel",
+ "peaceful",
+ "peppery",
+ "perfect",
+ "perfumed",
+ "periodic",
+ "perky",
+ "personal",
+ "pertinent",
+ "pesky",
+ "pessimistic",
+ "petty",
+ "phony",
+ "physical",
+ "piercing",
+ "pink",
+ "pitiful",
+ "plain",
+ "plaintive",
+ "plastic",
+ "playful",
+ "pleasant",
+ "pleased",
+ "pleasing",
+ "plump",
+ "plush",
+ "polished",
+ "polite",
+ "political",
+ "pointed",
+ "pointless",
+ "poised",
+ "poor",
+ "popular",
+ "portly",
+ "posh",
+ "positive",
+ "possible",
+ "potable",
+ "powerful",
+ "powerless",
+ "practical",
+ "precious",
+ "present",
+ "prestigious",
+ "pretty",
+ "precious",
+ "previous",
+ "pricey",
+ "prickly",
+ "primary",
+ "prime",
+ "pristine",
+ "private",
+ "prize",
+ "probable",
+ "productive",
+ "profitable",
+ "profuse",
+ "proper",
+ "proud",
+ "prudent",
+ "punctual",
+ "pungent",
+ "puny",
+ "pure",
+ "purple",
+ "pushy",
+ "putrid",
+ "puzzled",
+ "puzzling",
+ "quaint",
+ "qualified",
+ "quarrelsome",
+ "quarterly",
+ "queasy",
+ "querulous",
+ "questionable",
+ "quick",
+ "quick-witted",
+ "quiet",
+ "quintessential",
+ "quirky",
+ "quixotic",
+ "quizzical",
+ "radiant",
+ "ragged",
+ "rapid",
+ "rare",
+ "rash",
+ "raw",
+ "recent",
+ "reckless",
+ "rectangular",
+ "ready",
+ "real",
+ "realistic",
+ "reasonable",
+ "red",
+ "reflecting",
+ "regal",
+ "regular",
+ "reliable",
+ "relieved",
+ "remarkable",
+ "remorseful",
+ "remote",
+ "repentant",
+ "required",
+ "respectful",
+ "responsible",
+ "repulsive",
+ "revolving",
+ "rewarding",
+ "rich",
+ "rigid",
+ "right",
+ "ringed",
+ "ripe",
+ "roasted",
+ "robust",
+ "rosy",
+ "rotating",
+ "rotten",
+ "rough",
+ "round",
+ "rowdy",
+ "royal",
+ "rubbery",
+ "rundown",
+ "ruddy",
+ "rude",
+ "runny",
+ "rural",
+ "rusty",
+ "sad",
+ "safe",
+ "salty",
+ "same",
+ "sandy",
+ "sane",
+ "sarcastic",
+ "sardonic",
+ "satisfied",
+ "scaly",
+ "scarce",
+ "scared",
+ "scary",
+ "scented",
+ "scholarly",
+ "scientific",
+ "scornful",
+ "scratchy",
+ "scrawny",
+ "second",
+ "secondary",
+ "second-hand",
+ "secret",
+ "self-assured",
+ "self-reliant",
+ "selfish",
+ "sentimental",
+ "separate",
+ "serene",
+ "serious",
+ "serpentine",
+ "several",
+ "severe",
+ "shabby",
+ "shadowy",
+ "shady",
+ "shallow",
+ "shameful",
+ "shameless",
+ "sharp",
+ "shimmering",
+ "shiny",
+ "shocked",
+ "shocking",
+ "shoddy",
+ "short",
+ "short-term",
+ "showy",
+ "shrill",
+ "shy",
+ "sick",
+ "silent",
+ "silky",
+ "silly",
+ "silver",
+ "similar",
+ "simple",
+ "simplistic",
+ "sinful",
+ "single",
+ "sizzling",
+ "skeletal",
+ "skinny",
+ "sleepy",
+ "slight",
+ "slim",
+ "slimy",
+ "slippery",
+ "slow",
+ "slushy",
+ "small",
+ "smart",
+ "smoggy",
+ "smooth",
+ "smug",
+ "snappy",
+ "snarling",
+ "sneaky",
+ "sniveling",
+ "snoopy",
+ "sociable",
+ "soft",
+ "soggy",
+ "solid",
+ "somber",
+ "some",
+ "spherical",
+ "sophisticated",
+ "sore",
+ "sorrowful",
+ "soulful",
+ "soupy",
+ "sour",
+ "Spanish",
+ "sparkling",
+ "sparse",
+ "specific",
+ "spectacular",
+ "speedy",
+ "spicy",
+ "spiffy",
+ "spirited",
+ "spiteful",
+ "splendid",
+ "spotless",
+ "spotted",
+ "spry",
+ "square",
+ "squeaky",
+ "squiggly",
+ "stable",
+ "staid",
+ "stained",
+ "stale",
+ "standard",
+ "starchy",
+ "stark",
+ "starry",
+ "steep",
+ "sticky",
+ "stiff",
+ "stimulating",
+ "stingy",
+ "stormy",
+ "straight",
+ "strange",
+ "steel",
+ "strict",
+ "strident",
+ "striking",
+ "striped",
+ "strong",
+ "studious",
+ "stunning",
+ "stupendous",
+ "stupid",
+ "sturdy",
+ "stylish",
+ "subdued",
+ "submissive",
+ "substantial",
+ "subtle",
+ "suburban",
+ "sudden",
+ "sugary",
+ "sunny",
+ "super",
+ "superb",
+ "superficial",
+ "superior",
+ "supportive",
+ "sure-footed",
+ "surprised",
+ "suspicious",
+ "svelte",
+ "sweaty",
+ "sweet",
+ "sweltering",
+ "swift",
+ "sympathetic",
+ "tall",
+ "talkative",
+ "tame",
+ "tan",
+ "tangible",
+ "tart",
+ "tasty",
+ "tattered",
+ "taut",
+ "tedious",
+ "teeming",
+ "tempting",
+ "tender",
+ "tense",
+ "tepid",
+ "terrible",
+ "terrific",
+ "testy",
+ "thankful",
+ "that",
+ "these",
+ "thick",
+ "thin",
+ "third",
+ "thirsty",
+ "this",
+ "thorough",
+ "thorny",
+ "those",
+ "thoughtful",
+ "threadbare",
+ "thrifty",
+ "thunderous",
+ "tidy",
+ "tight",
+ "timely",
+ "tinted",
+ "tiny",
+ "tired",
+ "torn",
+ "total",
+ "tough",
+ "traumatic",
+ "treasured",
+ "tremendous",
+ "tragic",
+ "trained",
+ "tremendous",
+ "triangular",
+ "tricky",
+ "trifling",
+ "trim",
+ "trivial",
+ "troubled",
+ "true",
+ "trusting",
+ "trustworthy",
+ "trusty",
+ "truthful",
+ "tubby",
+ "turbulent",
+ "twin",
+ "ugly",
+ "ultimate",
+ "unacceptable",
+ "unaware",
+ "uncomfortable",
+ "uncommon",
+ "unconscious",
+ "understated",
+ "unequaled",
+ "uneven",
+ "unfinished",
+ "unfit",
+ "unfolded",
+ "unfortunate",
+ "unhappy",
+ "unhealthy",
+ "uniform",
+ "unimportant",
+ "unique",
+ "united",
+ "unkempt",
+ "unknown",
+ "unlawful",
+ "unlined",
+ "unlucky",
+ "unnatural",
+ "unpleasant",
+ "unrealistic",
+ "unripe",
+ "unruly",
+ "unselfish",
+ "unsightly",
+ "unsteady",
+ "unsung",
+ "untidy",
+ "untimely",
+ "untried",
+ "untrue",
+ "unused",
+ "unusual",
+ "unwelcome",
+ "unwieldy",
+ "unwilling",
+ "unwitting",
+ "unwritten",
+ "upbeat",
+ "upright",
+ "upset",
+ "urban",
+ "usable",
+ "used",
+ "useful",
+ "useless",
+ "utilized",
+ "utter",
+ "vacant",
+ "vague",
+ "vain",
+ "valid",
+ "valuable",
+ "vapid",
+ "variable",
+ "vast",
+ "velvety",
+ "venerated",
+ "vengeful",
+ "verifiable",
+ "vibrant",
+ "vicious",
+ "victorious",
+ "vigilant",
+ "vigorous",
+ "villainous",
+ "violet",
+ "violent",
+ "virtual",
+ "virtuous",
+ "visible",
+ "vital",
+ "vivacious",
+ "vivid",
+ "voluminous",
+ "wan",
+ "warlike",
+ "warm",
+ "warmhearted",
+ "warped",
+ "wary",
+ "wasteful",
+ "watchful",
+ "waterlogged",
+ "watery",
+ "wavy",
+ "wealthy",
+ "weak",
+ "weary",
+ "webbed",
+ "wee",
+ "weekly",
+ "weepy",
+ "weighty",
+ "weird",
+ "welcome",
+ "well-documented",
+ "well-groomed",
+ "well-informed",
+ "well-lit",
+ "well-made",
+ "well-off",
+ "well-to-do",
+ "well-worn",
+ "wet",
+ "which",
+ "whimsical",
+ "whirlwind",
+ "whispered",
+ "white",
+ "whole",
+ "whopping",
+ "wicked",
+ "wide",
+ "wide-eyed",
+ "wiggly",
+ "wild",
+ "willing",
+ "wilted",
+ "winding",
+ "windy",
+ "winged",
+ "wiry",
+ "wise",
+ "witty",
+ "wobbly",
+ "woeful",
+ "wonderful",
+ "wooden",
+ "woozy",
+ "wordy",
+ "worldly",
+ "worn",
+ "worried",
+ "worrisome",
+ "worse",
+ "worst",
+ "worthless",
+ "worthwhile",
+ "worthy",
+ "wrathful",
+ "wretched",
+ "writhing",
+ "wrong",
+ "wry",
+ "yawning",
+ "yearly",
+ "yellow",
+ "yellowish",
+ "young",
+ "youthful",
+ "yummy",
+ "zany",
+ "zealous",
+ "zesty",
+ "zigzag",
+]
+
+export function getRandomUsername(): { first: string, second: string } {
+ const n = Math.floor(Math.random() * noun.length)
+ const a = Math.floor(Math.random() * adj.length)
+ return {
+ first: adj[a],
+ second: noun[n]
+ }
+}
+
+export function getRandomPassword(): string {
+ if (bankUiSettings.simplePasswordForRandomAccounts) return "123"
+ return encodeCrock(getRandomBytes(16))
+} \ No newline at end of file