summaryrefslogtreecommitdiff
path: root/packages/bank-ui/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/bank-ui/src/pages')
-rw-r--r--packages/bank-ui/src/pages/AccountPage/index.ts135
-rw-r--r--packages/bank-ui/src/pages/AccountPage/state.ts122
-rw-r--r--packages/bank-ui/src/pages/AccountPage/stories.tsx29
-rw-r--r--packages/bank-ui/src/pages/AccountPage/test.ts31
-rw-r--r--packages/bank-ui/src/pages/AccountPage/views.tsx156
-rw-r--r--packages/bank-ui/src/pages/BankFrame.stories.tsx29
-rw-r--r--packages/bank-ui/src/pages/BankFrame.tsx368
-rw-r--r--packages/bank-ui/src/pages/LoginForm.tsx230
-rw-r--r--packages/bank-ui/src/pages/OperationState/index.ts157
-rw-r--r--packages/bank-ui/src/pages/OperationState/state.ts234
-rw-r--r--packages/bank-ui/src/pages/OperationState/stories.tsx29
-rw-r--r--packages/bank-ui/src/pages/OperationState/test.ts31
-rw-r--r--packages/bank-ui/src/pages/OperationState/views.tsx447
-rw-r--r--packages/bank-ui/src/pages/PaymentOptions.stories.tsx35
-rw-r--r--packages/bank-ui/src/pages/PaymentOptions.tsx239
-rw-r--r--packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx35
-rw-r--r--packages/bank-ui/src/pages/PaytoWireTransferForm.tsx924
-rw-r--r--packages/bank-ui/src/pages/ProfileNavigation.tsx202
-rw-r--r--packages/bank-ui/src/pages/PublicHistoriesPage.tsx98
-rw-r--r--packages/bank-ui/src/pages/QrCodeSection.stories.tsx32
-rw-r--r--packages/bank-ui/src/pages/QrCodeSection.tsx152
-rw-r--r--packages/bank-ui/src/pages/RegistrationPage.tsx423
-rw-r--r--packages/bank-ui/src/pages/ShowNotifications.tsx55
-rw-r--r--packages/bank-ui/src/pages/SolveChallengePage.tsx793
-rw-r--r--packages/bank-ui/src/pages/WalletWithdrawForm.tsx404
-rw-r--r--packages/bank-ui/src/pages/WireTransfer.tsx119
-rw-r--r--packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx425
-rw-r--r--packages/bank-ui/src/pages/WithdrawalOperationPage.tsx73
-rw-r--r--packages/bank-ui/src/pages/WithdrawalQRCode.tsx310
-rw-r--r--packages/bank-ui/src/pages/account/CashoutListForAccount.tsx89
-rw-r--r--packages/bank-ui/src/pages/account/ShowAccountDetails.tsx500
-rw-r--r--packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx319
-rw-r--r--packages/bank-ui/src/pages/admin/AccountForm.tsx859
-rw-r--r--packages/bank-ui/src/pages/admin/AccountList.tsx234
-rw-r--r--packages/bank-ui/src/pages/admin/AdminHome.tsx622
-rw-r--r--packages/bank-ui/src/pages/admin/CreateNewAccount.tsx226
-rw-r--r--packages/bank-ui/src/pages/admin/DownloadStats.tsx588
-rw-r--r--packages/bank-ui/src/pages/admin/RemoveAccount.tsx273
-rw-r--r--packages/bank-ui/src/pages/index.stories.tsx20
-rw-r--r--packages/bank-ui/src/pages/regional/ConversionConfig.tsx1170
-rw-r--r--packages/bank-ui/src/pages/regional/CreateCashout.tsx732
-rw-r--r--packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx194
-rw-r--r--packages/bank-ui/src/pages/rnd.ts2907
43 files changed, 15050 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/AccountPage/index.ts b/packages/bank-ui/src/pages/AccountPage/index.ts
new file mode 100644
index 000000000..8a9471ef4
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/index.ts
@@ -0,0 +1,135 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ TalerCorebankApi,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import { Loading, utils } from "@gnu-taler/web-util/browser";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { LoginForm } from "../LoginForm.js";
+import { useComponentState } from "./state.js";
+import { InvalidIbanView, ReadyView } from "./views.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export interface Props {
+ account: string;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onClose: () => void;
+ tab: "charge-wallet" | "wire-transfer" | undefined;
+ routeClose: RouteDefinition;
+ routeCashout: RouteDefinition;
+ routeChargeWallet: RouteDefinition;
+ routeWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routePublicAccounts: RouteDefinition;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ routeSolveSecondFactor: RouteDefinition;
+}
+
+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: TalerError;
+ }
+
+ export interface BaseInfo {
+ error: undefined;
+ }
+
+ export interface Ready extends BaseInfo {
+ status: "ready";
+ error: undefined;
+ account: string;
+ tab: "charge-wallet" | "wire-transfer" | undefined;
+ limit: AmountJson;
+ balance: AmountJson;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onClose: () => void;
+ routeClose: RouteDefinition;
+ routeCashout: RouteDefinition;
+ routeChargeWallet: RouteDefinition;
+ routePublicAccounts: RouteDefinition;
+ routeWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ routeSolveSecondFactor: RouteDefinition;
+ }
+
+ export interface InvalidIban {
+ status: "invalid-iban";
+ error: TalerCorebankApi.AccountData;
+ }
+
+ export interface UserNotFound {
+ status: "login";
+ reason: "not-found" | "forbidden";
+ routeRegister?: RouteDefinition;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ login: LoginForm,
+ "invalid-iban": InvalidIbanView,
+ "loading-error": ErrorLoadingWithDebug,
+ ready: ReadyView,
+};
+
+export const AccountPage = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/pages/AccountPage/state.ts b/packages/bank-ui/src/pages/AccountPage/state.ts
new file mode 100644
index 000000000..f8b91a2ce
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/state.ts
@@ -0,0 +1,122 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import { useAccountDetails } from "../../hooks/account.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ account,
+ tab,
+ routeChargeWallet,
+ routeCreateWireTransfer,
+ routePublicAccounts,
+ routeSolveSecondFactor,
+ routeOperationDetails,
+ routeWireTransfer,
+ routeCashout,
+ onOperationCreated,
+ onClose,
+ routeClose,
+ onAuthorizationRequired,
+}: Props): State {
+ const result = useAccountDetails(account);
+
+ if (!result) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return {
+ status: "login",
+ reason: "forbidden",
+ };
+ case HttpStatusCode.NotFound:
+ return {
+ status: "login",
+ reason: "not-found",
+ };
+ default: {
+ assertUnreachable(result);
+ }
+ }
+ }
+
+ const { body: 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: data,
+ };
+ }
+
+ const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+
+ const positiveBalance = balanceIsDebit
+ ? Amounts.zeroOfAmount(balance)
+ : balance;
+
+ return {
+ status: "ready",
+ onOperationCreated,
+ error: undefined,
+ tab,
+ routeCashout,
+ routeOperationDetails,
+ routeCreateWireTransfer,
+ routePublicAccounts,
+ routeSolveSecondFactor,
+ onAuthorizationRequired,
+ onClose,
+ routeClose,
+ routeChargeWallet,
+ routeWireTransfer,
+ account,
+ limit,
+ balance: positiveBalance,
+ };
+}
diff --git a/packages/bank-ui/src/pages/AccountPage/stories.tsx b/packages/bank-ui/src/pages/AccountPage/stories.tsx
new file mode 100644
index 000000000..fe09a4f89
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @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 const Ready = tests.createExample(ReadyView, {});
diff --git a/packages/bank-ui/src/pages/AccountPage/test.ts b/packages/bank-ui/src/pages/AccountPage/test.ts
new file mode 100644
index 000000000..14c8be948
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/test.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @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/bank-ui/src/pages/AccountPage/views.tsx b/packages/bank-ui/src/pages/AccountPage/views.tsx
new file mode 100644
index 000000000..42892f536
--- /dev/null
+++ b/packages/bank-ui/src/pages/AccountPage/views.tsx
@@ -0,0 +1,156 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Transactions } from "../../components/Transactions/index.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { usePreferences } from "../../hooks/preferences.js";
+import { PaymentOptions } from "../PaymentOptions.js";
+import { State } from "./index.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function InvalidIbanView({ error }: State.InvalidIban) {
+ return (
+ <div>Payto from server is not valid &quot;{error.payto_uri}&quot;</div>
+ );
+}
+
+const IS_PUBLIC_ACCOUNT_ENABLED = false;
+
+function ShowDemoInfo({
+ routePublicAccounts,
+}: {
+ routePublicAccounts: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = usePreferences();
+ 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 name="public account" href={routePublicAccounts.url({})}>
+ 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>
+ );
+}
+
+function ShowPedingOperation({
+ routeSolveSecondFactor,
+}: {
+ routeSolveSecondFactor: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [bankState, updateBankState] = useBankState();
+ if (!bankState.currentChallenge) return <Fragment />;
+ const title = ((op): TranslatedString => {
+ switch (op) {
+ case "delete-account":
+ return i18n.str`Pending account delete operation`;
+ case "update-account":
+ return i18n.str`Pending account update operation`;
+ case "update-password":
+ return i18n.str`Pending password update operation`;
+ case "create-transaction":
+ return i18n.str`Pending transaction operation`;
+ case "confirm-withdrawal":
+ return i18n.str`Pending withdrawal operation`;
+ case "create-cashout":
+ return i18n.str`Pending cashout operation`;
+ }
+ })(bankState.currentChallenge.operation);
+ return (
+ <Attention
+ title={title}
+ type="warning"
+ onClose={() => {
+ updateBankState("currentChallenge", undefined);
+ }}
+ >
+ <i18n.Translate>
+ You can complete or cancel the operation in
+ </i18n.Translate>{" "}
+ <a
+ class="font-semibold text-yellow-700 hover:text-yellow-600"
+ name="complete operation"
+ href={routeSolveSecondFactor.url({})}
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </Attention>
+ );
+}
+
+export function ReadyView({
+ tab,
+ account,
+ routeChargeWallet,
+ routeWireTransfer,
+ limit,
+ balance,
+ routeCashout,
+ routeCreateWireTransfer,
+ routePublicAccounts,
+ routeOperationDetails,
+ routeSolveSecondFactor,
+ onClose,
+ routeClose,
+ onOperationCreated,
+ onAuthorizationRequired,
+}: State.Ready): VNode {
+ return (
+ <Fragment>
+ <ShowPedingOperation routeSolveSecondFactor={routeSolveSecondFactor} />
+ <ShowDemoInfo routePublicAccounts={routePublicAccounts} />
+ <PaymentOptions
+ tab={tab}
+ routeOperationDetails={routeOperationDetails}
+ routeCashout={routeCashout}
+ routeChargeWallet={routeChargeWallet}
+ routeWireTransfer={routeWireTransfer}
+ limit={limit}
+ balance={balance}
+ routeClose={routeClose}
+ onClose={onClose}
+ onOperationCreated={onOperationCreated}
+ onAuthorizationRequired={onAuthorizationRequired}
+ />
+ <Transactions
+ account={account}
+ routeCreateWireTransfer={routeCreateWireTransfer}
+ />
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/BankFrame.stories.tsx b/packages/bank-ui/src/pages/BankFrame.stories.tsx
new file mode 100644
index 000000000..c874ac4ca
--- /dev/null
+++ b/packages/bank-ui/src/pages/BankFrame.stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { BankFrame } from "./BankFrame.js";
+
+export default {
+ title: "bank frame",
+};
+
+export const Ready = tests.createExample(BankFrame, {});
diff --git a/packages/bank-ui/src/pages/BankFrame.tsx b/packages/bank-ui/src/pages/BankFrame.tsx
new file mode 100644
index 000000000..db757ee07
--- /dev/null
+++ b/packages/bank-ui/src/pages/BankFrame.tsx
@@ -0,0 +1,368 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ ObservabilityEventType,
+ TalerError,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Footer,
+ Header,
+ Loading,
+ RouteDefinition,
+ ToastBanner,
+ notifyError,
+ notifyException,
+ useBankCoreApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { useEffect, useErrorBoundary, useState } from "preact/hooks";
+import { privatePages } from "../Routing.js";
+import { useSettingsContext } from "../context/settings.js";
+import { useAccountDetails } from "../hooks/account.js";
+import { useBankState } from "../hooks/bank-state.js";
+import {
+ getAllBooleanPreferences,
+ getLabelForPreferences,
+ usePreferences,
+} from "../hooks/preferences.js";
+import { useSessionState } from "../hooks/session.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
+
+const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
+const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
+
+export function BankFrame({
+ children,
+ account,
+ routeAccountDetails,
+}: {
+ account?: string;
+ routeAccountDetails?: RouteDefinition;
+ children: ComponentChildren;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const session = useSessionState();
+ const settings = useSettingsContext();
+ const [preferences, updatePreferences] = usePreferences();
+ const [, , resetBankState] = useBankState();
+
+ const [error, resetError] = useErrorBoundary();
+
+ useEffect(() => {
+ if (error) {
+ if (error instanceof Error) {
+ console.log("Internal error, please report", error);
+ notifyException(i18n.str`Internal error, please report.`, error);
+ } else {
+ console.log("Internal error, please report", error);
+ notifyError(
+ i18n.str`Internal error, please report.`,
+ String(error) as TranslatedString,
+ );
+ }
+ resetError();
+ }
+ }, [error]);
+
+ return (
+ <div
+ class="min-h-full flex flex-col m-0 bg-slate-200"
+ style="min-height: 100vh;"
+ >
+ <div class="bg-indigo-600 pb-32">
+ <Header
+ title="Bank"
+ iconLinkURL={settings.iconLinkURL ?? "#"}
+ profileURL={routeAccountDetails?.url({})}
+ notificationURL={
+ preferences.showDebugInfo
+ ? privatePages.notifications.url({})
+ : undefined
+ }
+ onLogout={
+ session.state.status !== "loggedIn"
+ ? undefined
+ : () => {
+ session.logOut();
+ resetBankState();
+ }
+ }
+ sites={
+ !settings.topNavSites ? [] : Object.entries(settings.topNavSites)
+ }
+ supportedLangs={["en", "es", "de"]}
+ >
+ <li>
+ <div class="text-xs font-semibold leading-6 text-gray-400">
+ <i18n.Translate>Preferences</i18n.Translate>
+ </div>
+ <ul role="list" class="space-y-4">
+ {getAllBooleanPreferences().map((set) => {
+ const isOn: boolean = !!preferences[set];
+ return (
+ <li key={set} class="pl-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"
+ >
+ {getLabelForPreferences(set, i18n)}
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`${set} switch`}
+ data-enabled={isOn}
+ 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={() => {
+ updatePreferences(set, !isOn);
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={isOn}
+ 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>
+ </Header>
+ </div>
+
+ <div class="fixed z-20 top-14 w-full">
+ <div class="mx-auto w-4/5">
+ <ToastBanner />
+ {/* <Attention type="success" title={"hola" as TranslatedString} onClose={() => { }} /> */}
+ </div>
+ </div>
+
+ <main class="-mt-32 flex-1">
+ {account && routeAccountDetails && (
+ <header class="py-6 bg-indigo-600">
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
+ <h1 class=" flex flex-wrap items-center justify-between sm:flex-nowrap">
+ <span class="text-2xl font-bold tracking-tight text-white">
+ <WelcomeAccount
+ account={account}
+ routeAccountDetails={routeAccountDetails}
+ />
+ </span>
+ <span class="text-2xl font-bold tracking-tight text-white">
+ <AccountBalance account={account} />
+ </span>
+ </h1>
+ </div>
+ </header>
+ )}
+
+ <div class="mx-auto max-w-7xl px-4 pb-4 sm:px-6 lg:px-8">
+ <div class="rounded-lg bg-white px-5 py-6 shadow sm:px-6">
+ {children}
+ </div>
+ </div>
+ </main>
+
+ <AppActivity />
+
+ <Footer
+ testingUrlKey="corebank-api-base-url"
+ GIT_HASH={GIT_HASH}
+ VERSION={VERSION}
+ />
+ </div>
+ );
+}
+
+function Wait({ class: clazz }: { class?: string }): VNode {
+ return (
+ <Fragment>
+ <style>{`
+ .animated-loader {
+ display: inline-block;
+ --b: 5px;
+ border-radius: 50%;
+ aspect-ratio: 1;
+ padding: 1px;
+ background: conic-gradient(#0000 10%,#4f46e5) content-box;
+ -webkit-mask:
+ repeating-conic-gradient(#0000 0deg,#000 1deg 20deg,#0000 21deg 36deg),
+ radial-gradient(farthest-side,#0000 calc(100% - var(--b) - 1px),#000 calc(100% - var(--b)));
+ -webkit-mask-composite: destination-in;
+ mask-composite: intersect;
+ animation:spinning-loader 1s infinite steps(10);
+ }
+ @keyframes spinning-loader {to{transform: rotate(1turn)}}
+ `}</style>
+ <div class={`animated-loader ${clazz}`} />
+ </Fragment>
+ );
+}
+
+function AppActivity(): VNode {
+ const [lastEvent, setLastEvent] = useState<{
+ url: string;
+ id: string;
+ when: AbsoluteTime;
+ }>();
+ const [status, setStatus] = useState<"ok" | "fail">();
+ const d = useBankCoreApiContext();
+ const onBackendActivity = !d ? undefined : d.onActivity;
+ const cancelRequest = !d ? undefined : d.cancelRequest;
+ const [pref] = usePreferences();
+ useEffect(() => {
+ // console.log("ASDASDS", onBackendActivity)
+ if (!pref.showDebugInfo) return;
+ if (!onBackendActivity) return;
+ return onBackendActivity((ev) => {
+ switch (ev.type) {
+ case ObservabilityEventType.HttpFetchStart: {
+ setLastEvent(ev);
+ setStatus(undefined);
+ return;
+ }
+ case ObservabilityEventType.HttpFetchFinishError: {
+ setStatus("fail");
+ return;
+ }
+ case ObservabilityEventType.HttpFetchFinishSuccess: {
+ setStatus("ok");
+ return;
+ }
+ /**
+ * all of this are ignored
+ */
+ case ObservabilityEventType.DbQueryStart:
+ case ObservabilityEventType.DbQueryFinishSuccess:
+ case ObservabilityEventType.DbQueryFinishError:
+ case ObservabilityEventType.RequestStart:
+ case ObservabilityEventType.RequestFinishSuccess:
+ case ObservabilityEventType.RequestFinishError:
+ case ObservabilityEventType.TaskStart:
+ case ObservabilityEventType.TaskStop:
+ case ObservabilityEventType.TaskReset:
+ case ObservabilityEventType.ShepherdTaskResult:
+ case ObservabilityEventType.DeclareTaskDependency:
+ case ObservabilityEventType.CryptoStart:
+ case ObservabilityEventType.CryptoFinishSuccess:
+ case ObservabilityEventType.CryptoFinishError:
+ case ObservabilityEventType.Message:
+ return;
+ default: {
+ assertUnreachable(ev);
+ }
+ }
+ });
+ });
+ if (!pref.showDebugInfo || !lastEvent) return <Fragment />;
+ return (
+ <div
+ data-status={status}
+ class="fixed z-20 bottom-0 w-full ease-in-out delay-1000 transition-transform data-[status=ok]:scale-y-0"
+ >
+ <div
+ data-status={status}
+ class="mx-auto w-4/5 center flex p-1 bg-gray-300 m-1 data-[status=fail]:bg-red-200 data-[status=ok]:bg-green-200 "
+ >
+ {!status ? <Wait class="w-6 h-6" /> : <div class="w-6 h-6" />}
+
+ <p class="ml-2 my-auto text-sm text-gray-500">{lastEvent.url}</p>
+ {!status ? (
+ <button
+ onClick={() => {
+ if (cancelRequest) cancelRequest(lastEvent.id);
+ }}
+ >
+ cancel
+ </button>
+ ) : undefined}
+ </div>
+ </div>
+ );
+}
+
+function WelcomeAccount({
+ account,
+ routeAccountDetails,
+}: {
+ account: string;
+ routeAccountDetails: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <div />;
+ }
+ if (result.type === "fail") {
+ return (
+ <a
+ name="account details"
+ href={routeAccountDetails.url({})}
+ class="underline underline-offset-2"
+ >
+ <i18n.Translate>Welcome</i18n.Translate>
+ </a>
+ );
+ }
+ return (
+ <a
+ name="account details"
+ href={routeAccountDetails.url({})}
+ class="underline underline-offset-2"
+ >
+ <i18n.Translate>
+ Welcome, <span class="whitespace-nowrap">{result.body.name}</span>
+ </i18n.Translate>
+ </a>
+ );
+}
+
+function AccountBalance({ account }: { account: string }): VNode {
+ const result = useAccountDetails(account);
+ const { config } = useBankCoreApiContext();
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <div />;
+ }
+ if (result.type === "fail") return <div />;
+
+ return (
+ <RenderAmount
+ value={Amounts.parseOrThrow(result.body.balance.amount)}
+ negative={result.body.balance.credit_debit_indicator === "debit"}
+ spec={config.currency_specification}
+ />
+ );
+}
diff --git a/packages/bank-ui/src/pages/LoginForm.tsx b/packages/bank-ui/src/pages/LoginForm.tsx
new file mode 100644
index 000000000..600025400
--- /dev/null
+++ b/packages/bank-ui/src/pages/LoginForm.tsx
@@ -0,0 +1,230 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { HttpStatusCode, createRFC8959AccessTokenEncoded, createRFC8959AccessTokenPlain } from "@gnu-taler/taler-util";
+import {
+ Button,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useEffect, useRef, useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { doAutoFocus } from "./PaytoWireTransferForm.js";
+import { USERNAME_REGEX } from "./RegistrationPage.js";
+
+/**
+ * Collect and submit login data.
+ */
+export function LoginForm({
+ currentUser,
+ fixedUser,
+ routeRegister,
+}: {
+ fixedUser?: boolean;
+ currentUser?: string;
+ routeRegister?: RouteDefinition;
+}): VNode {
+ const session = useSessionState();
+
+ const sessionUser =
+ session.state.status !== "loggedOut" ? session.state.username : undefined;
+ const [username, setUsername] = useState<string | undefined>(
+ currentUser ?? sessionUser,
+ );
+ const [password, setPassword] = useState<string | undefined>();
+ const { i18n } = useTranslationContext();
+ const {
+ lib: { auth: authenticator },
+ } = useBankCoreApiContext();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+ const { config } = useBankCoreApiContext();
+
+ const ref = useRef<HTMLInputElement>(null);
+ useEffect(function focusInput() {
+ ref.current?.focus();
+ }, []);
+
+ const errors = undefinedIfEmpty({
+ username: !username
+ ? i18n.str`Missing username`
+ : !USERNAME_REGEX.test(username)
+ ? i18n.str`Use letters, numbers or any of these characters: - . _ ~`
+ : undefined,
+ password: !password ? i18n.str`Missing password` : undefined,
+ });
+
+ async function doLogout() {
+ session.logOut();
+ }
+
+ const loginHandler =
+ !username || !password
+ ? undefined
+ : withErrorHandler(
+ async () =>
+ authenticator(username).createAccessTokenBasic(username, password, {
+ scope: "readwrite",
+ duration: { d_us: "forever" },
+ refreshable: true,
+ }),
+ (result) => {
+ session.logIn({ username, token: createRFC8959AccessTokenEncoded(result.body.access_token) });
+ },
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`Wrong credentials for "${username}"`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`Account not found`;
+ }
+ },
+ );
+
+ return (
+ <div class="flex min-h-full flex-col justify-center ">
+ <LocalNotificationBanner notification={notification} />
+ <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>
+ <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={fixedUser}
+ enterkeyhint="next"
+ placeholder="identification"
+ autocomplete="username"
+ title={i18n.str`Username of the account`}
+ 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>
+ </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"
+ title={i18n.str`Password of the account`}
+ required
+ onInput={(e): void => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </div>
+
+ {session.state.status !== "loggedOut" ? (
+ <div class="flex justify-between">
+ <button
+ type="submit"
+ name="cancel"
+ 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();
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ <Button
+ type="submit"
+ name="check"
+ 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}
+ handler={loginHandler}
+ >
+ <i18n.Translate>Check</i18n.Translate>
+ </Button>
+ </div>
+ ) : (
+ <div>
+ <Button
+ type="submit"
+ name="login"
+ 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}
+ handler={loginHandler}
+ >
+ <i18n.Translate>Log in</i18n.Translate>
+ </Button>
+ </div>
+ )}
+ </form>
+
+ {config.allow_registrations && routeRegister && (
+ <a
+ name="register"
+ href={routeRegister.url({})}
+ class="flex justify-center border-t 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"
+ >
+ <i18n.Translate>Register</i18n.Translate>
+ </a>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/OperationState/index.ts b/packages/bank-ui/src/pages/OperationState/index.ts
new file mode 100644
index 000000000..38f698a04
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/index.ts
@@ -0,0 +1,157 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ TalerCoreBankErrorsByMethod,
+ TalerError,
+ WithdrawUriResult,
+} from "@gnu-taler/taler-util";
+import { Loading, utils } from "@gnu-taler/web-util/browser";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useComponentState } from "./state.js";
+import {
+ AbortedView,
+ ConfirmedView,
+ FailedView,
+ InvalidPaytoView,
+ InvalidReserveView,
+ InvalidWithdrawalView,
+ NeedConfirmationView,
+ ReadyView,
+} from "./views.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export interface Props {
+ currency: string;
+ onAuthorizationRequired: () => void;
+ routeClose: RouteDefinition;
+ onAbort: () => void;
+ routeHere: RouteDefinition<{ wopid: string }>;
+}
+
+export type State =
+ | State.Loading
+ | State.LoadingError
+ | State.Ready
+ | State.Failed
+ | State.Aborted
+ | State.Confirmed
+ | State.InvalidPayto
+ | State.InvalidWithdrawal
+ | State.InvalidReserve
+ | State.NeedConfirmation;
+
+export namespace State {
+ export interface Loading {
+ status: "loading";
+ error: undefined;
+ }
+
+ export interface Failed {
+ status: "failed";
+ error: TalerCoreBankErrorsByMethod<"createWithdrawal">;
+ }
+
+ export interface LoadingError {
+ status: "loading-error";
+ error: TalerError;
+ }
+
+ /**
+ * Need to open the wallet
+ */
+ export interface Ready {
+ status: "ready";
+ error: undefined;
+ uri: WithdrawUriResult;
+ onAbort: () => Promise<
+ TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined
+ >;
+ routeClose: RouteDefinition;
+ }
+
+ export interface InvalidPayto {
+ status: "invalid-payto";
+ error: undefined;
+ payto: string | undefined;
+ }
+ export interface InvalidWithdrawal {
+ status: "invalid-withdrawal";
+ error: undefined;
+ uri: string;
+ }
+ export interface InvalidReserve {
+ status: "invalid-reserve";
+ error: undefined;
+ reserve: string | undefined;
+ }
+ export interface NeedConfirmation {
+ status: "need-confirmation";
+ onAuthorizationRequired: () => void;
+ account: string;
+ routeHere: RouteDefinition<{ wopid: string }>;
+ onAbort:
+ | undefined
+ | (() => Promise<
+ TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined
+ >);
+ onConfirm:
+ | undefined
+ | (() => Promise<
+ TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined
+ >);
+ error: undefined;
+ id: string;
+ }
+ export interface Aborted {
+ status: "aborted";
+ error: undefined;
+ routeClose: RouteDefinition;
+ }
+ export interface Confirmed {
+ status: "confirmed";
+ error: undefined;
+ routeClose: RouteDefinition;
+ }
+}
+
+export interface Transaction {
+ negative: boolean;
+ counterpart: string;
+ when: AbsoluteTime;
+ amount: AmountJson | undefined;
+ subject: string;
+}
+
+const viewMapping: utils.StateViewMap<State> = {
+ loading: Loading,
+ failed: FailedView,
+ "invalid-payto": InvalidPaytoView,
+ "invalid-withdrawal": InvalidWithdrawalView,
+ "invalid-reserve": InvalidReserveView,
+ "need-confirmation": NeedConfirmationView,
+ aborted: AbortedView,
+ confirmed: ConfirmedView,
+ "loading-error": ErrorLoadingWithDebug,
+ ready: ReadyView,
+};
+
+export const OperationState = utils.compose(
+ (p: Props) => useComponentState(p),
+ viewMapping,
+);
diff --git a/packages/bank-ui/src/pages/OperationState/state.ts b/packages/bank-ui/src/pages/OperationState/state.ts
new file mode 100644
index 000000000..19c097d18
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/state.ts
@@ -0,0 +1,234 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerCoreBankErrorsByMethod,
+ TalerError,
+ assertUnreachable,
+ parsePaytoUri,
+ parseWithdrawUri,
+ stringifyWithdrawUri,
+} from "@gnu-taler/taler-util";
+import { utils } from "@gnu-taler/web-util/browser";
+import { useEffect, useState } from "preact/hooks";
+import { mutate } from "swr";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useWithdrawalDetails } from "../../hooks/account.js";
+import { useSessionState } from "../../hooks/session.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { usePreferences } from "../../hooks/preferences.js";
+import { Props, State } from "./index.js";
+
+export function useComponentState({
+ currency,
+ routeClose,
+ onAbort,
+ routeHere,
+ onAuthorizationRequired,
+}: Props): utils.RecursiveState<State> {
+ const [settings] = usePreferences();
+ const [bankState, updateBankState] = useBankState();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const {
+ lib: { bank },
+ } = useBankCoreApiContext();
+
+ const [failure, setFailure] = useState<
+ TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined
+ >();
+ const amount = settings.maxWithdrawalAmount;
+
+ async function doSilentStart() {
+ // FIXME: if amount is not enough use balance
+ const parsedAmount = Amounts.parseOrThrow(`${currency}:${amount}`);
+ if (!creds) return;
+ const resp = await bank.createWithdrawal(creds, {
+ amount: Amounts.stringify(parsedAmount),
+ });
+ if (resp.type === "fail") {
+ setFailure(resp);
+ return;
+ }
+ updateBankState("currentWithdrawalOperationId", resp.body.withdrawal_id);
+ }
+
+ const withdrawalOperationId = bankState.currentWithdrawalOperationId;
+ useEffect(() => {
+ if (withdrawalOperationId === undefined) {
+ doSilentStart();
+ }
+ }, [settings.fastWithdrawal, amount]);
+
+ if (failure) {
+ return {
+ status: "failed",
+ error: failure,
+ };
+ }
+
+ if (!withdrawalOperationId) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+
+ const wid = withdrawalOperationId;
+
+ async function doAbort() {
+ if (!creds) return;
+ const resp = await bank.abortWithdrawalById(creds, wid);
+ if (resp.type === "ok") {
+ // updateBankState("currentWithdrawalOperationId", undefined)
+ onAbort();
+ } else {
+ return resp;
+ }
+ }
+
+ async function doConfirm(): Promise<
+ TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined
+ > {
+ if (!creds) return;
+ const resp = await bank.confirmWithdrawalById(creds, wid);
+ if (resp.type === "ok") {
+ mutate(() => true); //clean withdrawal state
+ } else {
+ return resp;
+ }
+ }
+
+ const uri = stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl: bank.getIntegrationAPI().href,
+ withdrawalOperationId,
+ });
+ const parsedUri = parseWithdrawUri(uri);
+ if (!parsedUri) {
+ return {
+ status: "invalid-withdrawal",
+ error: undefined,
+ uri,
+ };
+ }
+
+ return (): utils.RecursiveState<State> => {
+ const result = useWithdrawalDetails(withdrawalOperationId);
+ const shouldCreateNewOperation = result && !(result instanceof TalerError);
+
+ useEffect(() => {
+ if (shouldCreateNewOperation) {
+ doSilentStart();
+ }
+ }, []);
+ if (!result) {
+ return {
+ status: "loading",
+ error: undefined,
+ };
+ }
+ if (result instanceof TalerError) {
+ return {
+ status: "loading-error",
+ error: result,
+ };
+ }
+
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound: {
+ return {
+ status: "aborted",
+ error: undefined,
+ routeClose,
+ };
+ }
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const { body: data } = result;
+ if (data.status === "aborted") {
+ return {
+ status: "aborted",
+ error: undefined,
+ routeClose,
+ };
+ }
+
+ if (data.status === "confirmed") {
+ if (!settings.showWithdrawalSuccess) {
+ updateBankState("currentWithdrawalOperationId", undefined);
+ // onClose()
+ }
+ return {
+ status: "confirmed",
+ error: undefined,
+ routeClose,
+ };
+ }
+
+ if (data.status === "pending") {
+ return {
+ status: "ready",
+ error: undefined,
+ uri: parsedUri,
+ routeClose,
+ onAbort: !creds
+ ? async () => {
+ onAbort();
+ return undefined;
+ }
+ : doAbort,
+ };
+ }
+
+ if (!data.selected_reserve_pub) {
+ return {
+ status: "invalid-reserve",
+ error: undefined,
+ reserve: data.selected_reserve_pub,
+ };
+ }
+
+ 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,
+ };
+ }
+
+ return {
+ status: "need-confirmation",
+ error: undefined,
+ routeHere,
+ onAuthorizationRequired,
+ account: data.username,
+ id: withdrawalOperationId,
+ onAbort: !creds ? undefined : doAbort,
+ onConfirm: !creds ? undefined : doConfirm,
+ };
+ };
+}
diff --git a/packages/bank-ui/src/pages/OperationState/stories.tsx b/packages/bank-ui/src/pages/OperationState/stories.tsx
new file mode 100644
index 000000000..82253b82c
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/stories.tsx
@@ -0,0 +1,29 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @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/bank-ui/src/pages/OperationState/test.ts b/packages/bank-ui/src/pages/OperationState/test.ts
new file mode 100644
index 000000000..d47cb64a2
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/test.ts
@@ -0,0 +1,31 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @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/bank-ui/src/pages/OperationState/views.tsx b/packages/bank-ui/src/pages/OperationState/views.tsx
new file mode 100644
index 000000000..62308eca6
--- /dev/null
+++ b/packages/bank-ui/src/pages/OperationState/views.tsx
@@ -0,0 +1,447 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ stringifyWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyInfo,
+ useLocalNotification,
+ useTalerWalletIntegrationAPI,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect } from "preact/hooks";
+import { QR } from "../../components/QR.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { usePreferences } from "../../hooks/preferences.js";
+import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js";
+import { State } from "./index.js";
+
+export function InvalidPaytoView({ payto }: State.InvalidPayto) {
+ return <div>Payto from server is not valid &quot;{payto}&quot;</div>;
+}
+export function InvalidWithdrawalView({ uri }: State.InvalidWithdrawal) {
+ return <div>Withdrawal uri from server is not valid &quot;{uri}&quot;</div>;
+}
+export function InvalidReserveView({ reserve }: State.InvalidReserve) {
+ return <div>Reserve from server is not valid &quot;{reserve}&quot;</div>;
+}
+
+export function NeedConfirmationView({
+ onAbort: doAbort,
+ onConfirm: doConfirm,
+ routeHere,
+ account,
+ id,
+ onAuthorizationRequired,
+}: State.NeedConfirmation) {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+ const [notification, notify, errorHandler] = useLocalNotification();
+ const [, updateBankState] = useBankState();
+
+ async function onCancel() {
+ errorHandler(async () => {
+ if (!doAbort) return;
+ const resp = await doAbort();
+ if (!resp) return;
+ switch (resp.case) {
+ case HttpStatusCode.Conflict:
+ return notify({
+ type: "error",
+ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ });
+ }
+
+ async function onConfirm() {
+ errorHandler(async () => {
+ if (!doConfirm) return;
+ const resp = await doConfirm();
+ if (!resp) {
+ if (!settings.showWithdrawalSuccess) {
+ notifyInfo(i18n.str`Wire transfer completed!`);
+ }
+ return;
+ }
+ switch (resp.case) {
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "confirm-withdrawal",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({ wopid: id }),
+ request: id,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ });
+ }
+
+ return (
+ <div class="bg-white shadow sm:rounded-lg">
+ <LocalNotificationBanner notification={notification} />
+ <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-3 text-sm leading-6">
+ <ShouldBeSameUser username={account}>
+ <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="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <button
+ type="button"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={(e) => {
+ e.preventDefault();
+ onCancel();
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ type="submit"
+ name="transfer"
+ 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();
+ onConfirm();
+ }}
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </ShouldBeSameUser>
+ </div>
+ </div>
+ </div>
+ );
+}
+export function FailedView({ error }: State.Failed) {
+ const { i18n } = useTranslationContext();
+ switch (error.case) {
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`Unauthorized to make the operation, maybe the session has expired or the password changed.`}
+ >
+ <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div>
+ </Attention>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation was rejected due to insufficient funds.`}
+ >
+ <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div>
+ </Attention>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation was rejected due to insufficient funds.`}
+ >
+ <div class="mt-2 text-sm text-red-700">{error.detail.hint}</div>
+ </Attention>
+ );
+ default:
+ assertUnreachable(error);
+ }
+}
+
+export function AbortedView() {
+ return <div>aborted</div>;
+}
+
+export function ConfirmedView({ routeClose }: State.Confirmed) {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = usePreferences();
+ 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"
+ name="toggle withdrawal"
+ 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">
+ <a
+ href={routeClose.url({})}
+ type="button"
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+}
+
+export function ReadyView({ uri, onAbort: doAbort }: State.Ready): VNode {
+ const { i18n } = useTranslationContext();
+ const walletInegrationApi = useTalerWalletIntegrationAPI();
+ const [notification, notify, errorHandler] = useLocalNotification();
+
+ const talerWithdrawUri = stringifyWithdrawUri(uri);
+ useEffect(() => {
+ walletInegrationApi.publishTalerAction(uri);
+ }, []);
+
+ async function onAbort() {
+ errorHandler(async () => {
+ const hasError = await doAbort();
+ if (!hasError) return;
+ switch (hasError.case) {
+ case HttpStatusCode.Conflict:
+ return notify({
+ type: "error",
+ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`,
+ description: hasError.detail.hint as TranslatedString,
+ debug: hasError.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: hasError.detail.hint as TranslatedString,
+ debug: hasError.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: hasError.detail.hint as TranslatedString,
+ debug: hasError.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(hasError);
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="flex justify-end mt-4">
+ <button
+ type="button"
+ name="cancel"
+ 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={onAbort}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </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 web browser on desktop you can also
+ </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}
+ name="start"
+ 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/bank-ui/src/pages/PaymentOptions.stories.tsx b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx
new file mode 100644
index 000000000..78af886a8
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaymentOptions.stories.tsx
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { PaymentOptions } from "./PaymentOptions.js";
+
+export default {
+ title: "PaymentOptions",
+};
+
+export const USD = tests.createExample(PaymentOptions, {
+ limit: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+});
diff --git a/packages/bank-ui/src/pages/PaymentOptions.tsx b/packages/bank-ui/src/pages/PaymentOptions.tsx
new file mode 100644
index 000000000..386fe31bc
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaymentOptions.tsx
@@ -0,0 +1,239 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { AmountJson, TalerError } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect } from "preact/hooks";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
+import { WalletWithdrawForm } from "./WalletWithdrawForm.js";
+
+function ShowOperationPendingTag({
+ woid,
+ onOperationAlreadyCompleted,
+}: {
+ woid: string;
+ onOperationAlreadyCompleted?: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const result = useWithdrawalDetails(woid);
+ const loading = !result;
+ const error =
+ !loading && (result instanceof TalerError || result.type === "fail");
+ const pending =
+ !loading &&
+ !error &&
+ (result.body.status === "pending" || result.body.status === "selected") &&
+ credentials.status === "loggedIn" &&
+ credentials.username === result.body.username;
+ useEffect(() => {
+ if (!loading && !pending && onOperationAlreadyCompleted) {
+ onOperationAlreadyCompleted();
+ }
+ }, [pending]);
+
+ if (error || !pending) {
+ return <Fragment />;
+ }
+
+ return (
+ <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre">
+ <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>
+ );
+}
+
+/**
+ * Let the user choose a payment option,
+ * then specify the details trigger the action.
+ */
+export function PaymentOptions({
+ routeClose,
+ routeCashout,
+ routeChargeWallet,
+ routeWireTransfer,
+ tab,
+ limit,
+ balance,
+ onOperationCreated,
+ onClose,
+ routeOperationDetails,
+ onAuthorizationRequired,
+}: {
+ limit: AmountJson;
+ balance: AmountJson;
+ tab: "charge-wallet" | "wire-transfer" | undefined;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onClose: () => void;
+
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ routeClose: RouteDefinition;
+ routeCashout: RouteDefinition;
+ routeChargeWallet: RouteDefinition;
+ routeWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [bankState, updateBankState] = useBankState();
+
+ return (
+ <div class="mt-4">
+ <fieldset>
+ <legend class="px-4 text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Send money</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" --> */}
+ <a name="charge wallet" href={routeChargeWallet.url({})}>
+ <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")
+ }
+ >
+ <div class="flex flex-col">
+ <span class="flex">
+ <div class="text-4xl mr-4 my-auto">&#x1F4B5;</div>
+ <span class="grow self-center text-lg text-gray-900 align-middle text-center">
+ <i18n.Translate>to a Taler wallet</i18n.Translate>
+ </span>
+ <svg
+ class="self-center flex-none 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>
+ </span>
+ <div 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>
+ </div>
+ {!!bankState.currentWithdrawalOperationId && (
+ <ShowOperationPendingTag
+ woid={bankState.currentWithdrawalOperationId}
+ onOperationAlreadyCompleted={() => {
+ updateBankState(
+ "currentWithdrawalOperationId",
+ undefined,
+ );
+ }}
+ />
+ )}
+ </div>
+ </label>
+ </a>
+
+ <a name="wire transfer" href={routeWireTransfer.url({})}>
+ <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")
+ }
+ >
+ <div class="flex flex-col">
+ <span class="flex">
+ <div class="text-4xl mr-4 my-auto">&#x2194;</div>
+ <span class="grow self-center text-lg font-medium text-gray-900 align-middle text-center">
+ <i18n.Translate>to another bank account</i18n.Translate>
+ </span>
+ <svg
+ class="self-center flex-none 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>
+ </span>
+ <div class="mt-1 flex items-center text-sm text-gray-500">
+ <i18n.Translate>
+ Make a wire transfer to an account with known bank account
+ number.
+ </i18n.Translate>
+ </div>
+ </div>
+ </label>
+ </a>
+ </div>
+ {tab === "charge-wallet" && (
+ <WalletWithdrawForm
+ routeOperationDetails={routeOperationDetails}
+ focus
+ limit={limit}
+ balance={balance}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onOperationCreated={onOperationCreated}
+ onOperationAborted={onClose}
+ routeCancel={routeClose}
+ />
+ )}
+ {tab === "wire-transfer" && (
+ <PaytoWireTransferForm
+ focus
+ routeHere={routeWireTransfer}
+ limit={limit}
+ balance={balance}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onSuccess={onClose}
+ routeCashout={routeCashout}
+ routeCancel={routeClose}
+ />
+ )}
+ </fieldset>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx
new file mode 100644
index 000000000..61cfb5629
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.stories.tsx
@@ -0,0 +1,35 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
+
+export default {
+ title: "PaytoWireTransferForm",
+};
+
+export const USD = tests.createExample(PaytoWireTransferForm, {
+ limit: {
+ currency: "USD",
+ fraction: 0,
+ value: 1,
+ },
+});
diff --git a/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
new file mode 100644
index 000000000..3bf891504
--- /dev/null
+++ b/packages/bank-ui/src/pages/PaytoWireTransferForm.tsx
@@ -0,0 +1,924 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ AmountString,
+ Amounts,
+ CurrencySpecification,
+ FRAC_SEPARATOR,
+ HttpStatusCode,
+ PaytoString,
+ PaytoUri,
+ TalerCorebankApi,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ buildPayto,
+ parsePaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ RouteDefinition,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useBankCoreApiContext,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, Ref, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { mutate } from "swr";
+import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { useSessionState } from "../hooks/session.js";
+import { undefinedIfEmpty, validateIBAN, validateTalerBank } from "../utils.js";
+
+interface Props {
+ focus?: boolean;
+ withAccount?: string;
+ withSubject?: string;
+ withAmount?: string;
+ onSuccess: () => void;
+ onAuthorizationRequired: () => void;
+ routeCancel?: RouteDefinition;
+ routeCashout?: RouteDefinition;
+ routeHere: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ limit: AmountJson;
+ balance: AmountJson;
+}
+
+export function PaytoWireTransferForm({
+ focus,
+ withAccount,
+ withSubject,
+ withAmount,
+ onSuccess,
+ routeCancel,
+ routeCashout,
+ routeHere,
+ onAuthorizationRequired,
+ limit,
+}: Props): VNode {
+ const [inputType, setInputType] = useState<"form" | "payto" | "qr">("form");
+ const isRawPayto = inputType !== "form";
+
+ const { state: credentials } = useSessionState();
+ const {
+ lib: { bank: api },
+ config,
+ url,
+ } = useBankCoreApiContext();
+
+ const sendingToFixedAccount = withAccount !== undefined;
+
+ const [account, setAccount] = useState<string | undefined>(withAccount);
+ const [subject, setSubject] = useState<string | undefined>(withSubject);
+ const [amount, setAmount] = useState<string | undefined>(withAmount);
+ const [, updateBankState] = useBankState();
+
+ const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>(
+ undefined,
+ );
+ const { i18n } = useTranslationContext();
+
+ const trimmedAmountStr = amount?.trim();
+ const parsedAmount = Amounts.parse(`${limit.currency}:${trimmedAmountStr}`);
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const paytoType =
+ config.wire_type === "X_TALER_BANK"
+ ? ("x-taler-bank" as const)
+ : ("iban" as const);
+
+ const errorsWire = undefinedIfEmpty({
+ account: !account
+ ? i18n.str`Required`
+ : paytoType === "iban"
+ ? validateIBAN(account, i18n)
+ : paytoType === "x-taler-bank"
+ ? validateTalerBank(account, i18n)
+ : undefined,
+ subject: !subject ? i18n.str`Required` : validateSubject(subject, i18n),
+ amount: !trimmedAmountStr
+ ? i18n.str`Required`
+ : !parsedAmount
+ ? i18n.str`Not valid`
+ : validateAmount(parsedAmount, limit, i18n),
+ });
+
+ const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput);
+
+ const errorsPayto = undefinedIfEmpty({
+ rawPaytoInput: !rawPaytoInput
+ ? i18n.str`Required`
+ : !parsed
+ ? i18n.str`Does not follow the pattern`
+ : validateRawPayto(parsed, limit, url.host, i18n, paytoType),
+ });
+
+ async function doSend() {
+ let payto_uri: PaytoString | undefined;
+ let sendingAmount: AmountString | undefined;
+
+ if (credentials.status !== "loggedIn") return;
+ let acName: string | undefined;
+ if (isRawPayto) {
+ const p = parsePaytoUri(rawPaytoInput!);
+ if (!p) return;
+ sendingAmount = p.params.amount as AmountString;
+ delete p.params.amount;
+ // if this payto is valid then it already have message
+ payto_uri = stringifyPaytoUri(p);
+ acName = !p.isKnown
+ ? undefined
+ : p.targetType === "iban"
+ ? p.iban
+ : p.targetType === "bitcoin"
+ ? p.address
+ : p.targetType === "x-taler-bank"
+ ? p.account
+ : assertUnreachable(p);
+ } else {
+ if (!account || !subject) return;
+ let payto;
+ acName = account;
+ switch (paytoType) {
+ case "x-taler-bank": {
+ payto = buildPayto("x-taler-bank", url.host, account);
+
+ break;
+ }
+ case "iban": {
+ payto = buildPayto("iban", account, undefined);
+ break;
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+
+ payto.params.message = encodeURIComponent(subject);
+ payto_uri = stringifyPaytoUri(payto);
+ sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString;
+ }
+ const puri = payto_uri;
+ const sAmount = sendingAmount;
+
+ await handleError(async function createTransactionHandleError() {
+ const request: TalerCorebankApi.CreateTransactionRequest = {
+ payto_uri: puri,
+ amount: sAmount,
+ };
+ const check = IdempotencyRetry.tryFiveTimes();
+ const resp = await api.createTransaction(credentials, request, check);
+ mutate(() => true);
+ if (resp.type === "fail") {
+ switch (resp.case) {
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The request was invalid or the payto://-URI used unacceptable features.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Not enough permission to complete the operation.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_ADMIN_CREDITOR:
+ return notify({
+ type: "error",
+ title: i18n.str`Bank administrator can't be the transfer creditor.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNKNOWN_CREDITOR:
+ return notify({
+ type: "error",
+ title: i18n.str`The destination account "${
+ acName ?? puri
+ }" was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_SAME_ACCOUNT:
+ return notify({
+ type: "error",
+ title: i18n.str`The origin and the destination of the transfer can't be the same.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The origin account "${puri}" was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED: {
+ return notify({
+ type: "error",
+ title: i18n.str`Tried to create the transaction ${check.maxTries} times with different UID but failed.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "create-transaction",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({
+ account: account ?? "",
+ amount,
+ subject,
+ }),
+ sent: AbsoluteTime.never(),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ notifyInfo(i18n.str`Wire transfer created!`);
+ onSuccess();
+ setAmount(undefined);
+ setAccount(undefined);
+ setSubject(undefined);
+ rawPaytoInputSetter(undefined);
+ });
+ }
+
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 my-4 md:grid-cols-3 bg-gray-100 px-4 pb-4 rounded-lg">
+ <div>
+ <fieldset class="px-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <legend class="sr-only">
+ <i18n.Translate>Input wire transfer detail</i18n.Translate>
+ </legend>
+ <div class="-space-y-px rounded-md ">
+ <label
+ data-checked={inputType === "form"}
+ class="group rounded-tl-md rounded-tr-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50"
+ >
+ <input
+ type="radio"
+ name="input-type"
+ onChange={() => {
+ if (parsed && parsed.isKnown) {
+ switch (parsed.targetType) {
+ case "iban": {
+ setAccount(parsed.iban);
+ break;
+ }
+ case "x-taler-bank": {
+ setAccount(parsed.account);
+ break;
+ }
+ case "bitcoin": {
+ break;
+ }
+ default: {
+ assertUnreachable(parsed);
+ }
+ }
+ const amountStr = !parsed.params
+ ? undefined
+ : parsed.params["amount"];
+ if (amountStr) {
+ const amount = Amounts.parse(amountStr);
+ if (amount) {
+ setAmount(Amounts.stringifyValue(amount));
+ }
+ }
+ const subject = parsed.params["message"];
+ if (subject) {
+ setSubject(subject);
+ }
+ }
+ setInputType("form");
+ }}
+ checked={inputType === "form"}
+ value="form"
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600"
+ />
+ <span class="ml-3 flex flex-col">
+ {/* <!-- Checked: "text-indigo-900", Not Checked: "text-gray-900" --> */}
+ <span
+ data-checked={inputType === "form"}
+ class="block text-sm font-medium data-[checked=true]:text-indigo-900"
+ >
+ <i18n.Translate>Using a form</i18n.Translate>
+ </span>
+ </span>
+ </label>
+ {sendingToFixedAccount ? undefined : (
+ <Fragment>
+ <label
+ data-checked={inputType === "payto"}
+ class="relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50"
+ >
+ <input
+ type="radio"
+ name="input-type"
+ onChange={() => {
+ if (account) {
+ let payto;
+ switch (paytoType) {
+ case "x-taler-bank": {
+ payto = buildPayto(
+ "x-taler-bank",
+ url.host,
+ account,
+ );
+ if (parsedAmount) {
+ payto.params["amount"] =
+ Amounts.stringify(parsedAmount);
+ }
+ if (subject) {
+ payto.params["message"] = subject;
+ }
+ break;
+ }
+ case "iban": {
+ payto = buildPayto("iban", account, undefined);
+ if (parsedAmount) {
+ payto.params["amount"] =
+ Amounts.stringify(parsedAmount);
+ }
+ if (subject) {
+ payto.params["message"] = subject;
+ }
+ break;
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+ rawPaytoInputSetter(stringifyPaytoUri(payto));
+ }
+ setInputType("payto");
+ }}
+ checked={inputType === "payto"}
+ value="payto"
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600"
+ />
+ <span class="ml-3 flex flex-col">
+ <span
+ data-checked={inputType === "payto"}
+ class="block font-medium data-[checked=true]:text-indigo-900"
+ >
+ payto:// URI
+ </span>
+ <span
+ data-checked={inputType === "payto"}
+ class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600"
+ >
+ <i18n.Translate>
+ A special URI that indicate the transfer amount and
+ account target.
+ </i18n.Translate>
+ </span>
+ </span>
+ </label>
+ {
+ //FIXME: add QR support
+ false && (
+ <label
+ data-checked={inputType === "qr"}
+ class="rounded-bl-md rounded-br-md relative flex cursor-pointer border p-4 focus:outline-none bg-white data-[checked=true]:z-10 data-[checked=true]:border-indigo-200 data-[checked=true]:bg-indigo-50"
+ >
+ <input
+ type="radio"
+ name="input-type"
+ onChange={() => {
+ setInputType("qr");
+ }}
+ checked={inputType === "qr"}
+ value="qr"
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer text-indigo-600 border-gray-300 focus:ring-indigo-600 active:ring-2 active:ring-offset-2 active:ring-indigo-600"
+ />
+ <span class="ml-3 flex flex-col">
+ <span
+ data-checked={inputType === "qr"}
+ class="block font-medium data-[checked=true]:text-indigo-900"
+ >
+ <i18n.Translate>QR code</i18n.Translate>
+ </span>
+ <span
+ data-checked={inputType === "qr"}
+ class="block text-sm text-gray-500 data-[checked=true]:text-indigo-600"
+ >
+ <i18n.Translate>
+ If you have a camera in this device you can import a
+ payto:// URI from a QR code.
+ </i18n.Translate>
+ </span>
+ </span>
+ </label>
+ )
+ }
+ </Fragment>
+ )}
+ </div>
+ {routeCashout ? (
+ <a
+ name="do cashout"
+ href={routeCashout.url({})}
+ class="bg-white p-4 rounded-lg text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </a>
+ ) : undefined}
+ </fieldset>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md sm:rounded-xl md:col-span-2 w-fit mx-auto"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="p-4 sm:p-8">
+ {!isRawPayto ? (
+ <div class="grid max-w-xs grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ {(() => {
+ switch (paytoType) {
+ case "x-taler-bank": {
+ return (
+ <TextField
+ id="x-taler-bank"
+ required
+ label={i18n.str`Recipient`}
+ help={i18n.str`Id of the recipient's account`}
+ error={errorsWire?.account}
+ onChange={setAccount}
+ value={account}
+ placeholder={i18n.str`username`}
+ focus={focus}
+ disabled={sendingToFixedAccount}
+ />
+ );
+ }
+ case "iban": {
+ return (
+ <TextField
+ id="iban"
+ required
+ label={i18n.str`Recipient`}
+ help={i18n.str`IBAN of the recipient's account`}
+ placeholder={"CC0123456789" as TranslatedString}
+ error={errorsWire?.account}
+ onChange={(v) => setAccount(v.toUpperCase())}
+ value={account}
+ focus={focus}
+ disabled={sendingToFixedAccount}
+ />
+ );
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+ })()}
+
+ <div class="sm:col-span-5">
+ <label
+ for="subject"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {i18n.str`Transfer subject`}
+ <b style={{ color: "red" }}> *</b>
+ </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={i18n.str`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">
+ <i18n.Translate>
+ Some text to identify the transfer
+ </i18n.Translate>
+ </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`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <InputAmount
+ name="amount"
+ left
+ currency={limit.currency}
+ value={trimmedAmountStr}
+ onChange={(d) => {
+ setAmount(d);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errorsWire?.amount}
+ isDirty={trimmedAmountStr !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Amount to transfer</i18n.Translate>
+ </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:`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <textarea
+ ref={focus ? doAutoFocus : undefined}
+ name="address"
+ id="address"
+ type="textarea"
+ rows={5}
+ class="block overflow-hidden w-44 sm:w-96 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
+ title={i18n.str`Uniform resource identifier of the target account`}
+ placeholder={((): TranslatedString => {
+ switch (paytoType) {
+ case "x-taler-bank":
+ return i18n.str`payto://x-taler-bank/[bank-host]/[receiver-account]?message=[subject]&amount=[${limit.currency}:X.Y]`;
+ case "iban":
+ return 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">
+ {routeCancel ? (
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ ) : (
+ <div />
+ )}
+ <button
+ type="submit"
+ name="send"
+ 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>
+ <LocalNotificationBanner notification={notification} />
+ </form>
+ </div>
+ );
+}
+
+/**
+ * Show the element when the load ended
+ * @param element
+ */
+export function doAutoFocus(element: HTMLElement | null) {
+ if (element) {
+ setTimeout(() => {
+ element.focus({ preventScroll: true });
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "center",
+ });
+ }, 100);
+ }
+}
+
+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 { config } = useBankCoreApiContext();
+ 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="disabled:bg-gray-200 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 >
+ config.currency_specification.num_fractional_input_digits
+ ) {
+ e.currentTarget.value = e.currentTarget.value.substring(
+ 0,
+ sep_pos +
+ config.currency_specification.num_fractional_input_digits +
+ 1,
+ );
+ }
+ onChange(e.currentTarget.value);
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+ </div>
+ );
+}
+
+export function RenderAmount({
+ value,
+ spec,
+ negative,
+ withColor,
+ hideSmall,
+}: {
+ spec: CurrencySpecification;
+ value: AmountJson;
+ hideSmall?: boolean;
+ negative?: boolean;
+ withColor?: boolean;
+}): VNode {
+ const neg = !!negative; // convert to true or false
+
+ const { currency, normal, small } = Amounts.stringifyValueWithSpec(
+ value,
+ spec,
+ );
+
+ return (
+ <span
+ data-negative={withColor ? neg : undefined}
+ class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600"
+ >
+ {negative ? "- " : undefined}
+ {currency} {normal}{" "}
+ {!hideSmall && small && <sup class="-ml-1">{small}</sup>}
+ </span>
+ );
+}
+
+function validateRawPayto(
+ parsed: PaytoUri,
+ limit: AmountJson,
+ host: string,
+ i18n: InternationalizationAPI,
+ type: "iban" | "x-taler-bank",
+): TranslatedString | undefined {
+ if (!parsed.isKnown) {
+ return i18n.str`The target type is unknown, use "${type}"`;
+ }
+ let result: TranslatedString | undefined;
+ switch (type) {
+ case "x-taler-bank": {
+ if (parsed.targetType !== "x-taler-bank") {
+ return i18n.str`Only "x-taler-bank" target are supported`;
+ }
+
+ if (parsed.host !== host) {
+ return i18n.str`Only this host is allowed. Use "${host}"`;
+ }
+
+ if (!parsed.account) {
+ return i18n.str`Missing account name`;
+ }
+ const result = validateTalerBank(parsed.account, i18n);
+ if (result) return result;
+ break;
+ }
+ case "iban": {
+ if (parsed.targetType !== "iban") {
+ return i18n.str`Only "IBAN" target are supported`;
+ }
+ const result = validateIBAN(parsed.iban, i18n);
+ if (result) return result;
+ break;
+ }
+ default:
+ assertUnreachable(type);
+ }
+ if (!parsed.params.amount) {
+ return i18n.str`Missing "amount" parameter to specify the amount to be transferred`;
+ }
+ const amount = Amounts.parse(parsed.params.amount);
+ if (!amount) {
+ return i18n.str`The "amount" parameter is not valid`;
+ }
+ result = validateAmount(amount, limit, i18n);
+ if (result) return result;
+
+ if (!parsed.params.message) {
+ return i18n.str`Missing the "message" parameter to specify a reference text for the transfer`;
+ }
+ const subject = parsed.params.message;
+ result = validateSubject(subject, i18n);
+ if (result) return result;
+
+ return undefined;
+}
+
+function validateAmount(
+ amount: AmountJson,
+ limit: AmountJson,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ if (amount.currency !== limit.currency) {
+ return i18n.str`The only currency allowed is "${limit.currency}"`;
+ }
+ if (Amounts.isZero(amount)) {
+ return i18n.str`Can't transfer zero amount`;
+ }
+ if (Amounts.cmp(limit, amount) === -1) {
+ return i18n.str`Balance is not enough`;
+ }
+ return undefined;
+}
+
+function validateSubject(
+ text: string,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ if (text.length < 2) {
+ return i18n.str`Use a longer subject`;
+ }
+ return undefined;
+}
+
+interface PaytoFieldProps {
+ id: string;
+ label: TranslatedString;
+ required?: boolean;
+ help?: TranslatedString;
+ placeholder?: TranslatedString;
+ error: string | undefined;
+ value: string | undefined;
+ rightIcons?: VNode;
+ onChange: (p: string) => void;
+ focus?: boolean;
+ disabled?: boolean;
+}
+
+function Wrapper({
+ withIcon,
+ children,
+}: {
+ withIcon: boolean;
+ children: ComponentChildren;
+}): VNode {
+ if (withIcon) {
+ return <div class="flex justify-between">{children}</div>;
+ }
+ return <Fragment>{children}</Fragment>;
+}
+
+export function TextField({
+ id,
+ label,
+ help,
+ focus,
+ disabled,
+ onChange,
+ placeholder,
+ rightIcons,
+ required,
+ value,
+ error,
+}: PaytoFieldProps): VNode {
+ return (
+ <div class="sm:col-span-5">
+ <label for={id} class="block text-sm font-medium leading-6 text-gray-900">
+ {label}
+ {required && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <Wrapper withIcon={rightIcons !== undefined}>
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ 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"
+ name={id}
+ id={id}
+ disabled={disabled}
+ value={value ?? ""}
+ placeholder={placeholder}
+ autocomplete="off"
+ required
+ onInput={(e): void => {
+ onChange(e.currentTarget.value);
+ }}
+ />
+ {rightIcons}
+ </Wrapper>
+ <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+ </div>
+ {help && <p class="mt-2 text-sm text-gray-500">{help}</p>}
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/ProfileNavigation.tsx b/packages/bank-ui/src/pages/ProfileNavigation.tsx
new file mode 100644
index 000000000..3e81e307c
--- /dev/null
+++ b/packages/bank-ui/src/pages/ProfileNavigation.tsx
@@ -0,0 +1,202 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { assertUnreachable } from "@gnu-taler/taler-util";
+import {
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function ProfileNavigation({
+ current,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeMyAccountPassword,
+ routeConversionConfig,
+}: {
+ current: "details" | "delete" | "credentials" | "cashouts" | "conversion";
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+ const { state: credentials } = useSessionState();
+ const isAdminUser =
+ credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator;
+ const nonAdminUser = !isAdminUser;
+
+ const { navigateTo } = useNavigationContext();
+ return (
+ <div>
+ <div class="sm:hidden">
+ <label for="tabs" class="sr-only">
+ <i18n.Translate>Select a section</i18n.Translate>
+ </label>
+ <select
+ id="tabs"
+ name="tabs"
+ class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
+ onChange={(e) => {
+ const op = e.currentTarget.value as typeof current;
+ switch (op) {
+ case "details": {
+ navigateTo(routeMyAccountDetails.url({}));
+ return;
+ }
+ case "delete": {
+ navigateTo(routeMyAccountDelete.url({}));
+ return;
+ }
+ case "credentials": {
+ navigateTo(routeMyAccountPassword.url({}));
+ return;
+ }
+ case "cashouts": {
+ navigateTo(routeMyAccountCashout.url({}));
+ return;
+ }
+ case "conversion": {
+ navigateTo(routeConversionConfig.url({}));
+ return;
+ }
+ default:
+ assertUnreachable(op);
+ }
+ }}
+ >
+ <option value="details" selected={current == "details"}>
+ <i18n.Translate>Details</i18n.Translate>
+ </option>
+ {!config.allow_deletions ? undefined : (
+ <option value="delete" selected={current == "delete"}>
+ <i18n.Translate>Delete</i18n.Translate>
+ </option>
+ )}
+ <option value="credentials" selected={current == "credentials"}>
+ <i18n.Translate>Credentials</i18n.Translate>
+ </option>
+ {config.allow_conversion ? (
+ <Fragment>
+ <option value="cashouts" selected={current == "cashouts"}>
+ <i18n.Translate>Cashouts</i18n.Translate>
+ </option>
+ <option value="conversion" selected={current == "cashouts"}>
+ <i18n.Translate>Conversion</i18n.Translate>
+ </option>
+ </Fragment>
+ ) : undefined}
+ </select>
+ </div>
+ <div class="hidden sm:block">
+ <nav
+ class="isolate flex divide-x divide-gray-200 rounded-lg shadow"
+ aria-label="Tabs"
+ >
+ <a
+ name="my account details"
+ href={routeMyAccountDetails.url({})}
+ data-selected={current == "details"}
+ class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Details</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "details"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ {!config.allow_deletions ? undefined : (
+ <a
+ name="my account delete"
+ href={routeMyAccountDelete.url({})}
+ data-selected={current == "delete"}
+ aria-current="page"
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Delete</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "delete"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ )}
+ <a
+ name="my account password"
+ href={routeMyAccountPassword.url({})}
+ data-selected={current == "credentials"}
+ aria-current="page"
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Credentials</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "credentials"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ {config.allow_conversion && nonAdminUser ? (
+ <a
+ name="my account cashout"
+ href={routeMyAccountCashout.url({})}
+ data-selected={current == "cashouts"}
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Cashouts</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "cashouts"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ ) : undefined}
+ {config.allow_conversion && isAdminUser ? (
+ <a
+ name="conversion config"
+ href={routeConversionConfig.url({})}
+ data-selected={current == "conversion"}
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Conversion</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={current == "conversion"}
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </a>
+ ) : undefined}
+ </nav>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/PublicHistoriesPage.tsx b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx
new file mode 100644
index 000000000..80ae28dde
--- /dev/null
+++ b/packages/bank-ui/src/pages/PublicHistoriesPage.tsx
@@ -0,0 +1,98 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { TalerError } from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Transactions } from "../components/Transactions/index.js";
+import { usePublicAccounts } from "../hooks/account.js";
+
+/**
+ * Show histories of public accounts.
+ */
+export function PublicHistoriesPage(): VNode {
+ const { i18n } = useTranslationContext();
+
+ // TODO: implemented filter by account name
+ const result = usePublicAccounts(undefined);
+ const firstAccount =
+ result && !(result instanceof TalerError) && result.body.length > 0
+ ? result.body[0].username
+ : undefined;
+
+ const [showAccount, setShowAccount] = useState(firstAccount);
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <Loading />;
+ }
+
+ const { body: accountList } = result;
+
+ const txs: Record<string, h.JSX.Element> = {};
+ const accountsBar = [];
+
+ // Ask story of all the public accounts.
+ for (const account of accountList) {
+ const isSelected = account.username == showAccount;
+ accountsBar.push(
+ <li
+ class={
+ isSelected
+ ? "pure-menu-selected pure-menu-item"
+ : "pure-menu-item pure-menu"
+ }
+ >
+ <a
+ href="#"
+ name={`show account ${account.username}`}
+ class="pure-menu-link"
+ onClick={() => setShowAccount(account.username)}
+ >
+ {account.username}
+ </a>
+ </li>,
+ );
+ txs[account.username] = (
+ <Transactions
+ account={account.username}
+ routeCreateWireTransfer={undefined}
+ />
+ );
+ }
+
+ return (
+ <Fragment>
+ <h1 class="nav">{i18n.str`History of public accounts`}</h1>
+ <section id="main">
+ <article>
+ <div class="pure-menu pure-menu-horizontal" name="accountMenu">
+ <ul class="pure-menu-list">{accountsBar}</ul>
+ {typeof showAccount !== "undefined" ? (
+ txs[showAccount]
+ ) : (
+ <p>No public transactions found.</p>
+ )}
+ <br />
+ </div>
+ </article>
+ </section>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/QrCodeSection.stories.tsx b/packages/bank-ui/src/pages/QrCodeSection.stories.tsx
new file mode 100644
index 000000000..d53d2e7b4
--- /dev/null
+++ b/packages/bank-ui/src/pages/QrCodeSection.stories.tsx
@@ -0,0 +1,32 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+/**
+ *
+ * @author Sebastian Javier Marchano (sebasjm)
+ */
+
+import * as tests from "@gnu-taler/web-util/testing";
+import { QrCodeSection } from "./QrCodeSection.js";
+import { parseWithdrawUri } from "@gnu-taler/taler-util";
+
+export default {
+ title: "Qr Code Selection",
+};
+
+export const SimpleExample = tests.createExample(QrCodeSection, {
+ withdrawUri: parseWithdrawUri("taler://withdraw/bank.com/operationId"),
+});
diff --git a/packages/bank-ui/src/pages/QrCodeSection.tsx b/packages/bank-ui/src/pages/QrCodeSection.tsx
new file mode 100644
index 000000000..359d4c18f
--- /dev/null
+++ b/packages/bank-ui/src/pages/QrCodeSection.tsx
@@ -0,0 +1,152 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ HttpStatusCode,
+ stringifyWithdrawUri,
+ WithdrawUriResult,
+} from "@gnu-taler/taler-util";
+import {
+ Button,
+ LocalNotificationBanner,
+ useLocalNotificationHandler,
+ useTalerWalletIntegrationAPI,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useEffect } from "preact/hooks";
+import { QR } from "../components/QR.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+
+export function QrCodeSection({
+ withdrawUri,
+ onAborted,
+}: {
+ withdrawUri: WithdrawUriResult;
+ onAborted: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const walletInegrationApi = useTalerWalletIntegrationAPI();
+ const talerWithdrawUri = stringifyWithdrawUri(withdrawUri);
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+
+ useEffect(() => {
+ walletInegrationApi.publishTalerAction(withdrawUri);
+ }, []);
+
+ const [notification, handleError] = useLocalNotificationHandler();
+
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const onAbortHandler = handleError(
+ async () => {
+ if (!creds) return undefined;
+ return api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId);
+ },
+ onAborted,
+ (fail) => {
+ switch (fail.case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`The operation id is invalid.`;
+ case HttpStatusCode.NotFound:
+ return i18n.str`The operation was not found.`;
+ case HttpStatusCode.Conflict:
+ return i18n.str`The reserve operation has been confirmed previously and can't be aborted`;
+ }
+ },
+ );
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ <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 following instructions in
+ </i18n.Translate>{" "}
+ <a
+ class="font-semibold text-gray-500 hover:text-gray-400"
+ name="wallet page"
+ href="https://taler.net/en/wallet.html"
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ .
+ </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"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ handler={onAbortHandler}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ <a
+ href={talerWithdrawUri}
+ name="withdraw"
+ 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 Taler 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>
+ <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"
+ handler={onAbortHandler}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx
new file mode 100644
index 000000000..61939c3d6
--- /dev/null
+++ b/packages/bank-ui/src/pages/RegistrationPage.tsx
@@ -0,0 +1,423 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util";
+import {
+ LocalNotificationBanner,
+ RouteDefinition,
+ ShowInputErrorLabel,
+ useBankCoreApiContext,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useSettingsContext } from "../context/settings.js";
+import { undefinedIfEmpty } from "../utils.js";
+import { getRandomPassword, getRandomUsername } from "./rnd.js";
+
+export function RegistrationPage({
+ onRegistrationSuccesful,
+ routeCancel,
+}: {
+ onRegistrationSuccesful: (user: string, password: string) => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+ if (!config.allow_registrations) {
+ return (
+ <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p>
+ );
+ }
+ return (
+ <RegistrationForm
+ onRegistrationSuccesful={onRegistrationSuccesful}
+ routeCancel={routeCancel}
+ />
+ );
+}
+
+// eslint-disable-next-line no-useless-escape
+export const USERNAME_REGEX = /^[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({
+ onRegistrationSuccesful,
+ routeCancel,
+}: {
+ onRegistrationSuccesful: (user: string, password: string) => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ 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 [notification, , handleError] = useLocalNotification();
+ const settings = useSettingsContext();
+
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ // 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, numbers or any of these characters: - . _ ~`
+ : 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,
+ });
+
+ async function doRegistrationAndLogin(
+ name: string,
+ username: string,
+ password: string,
+ onComplete: () => void,
+ ) {
+ await handleError(async (onError) => {
+ const resp = await api.createAccount(undefined, {
+ name,
+ username,
+ password,
+ });
+ if (resp.type === "ok") {
+ onComplete();
+ } else {
+ onError(resp, (_case) => {
+ switch (_case) {
+ case HttpStatusCode.BadRequest:
+ return i18n.str`Server replied with invalid phone or email.`;
+ case HttpStatusCode.Unauthorized:
+ return i18n.str`No enough permission to create that account.`;
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return i18n.str`Registration is disabled because the bank ran out of bonus credit.`;
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return i18n.str`That username can't be used because is reserved.`;
+ case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
+ return i18n.str`That username is already taken.`;
+ case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
+ return i18n.str`That account id is already taken.`;
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return i18n.str`No information for the selected authentication channel.`;
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return i18n.str`Authentication channel is not supported.`;
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return i18n.str`Only admin is allow to set debt limit.`;
+ case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT:
+ return i18n.str`Only the administrator can change the minimum cashout limit.`;
+ case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
+ return i18n.str`Only admin can create accounts with second factor authentication.`;
+ }
+ });
+ }
+ });
+ }
+
+ async function doRegistrationStep() {
+ if (!username || !password || !name) return;
+ await doRegistrationAndLogin(name, username, password, () => {
+ setUsername(undefined);
+ setPassword(undefined);
+ setRepeatPassword(undefined);
+ onRegistrationSuccesful(username, password);
+ });
+ }
+
+ async function doRandomRegistration() {
+ const user = getRandomUsername();
+
+ const password = settings.simplePasswordForRandomAccounts
+ ? "123"
+ : getRandomPassword();
+ const username = `_${user.first}-${user.second}_`;
+ const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter(
+ user.second,
+ )}`;
+ await doRegistrationAndLogin(name, username, password, () => {
+ onRegistrationSuccesful(username, password);
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <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>
+ <label
+ for="username"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Login 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="account identification to login"
+ 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>
+ <div class="flex items-center justify-between">
+ <label
+ for="name"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ <i18n.Translate>Full name</i18n.Translate>
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ </div>
+ <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="John Doe"
+ 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">
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ 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"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="register"
+ 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={async (e) => {
+ e.preventDefault();
+
+ doRegistrationStep();
+ }}
+ >
+ <i18n.Translate>Register</i18n.Translate>
+ </button>
+ </div>
+ </form>
+
+ {settings.allowRandomAccountCreation && (
+ <p class="mt-10 text-center text-sm text-gray-500 border-t">
+ <button
+ type="submit"
+ name="create random"
+ 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 temporary user</i18n.Translate>
+ </button>
+ </p>
+ )}
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function capitalizeFirstLetter(str: string) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+}
diff --git a/packages/bank-ui/src/pages/ShowNotifications.tsx b/packages/bank-ui/src/pages/ShowNotifications.tsx
new file mode 100644
index 000000000..fe041fb19
--- /dev/null
+++ b/packages/bank-ui/src/pages/ShowNotifications.tsx
@@ -0,0 +1,55 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { useNotifications } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { Time } from "../components/Time.js";
+
+export function ShowNotifications(): VNode {
+ const ns = useNotifications();
+ if (!ns.length) {
+ return <div>no notifications</div>;
+ }
+ return (
+ <div>
+ <p>Notifications</p>
+ <table>
+ <thead></thead>
+ <tbody>
+ {ns.map((n, idx) => {
+ return (
+ <tr key={idx}>
+ <td>
+ <Time
+ timestamp={n.message.when}
+ format="dd/MM/yyyy HH:mm:ss"
+ />
+ </td>
+ <td>{n.message.title}</td>
+ <td>
+ {n.message.type === "error"
+ ? n.message.description
+ : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ {/* <ToastBanner all /> */}
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx
new file mode 100644
index 000000000..624890468
--- /dev/null
+++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx
@@ -0,0 +1,793 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ Amounts,
+ Duration,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotification,
+ useNavigationContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { Time } from "../components/Time.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js";
+import { useConversionInfo } from "../hooks/regional.js";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
+import { OperationNotFound } from "./WithdrawalQRCode.js";
+import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js";
+
+const TAN_PREFIX = "T-";
+const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/;
+export function SolveChallengePage({
+ onChallengeCompleted,
+ routeClose,
+}: {
+ onChallengeCompleted: () => void;
+ routeClose: RouteDefinition;
+}): VNode {
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const { i18n } = useTranslationContext();
+ const [bankState, updateBankState] = useBankState();
+ const [code, setCode] = useState<string | undefined>(undefined);
+ const [notification, notify, handleError] = useLocalNotification();
+ const { state } = useSessionState();
+ const creds = state.status !== "loggedIn" ? undefined : state;
+ const { navigateTo } = useNavigationContext();
+
+ if (!bankState.currentChallenge) {
+ return (
+ <div>
+ <span>no challenge to solve </span>
+ <a
+ href={routeClose.url({})}
+ name="close"
+ 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"
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </a>
+ </div>
+ );
+ }
+
+ const ch = bankState.currentChallenge;
+ const errors = undefinedIfEmpty({
+ code: !code
+ ? i18n.str`Required`
+ : !TAN_REGEX.test(code)
+ ? i18n.str`Confirmation codes are numerical, possibly beginning with 'T-.'`
+ : undefined,
+ });
+
+ async function startChallenge() {
+ if (!creds) return;
+ await handleError(async () => {
+ const resp = await api.sendChallenge(creds, ch.id);
+ if (resp.type === "ok") {
+ const newCh = structuredClone(ch);
+ newCh.sent = AbsoluteTime.now();
+ newCh.info = resp.body;
+ updateBankState("currentChallenge", newCh);
+ } else {
+ const newCh = structuredClone(ch);
+ newCh.sent = AbsoluteTime.now();
+ newCh.info = undefined;
+ updateBankState("currentChallenge", newCh);
+ switch (resp.case) {
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ async function completeChallenge() {
+ if (!creds || !code) return;
+ const tan = code.toUpperCase().startsWith(TAN_PREFIX)
+ ? code.substring(TAN_PREFIX.length)
+ : code;
+ await handleError(async () => {
+ {
+ const resp = await api.confirmChallenge(creds, ch.id, { tan });
+ if (resp.type === "fail") {
+ setCode("");
+ switch (resp.case) {
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Challenge not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`This user is not authorized to complete this challenge.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.TooManyRequests:
+ return notify({
+ type: "error",
+ title: i18n.str`Too many attempts, try another code.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`The confirmation code is wrong, try again.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation expired.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ }
+ {
+ const resp = await (async (ch: ChallengeInProgess) => {
+ switch (ch.operation) {
+ case "delete-account":
+ return await api.deleteAccount(creds, ch.id);
+ case "update-account":
+ return await api.updateAccount(creds, ch.request, ch.id);
+ case "update-password":
+ return await api.updatePassword(creds, ch.request, ch.id);
+ case "create-transaction":
+ return await api.createTransaction(creds, ch.request, undefined, ch.id);
+ case "confirm-withdrawal":
+ return await api.confirmWithdrawalById(creds, ch.request, ch.id);
+ case "create-cashout":
+ return await api.createCashout(creds, ch.request, ch.id);
+ default:
+ assertUnreachable(ch);
+ }
+ })(ch);
+
+ if (resp.type === "fail") {
+ if (resp.case !== HttpStatusCode.Accepted) {
+ return notify({
+ type: "error",
+ title: i18n.str`The operation failed.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ // another challenge required, save the request and the ID
+ // @ts-expect-error no need to check the type of request, since it will be the same as the previous request
+ updateBankState("currentChallenge", {
+ operation: ch.operation,
+ id: String(resp.body.challenge_id),
+ location: ch.location,
+ sent: AbsoluteTime.never(),
+ request: ch.request,
+ });
+ return notify({
+ type: "info",
+ title: i18n.str`The operation needs another confirmation to complete.`,
+ when: AbsoluteTime.now(),
+ });
+ }
+ updateBankState("currentChallenge", undefined);
+ return onChallengeCompleted();
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 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">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Confirm the operation</i18n.Translate>
+ </span>
+ </h2>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ This operation is protected with second factor authentication. In
+ order to complete it we need to verify your identity using the
+ authentication channel you provided.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
+ <ChallengeDetails
+ challenge={bankState.currentChallenge}
+ onStart={startChallenge}
+ onCancel={() => {
+ updateBankState("currentChallenge", undefined);
+ navigateTo(ch.location);
+ }}
+ />
+ {ch.info && (
+ <div class="mt-2">
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-4">
+ <label for="withdraw-amount">
+ <i18n.Translate>Enter the confirmation code</i18n.Translate>
+ </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={code ?? ""}
+ required
+ onPaste={(e) => {
+ e.preventDefault();
+ const pasted = e.clipboardData?.getData("text/plain");
+ if (!pasted) return;
+ if (pasted.toUpperCase().startsWith(TAN_PREFIX)) {
+ const sub = pasted.substring(TAN_PREFIX.length);
+ setCode(sub);
+ return;
+ }
+ setCode(pasted);
+ }}
+ name="answer"
+ id="answer"
+ autocomplete="off"
+ onChange={(e): void => {
+ setCode(e.currentTarget.value);
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel
+ message={errors?.code}
+ isDirty={code !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ {((ch: TalerCorebankApi.TanChannel): VNode => {
+ switch (ch) {
+ case TalerCorebankApi.TanChannel.SMS:
+ return (
+ <i18n.Translate>
+ You should have received a code in your phone.
+ </i18n.Translate>
+ );
+ case TalerCorebankApi.TanChannel.EMAIL:
+ return (
+ <i18n.Translate>
+ You should have received a code in your email.
+ </i18n.Translate>
+ );
+ default:
+ assertUnreachable(ch);
+ }
+ })(ch.info.tan_channel)}
+ </p>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ The confirmation code starts with "{TAN_PREFIX}" followed
+ by numbers.
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="flex items-center justify-between border-gray-900/10 px-4 py-4 ">
+ <div />
+ <button
+ type="submit"
+ name="confirm"
+ 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) => {
+ completeChallenge();
+ e.preventDefault();
+ }}
+ >
+ <i18n.Translate>Confirm</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ )}
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+function ChallengeDetails({
+ challenge,
+ onStart,
+ onCancel,
+}: {
+ challenge: ChallengeInProgess;
+ onStart: () => void;
+ onCancel: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+
+ const firstTime = AbsoluteTime.isNever(challenge.sent);
+ useEffect(() => {
+ if (firstTime) {
+ onStart();
+ }
+ }, []);
+
+ const subtitle = ((op): TranslatedString => {
+ switch (op) {
+ case "delete-account":
+ return i18n.str`Removing account`;
+ case "update-account":
+ return i18n.str`Updating account values`;
+ case "update-password":
+ return i18n.str`Updating password`;
+ case "create-transaction":
+ return i18n.str`Making a wire transfer`;
+ case "confirm-withdrawal":
+ return i18n.str`Confirming withdrawal`;
+ case "create-cashout":
+ return i18n.str`Making a cashout`;
+ }
+ })(challenge.operation);
+
+ return (
+ <div class="px-4 mt-4 ">
+ <div class="w-full">
+ <div class="border-gray-100">
+ <h2 class="text-base font-semibold leading-10 text-gray-900">
+ <span class=" text-black font-semibold leading-6 ">
+ <i18n.Translate>Operation:</i18n.Translate>
+ </span>{" "}
+ &nbsp;
+ <span class=" text-black font-normal leading-6 ">{subtitle}</span>
+ </h2>
+ <dl class="divide-y divide-gray-100">
+ {((): VNode => {
+ switch (challenge.operation) {
+ case "delete-account":
+ 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">
+ <i18n.Translate>Type</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <i18n.Translate>
+ Updating account settings
+ </i18n.Translate>
+ </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">
+ <i18n.Translate>Account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request}
+ </dd>
+ </div>
+ </Fragment>
+ );
+ case "create-transaction": {
+ const payto = parsePaytoUri(challenge.request.payto_uri)!;
+ return (
+ <Fragment>
+ {challenge.request.amount && (
+ <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">
+ <i18n.Translate>Amount</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(
+ challenge.request.amount,
+ )}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ {payto.isKnown && payto.targetType === "iban" && (
+ <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">
+ <i18n.Translate>To account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {payto.iban}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "confirm-withdrawal":
+ return <ShowWithdrawalDetails id={challenge.request} />;
+ case "create-cashout": {
+ return <ShowCashoutDetails request={challenge.request} />;
+ }
+ case "update-account": {
+ return (
+ <Fragment>
+ {challenge.request.cashout_payto_uri !== undefined && (
+ <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">
+ <i18n.Translate>Cashout account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.cashout_payto_uri}
+ </dd>
+ </div>
+ )}
+ {challenge.request.contact_data?.email !== undefined && (
+ <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">
+ <i18n.Translate>Email</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.contact_data?.email}
+ </dd>
+ </div>
+ )}
+ {challenge.request.contact_data?.phone !== undefined && (
+ <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">
+ <i18n.Translate>Phone</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.contact_data?.phone}
+ </dd>
+ </div>
+ )}
+ {challenge.request.debit_threshold !== undefined && (
+ <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">
+ <i18n.Translate>Debit threshold</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(
+ challenge.request.debit_threshold,
+ )}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ {challenge.request.is_public !== undefined && (
+ <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">
+ <i18n.Translate>
+ Is this account public?
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.is_public
+ ? i18n.str`Enable`
+ : i18n.str`Disable`}
+ </dd>
+ </div>
+ )}
+ {challenge.request.name !== undefined && (
+ <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">
+ <i18n.Translate>Name</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.name}
+ </dd>
+ </div>
+ )}
+ {challenge.request.tan_channel !== undefined && (
+ <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">
+ <i18n.Translate>
+ Authentication channel
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.tan_channel ?? i18n.str`Remove`}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "update-password": {
+ 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">
+ <i18n.Translate>New password</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.request.new_password}
+ </dd>
+ </div>
+ </Fragment>
+ );
+ }
+ default:
+ assertUnreachable(challenge);
+ }
+ })()}
+ </dl>
+ {challenge.info && (
+ <h2 class="text-base font-semibold leading-7 text-gray-900 mt-4">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Challenge details</i18n.Translate>
+ </span>
+ </h2>
+ )}
+ <dl class="divide-y divide-gray-100">
+ {challenge.sent.t_ms !== "never" && (
+ <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">
+ <i18n.Translate>Sent at</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <Time
+ format="dd/MM/yyyy HH:mm:ss"
+ timestamp={challenge.sent}
+ relative={Duration.fromSpec({ days: 1 })}
+ />
+ </dd>
+ </div>
+ )}
+ {challenge.info && (
+ <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">
+ {((ch: TalerCorebankApi.TanChannel): VNode => {
+ switch (ch) {
+ case TalerCorebankApi.TanChannel.SMS:
+ return <i18n.Translate>To phone</i18n.Translate>;
+ case TalerCorebankApi.TanChannel.EMAIL:
+ return <i18n.Translate>To email</i18n.Translate>;
+ default:
+ assertUnreachable(ch);
+ }
+ })(challenge.info.tan_channel)}
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {challenge.info.tan_info}
+ </dd>
+ </div>
+ )}
+ </dl>
+ </div>
+ <div class="mt-6 mb-4 flex justify-between">
+ <button
+ type="button"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={onCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ {challenge.info ? (
+ <button
+ type="submit"
+ name="send again"
+ 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) => {
+ onStart();
+ e.preventDefault();
+ }}
+ >
+ <i18n.Translate>Send again</i18n.Translate>
+ </button>
+ ) : (
+ <div> sending code ...</div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function ShowWithdrawalDetails({ id }: { id: string }): VNode {
+ const details = useWithdrawalDetails(id);
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+ if (!details) {
+ return <Loading />;
+ }
+ if (details instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={details} />;
+ }
+ if (details.type === "fail") {
+ switch (details.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound:
+ return <OperationNotFound routeClose={undefined} />;
+ default:
+ assertUnreachable(details);
+ }
+ }
+
+ 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">Amount</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(details.body.amount)}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ {details.body.selected_reserve_pub !== undefined && (
+ <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">
+ <i18n.Translate>Withdraw id</i18n.Translate>
+ </dt>
+ <dd
+ class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"
+ title={details.body.selected_reserve_pub}
+ >
+ {details.body.selected_reserve_pub.substring(0, 16)}...
+ </dd>
+ </div>
+ )}
+ {details.body.selected_exchange_account !== undefined && (
+ <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">
+ <i18n.Translate>To account</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.body.selected_exchange_account}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+}
+
+function ShowCashoutDetails({
+ request,
+}: {
+ request: TalerCorebankApi.CashoutRequest;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const info = useConversionInfo();
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ return (
+ <Fragment>
+ {request.subject !== undefined && (
+ <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">
+ <i18n.Translate>Subject</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {request.subject}
+ </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">Debit</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(request.amount_credit)}
+ spec={info.body.regional_currency_specification}
+ />
+ </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">Credit</dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={Amounts.parseOrThrow(request.amount_credit)}
+ spec={info.body.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/WalletWithdrawForm.tsx b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx
new file mode 100644
index 000000000..a9c652643
--- /dev/null
+++ b/packages/bank-ui/src/pages/WalletWithdrawForm.tsx
@@ -0,0 +1,404 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TranslatedString,
+ assertUnreachable,
+ parseWithdrawUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyError,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { forwardRef } from "preact/compat";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../hooks/session.js";
+import { useBankState } from "../hooks/bank-state.js";
+import { usePreferences } from "../hooks/preferences.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../utils.js";
+import { OperationState } from "./OperationState/index.js";
+import {
+ InputAmount,
+ RenderAmount,
+ doAutoFocus,
+} from "./PaytoWireTransferForm.js";
+
+const RefAmount = forwardRef(InputAmount);
+
+function OldWithdrawalForm({
+ onOperationCreated,
+ limit,
+ balance,
+ routeCancel,
+ focus,
+ routeOperationDetails,
+}: {
+ limit: AmountJson;
+ balance: AmountJson;
+ focus?: boolean;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ onOperationCreated: (wopid: string) => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+
+ // const walletInegrationApi = useTalerWalletIntegrationAPI()
+ // const { navigateTo } = useNavigationContext();
+
+ const [bankState, updateBankState] = useBankState();
+ const {
+ lib: { bank: api },
+ config,
+ } = useBankCoreApiContext();
+
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+
+ const [amountStr, setAmountStr] = useState<string | undefined>(
+ `${settings.maxWithdrawalAmount}`,
+ );
+ const [notification, notify, handleError] = useLocalNotification();
+
+ if (bankState.currentWithdrawalOperationId) {
+ // FIXME: doing the preventDefault is not optimal
+
+ // const suri = stringifyWithdrawUri({
+ // bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl,
+ // withdrawalOperationId: bankState.currentWithdrawalOperationId,
+ // });
+ // const uri = parseWithdrawUri(suri)!
+ const url = routeOperationDetails.url({
+ wopid: bankState.currentWithdrawalOperationId,
+ });
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`There is an operation already`}
+ onClose={() => {
+ updateBankState("currentWithdrawalOperationId", undefined);
+ }}
+ >
+ <span ref={focus ? doAutoFocus : undefined} />
+ <i18n.Translate>Complete the operation in</i18n.Translate>{" "}
+ <a
+ class="font-semibold text-yellow-700 hover:text-yellow-600"
+ name="complete operation"
+ href={url}
+ // onClick={(e) => {
+ // e.preventDefault()
+ // walletInegrationApi.publishTalerAction(uri, () => {
+ // navigateTo(url)
+ // })
+ // }}
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </Attention>
+ );
+ }
+
+ const trimmedAmountStr = amountStr?.trim();
+
+ const parsedAmount = trimmedAmountStr
+ ? Amounts.parse(`${limit.currency}:${trimmedAmountStr}`)
+ : undefined;
+
+ const errors = undefinedIfEmpty({
+ amount:
+ trimmedAmountStr == null
+ ? i18n.str`Required`
+ : !parsedAmount
+ ? i18n.str`Invalid`
+ : Amounts.cmp(limit, parsedAmount) === -1
+ ? i18n.str`Balance is not enough`
+ : undefined,
+ });
+
+ async function doStart() {
+ if (!parsedAmount || !creds) return;
+ await handleError(async () => {
+ const resp = await api.createWithdrawal(creds, {
+ amount: Amounts.stringify(parsedAmount),
+ });
+ if (resp.type === "ok") {
+ const uri = parseWithdrawUri(resp.body.taler_withdraw_uri);
+ if (!uri) {
+ return notifyError(
+ i18n.str`Server responded with an invalid withdraw URI`,
+ i18n.str`Withdraw URI: ${resp.body.taler_withdraw_uri}`,
+ );
+ } else {
+ updateBankState(
+ "currentWithdrawalOperationId",
+ uri.withdrawalOperationId,
+ );
+ onOperationCreated(uri.withdrawalOperationId);
+ }
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict: {
+ notify({
+ type: "error",
+ title: i18n.str`The operation was rejected due to insufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ break;
+ }
+ case HttpStatusCode.Unauthorized: {
+ notify({
+ type: "error",
+ title: i18n.str`The operation was rejected due to insufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ break;
+ }
+ case HttpStatusCode.NotFound: {
+ notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ break;
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ 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();
+ }}
+ >
+ <LocalNotificationBanner notification={notification} />
+
+ <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>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Current balance is{" "}
+ <RenderAmount
+ value={balance}
+ spec={config.currency_specification}
+ />
+ </i18n.Translate>
+ </p>
+ {Amounts.cmp(limit, balance) > 0 ? (
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Your account allows you to withdraw{" "}
+ <RenderAmount
+ value={limit}
+ spec={config.currency_specification}
+ />
+ </i18n.Translate>
+ </p>
+ ) : undefined}
+ <div class="mt-4">
+ <div class="sm:inline">
+ <button
+ type="button"
+ name="set 50"
+ 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"
+ name="set 25"
+ 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"
+ name="set 10"
+ 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"
+ name="set 5"
+ 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">
+ <a
+ href={routeCancel.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="continue"
+ 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 WalletWithdrawForm({
+ focus,
+ limit,
+ balance,
+ routeCancel,
+ onAuthorizationRequired,
+ onOperationCreated,
+ onOperationAborted,
+ routeOperationDetails,
+}: {
+ limit: AmountJson;
+ balance: AmountJson;
+ focus?: boolean;
+ routeOperationDetails: RouteDefinition<{ wopid: string }>;
+ onAuthorizationRequired: () => void;
+ onOperationCreated: (wopid: string) => void;
+ onOperationAborted: () => void;
+ routeCancel: RouteDefinition;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings, updateSettings] = usePreferences();
+
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 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 Taler 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 Taler wallet`}
+ onClose={() => {
+ updateSettings("showInstallWallet", false);
+ }}
+ >
+ <i18n.Translate>
+ If you don't have one yet you can follow the instruction in
+ </i18n.Translate>{" "}
+ <a
+ target="_blank"
+ name="wallet page"
+ rel="noreferrer noopener"
+ class="font-semibold text-blue-700 hover:text-blue-600"
+ href="https://taler.net/en/wallet.html"
+ >
+ <i18n.Translate>this page</i18n.Translate>
+ </a>
+ </Attention>
+ )}
+
+ {!settings.fastWithdrawal ? (
+ <OldWithdrawalForm
+ focus={focus}
+ routeOperationDetails={routeOperationDetails}
+ limit={limit}
+ balance={balance}
+ routeCancel={routeCancel}
+ onOperationCreated={onOperationCreated}
+ />
+ ) : (
+ <OperationState
+ currency={limit.currency}
+ onAuthorizationRequired={onAuthorizationRequired}
+ routeClose={routeCancel}
+ routeHere={routeOperationDetails}
+ onAbort={onOperationAborted}
+ // route={routeCancel}
+ />
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/WireTransfer.tsx b/packages/bank-ui/src/pages/WireTransfer.tsx
new file mode 100644
index 000000000..f45390938
--- /dev/null
+++ b/packages/bank-ui/src/pages/WireTransfer.tsx
@@ -0,0 +1,119 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Loading,
+ notifyInfo,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useAccountDetails } from "../hooks/account.js";
+import { useSessionState } from "../hooks/session.js";
+import { LoginForm } from "./LoginForm.js";
+import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function WireTransfer({
+ toAccount,
+ withSubject,
+ withAmount,
+ onAuthorizationRequired,
+ routeCancel,
+ routeHere,
+ onSuccess,
+}: {
+ onSuccess?: () => void;
+ routeHere: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+ toAccount?: string;
+ withSubject?: string;
+ withAmount?: string;
+ routeCancel?: RouteDefinition;
+ onAuthorizationRequired: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const r = useSessionState();
+ const account = r.state.status !== "loggedOut" ? r.state.username : "admin";
+ const result = useAccountDetails(account);
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={account} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={account} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+ const { body: data } = result;
+
+ const balance = Amounts.parseOrThrow(data.balance.amount);
+ const balanceIsDebit = data.balance.credit_debit_indicator == "debit";
+
+ const debitThreshold = Amounts.parseOrThrow(data.debit_threshold);
+
+ if (!balance) return <Fragment />;
+
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+
+ const positiveBalance = balanceIsDebit
+ ? Amounts.zeroOfAmount(balance)
+ : balance;
+ return (
+ <div class="px-4 mt-8">
+ <div class="sm:flex sm:items-center mb-4">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Make a wire transfer</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <PaytoWireTransferForm
+ withAccount={toAccount}
+ withAmount={withAmount}
+ balance={positiveBalance}
+ withSubject={withSubject}
+ routeHere={routeHere}
+ limit={limit}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onSuccess={() => {
+ notifyInfo(i18n.str`Wire transfer created!`);
+ if (onSuccess) onSuccess();
+ }}
+ routeCancel={routeCancel}
+ />
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
new file mode 100644
index 000000000..853dd7bae
--- /dev/null
+++ b/packages/bank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -0,0 +1,425 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ HttpStatusCode,
+ PaytoUri,
+ PaytoUriIBAN,
+ PaytoUriTalerBank,
+ TalerErrorCode,
+ TranslatedString,
+ WithdrawUriResult,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, Fragment, VNode, h } from "preact";
+import { mutate } from "swr";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useBankState } from "../hooks/bank-state.js";
+import { usePreferences } from "../hooks/preferences.js";
+import { useSessionState } from "../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { LoginForm } from "./LoginForm.js";
+import { RenderAmount } from "./PaytoWireTransferForm.js";
+
+interface Props {
+ onAborted: () => void;
+ withdrawUri: WithdrawUriResult;
+ routeHere: RouteDefinition<{ wopid: string }>;
+ details: {
+ account: PaytoUri;
+ reserve: string;
+ username: string;
+ amount: AmountJson;
+ };
+ onAuthorizationRequired: () => void;
+}
+/**
+ * Additional authentication required to complete the operation.
+ * Not providing a back button, only abort.
+ */
+export function WithdrawalConfirmationQuestion({
+ onAborted,
+ details,
+ onAuthorizationRequired,
+ routeHere,
+ withdrawUri,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const [, updateBankState] = useBankState();
+
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const {
+ config,
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ async function doTransfer() {
+ await handleError(async () => {
+ if (!creds) return;
+ const resp = await api.confirmWithdrawalById(
+ creds,
+ withdrawUri.withdrawalOperationId,
+ );
+ if (resp.type === "ok") {
+ mutate(() => true); // clean any info that we have
+ if (!settings.showWithdrawalSuccess) {
+ notifyInfo(i18n.str`Wire transfer completed!`);
+ }
+ } else {
+ switch (resp.case) {
+ case TalerErrorCode.BANK_CONFIRM_ABORT_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal has been aborted previously and can't be confirmed`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`The withdrawal operation can't be confirmed before a wallet accepted the transaction.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Your balance is not enough for the operation.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "confirm-withdrawal",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({
+ wopid: withdrawUri.withdrawalOperationId,
+ }),
+ sent: AbsoluteTime.never(),
+ request: withdrawUri.withdrawalOperationId,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ async function doCancel() {
+ await handleError(async () => {
+ if (!creds) return;
+ const resp = await api.abortWithdrawalById(
+ creds,
+ withdrawUri.withdrawalOperationId,
+ );
+ if (resp.type === "ok") {
+ onAborted();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Conflict:
+ return notify({
+ type: "error",
+ title: i18n.str`The reserve operation has been confirmed previously and can't be aborted`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation id is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The operation was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ default: {
+ assertUnreachable(resp);
+ }
+ }
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+
+ <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-3 text-sm leading-6">
+ <ShouldBeSameUser username={details.username}>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <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 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 => {
+ if (!details.account.isKnown) {
+ 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">
+ <i18n.Translate>
+ Payment provider's account
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.targetPath}
+ </dd>
+ </div>
+ );
+ }
+ switch (details.account.targetType) {
+ case "iban": {
+ const name =
+ details.account.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">
+ <i18n.Translate>
+ Payment provider's account number
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.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">
+ <i18n.Translate>
+ Payment provider's name
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {name}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "x-taler-bank": {
+ const name =
+ details.account.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">
+ <i18n.Translate>
+ Payment provider's account bank
+ hostname
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.host}
+ </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">
+ <i18n.Translate>
+ Payment provider's account id
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.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">
+ <i18n.Translate>
+ Payment provider's name
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {name}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ case "bitcoin": {
+ const name =
+ details.account.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">
+ <i18n.Translate>
+ Payment provider's account address
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {details.account.address}
+ </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">
+ <i18n.Translate>
+ Payment provider's name
+ </i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ {name}
+ </dd>
+ </div>
+ )}
+ </Fragment>
+ );
+ }
+ default: {
+ assertUnreachable(details.account);
+ }
+ }
+ })()}
+ <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">
+ <i18n.Translate>Amount</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">
+ <RenderAmount
+ value={details.amount}
+ spec={config.currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </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"
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={doCancel}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ <button
+ type="submit"
+ name="transfer"
+ 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();
+ doTransfer();
+ }}
+ >
+ <i18n.Translate>Transfer</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </ShouldBeSameUser>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
+
+export function ShouldBeSameUser({
+ username,
+ children,
+}: {
+ username: string;
+ children: ComponentChildren;
+}): VNode {
+ const { state: credentials } = useSessionState();
+ const { i18n } = useTranslationContext();
+ if (credentials.status === "loggedOut") {
+ return (
+ <Fragment>
+ <Attention type="info" title={i18n.str`Authentication required`} />
+ <LoginForm currentUser={username} fixedUser />
+ </Fragment>
+ );
+ }
+ if (credentials.username !== username) {
+ return (
+ <Fragment>
+ <Attention
+ type="warning"
+ title={i18n.str`This operation was created with other username`}
+ />
+ <LoginForm currentUser={username} fixedUser />
+ </Fragment>
+ );
+ }
+ return <Fragment>{children}</Fragment>;
+}
diff --git a/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx
new file mode 100644
index 000000000..c0c55f14b
--- /dev/null
+++ b/packages/bank-ui/src/pages/WithdrawalOperationPage.tsx
@@ -0,0 +1,73 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import { parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util";
+import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useBankState } from "../hooks/bank-state.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
+
+export function WithdrawalOperationPage({
+ operationId,
+ onAuthorizationRequired,
+ onOperationAborted,
+ routeClose,
+ routeWithdrawalDetails,
+}: {
+ onAuthorizationRequired: () => void;
+ operationId: string;
+ purpose: "after-creation" | "after-confirmation";
+ onOperationAborted: () => void;
+ routeClose: RouteDefinition;
+ routeWithdrawalDetails: RouteDefinition<{ wopid: string }>;
+}): VNode {
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const uri = stringifyWithdrawUri({
+ bankIntegrationApiBaseUrl: api.getIntegrationAPI().href,
+ withdrawalOperationId: operationId,
+ });
+ const parsedUri = parseWithdrawUri(uri);
+ const { i18n } = useTranslationContext();
+ const [, updateBankState] = useBankState();
+
+ if (!parsedUri) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The Withdrawal URI is not valid`}
+ >
+ {uri}
+ </Attention>
+ );
+ }
+
+ return (
+ <WithdrawalQRCode
+ withdrawUri={parsedUri}
+ routeWithdrawalDetails={routeWithdrawalDetails}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onOperationAborted={() => {
+ updateBankState("currentWithdrawalOperationId", undefined);
+ onOperationAborted();
+ }}
+ routeClose={routeClose}
+ />
+ );
+}
diff --git a/packages/bank-ui/src/pages/WithdrawalQRCode.tsx b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx
new file mode 100644
index 000000000..b61f0cc8f
--- /dev/null
+++ b/packages/bank-ui/src/pages/WithdrawalQRCode.tsx
@@ -0,0 +1,310 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ WithdrawUriResult,
+ assertUnreachable,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ notifyInfo,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useWithdrawalDetails } from "../hooks/account.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { QrCodeSection } from "./QrCodeSection.js";
+import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js";
+
+interface Props {
+ withdrawUri: WithdrawUriResult;
+ onOperationAborted: () => void;
+ routeClose: RouteDefinition;
+ routeWithdrawalDetails: RouteDefinition<{ wopid: string }>;
+ onAuthorizationRequired: () => void;
+}
+/**
+ * Offer the QR code (and a clickable taler://-link) to
+ * permit the passing of exchange and reserve details to
+ * the bank. Poll the backend until such operation is done.
+ */
+export function WithdrawalQRCode({
+ withdrawUri,
+ onOperationAborted,
+ routeClose,
+ routeWithdrawalDetails,
+ onAuthorizationRequired,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId);
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.BadRequest:
+ case HttpStatusCode.NotFound:
+ return <OperationNotFound routeClose={routeClose} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const { body: data } = result;
+
+ if (data.status === "aborted") {
+ 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-yellow-100">
+ <svg
+ class="h-5 w-5 text-yellow-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+ </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>Operation aborted</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ The wire transfer to the payment provider's account was
+ aborted from somewhere else, your balance was not affected.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="continue"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Continue</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+ }
+
+ if (data.status === "confirmed") {
+ 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">
+ <a
+ href={routeClose.url({})}
+ name="done"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Done</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+ }
+
+ if (data.status === "pending") {
+ return (
+ <QrCodeSection
+ withdrawUri={withdrawUri}
+ onAborted={() => {
+ notifyInfo(i18n.str`Operation canceled`);
+ onOperationAborted();
+ }}
+ />
+ );
+ }
+
+ const account = !data.selected_exchange_account
+ ? undefined
+ : parsePaytoUri(data.selected_exchange_account);
+
+ if (!data.selected_reserve_pub && account) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`}
+ >
+ <i18n.Translate>
+ The account is selected but no withdrawal identification found.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+
+ if (!account && data.selected_reserve_pub) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`}
+ >
+ <i18n.Translate>
+ There is a withdrawal identification but no account has been selected
+ or the selected account is invalid.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+
+ if (!account || !data.selected_reserve_pub) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`The operation is marked as 'selected' but some step in the withdrawal failed`}
+ >
+ <i18n.Translate>
+ No withdrawal ID found and no account has been selected or the
+ selected account is invalid.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+
+ return (
+ <WithdrawalConfirmationQuestion
+ withdrawUri={withdrawUri}
+ routeHere={routeWithdrawalDetails}
+ details={{
+ username: data.username,
+ account,
+ reserve: data.selected_reserve_pub,
+ amount: Amounts.parseOrThrow(data.amount),
+ }}
+ onAuthorizationRequired={onAuthorizationRequired}
+ onAborted={() => {
+ notifyInfo(i18n.str`Operation canceled`);
+ onOperationAborted();
+ }}
+ />
+ );
+}
+
+export function OperationNotFound({
+ routeClose,
+}: {
+ routeClose: RouteDefinition | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ 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-red-100 ">
+ <svg
+ class="h-6 w-6 text-red-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="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
+ />
+ </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>Operation not found</i18n.Translate>
+ </h3>
+ <div class="mt-2">
+ <p class="text-sm text-gray-500">
+ <i18n.Translate>
+ This operation is not known by the server. The operation id is
+ wrong or the server deleted the operation information before
+ reaching here.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ {routeClose && (
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="continue to dashboard"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Cotinue to dashboard</i18n.Translate>
+ </a>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx
new file mode 100644
index 000000000..fd6379895
--- /dev/null
+++ b/packages/bank-ui/src/pages/account/CashoutListForAccount.tsx
@@ -0,0 +1,89 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { useSessionState } from "../../hooks/session.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+import { CreateCashout } from "../regional/CreateCashout.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+interface Props {
+ account: string;
+ routeClose: RouteDefinition;
+ onAuthorizationRequired: () => void;
+ onCashout: () => void;
+ routeCashoutDetails: RouteDefinition<{ cid: string }>;
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeCreateCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+}
+
+export function CashoutListForAccount({
+ account,
+ onAuthorizationRequired,
+ onCashout,
+ routeCreateCashout,
+ routeCashoutDetails,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeConversionConfig,
+ routeMyAccountPassword,
+ routeClose,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const { state: credentials } = useSessionState();
+
+ const accountIsTheCurrentUser =
+ credentials.status === "loggedIn"
+ ? credentials.username === account
+ : false;
+
+ return (
+ <Fragment>
+ {accountIsTheCurrentUser ? (
+ <ProfileNavigation
+ current="cashouts"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
+ ) : (
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Cashout for account {account}</i18n.Translate>
+ </h1>
+ )}
+
+ <CreateCashout
+ focus
+ routeHere={routeCreateCashout}
+ routeClose={routeClose}
+ onCashout={onCashout}
+ onAuthorizationRequired={onAuthorizationRequired}
+ account={account}
+ />
+
+ <Cashouts account={account} routeCashoutDetails={routeCashoutDetails} />
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx
new file mode 100644
index 000000000..6db0e5512
--- /dev/null
+++ b/packages/bank-ui/src/pages/account/ShowAccountDetails.tsx
@@ -0,0 +1,500 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ parsePaytoUri
+} from "@gnu-taler/taler-util";
+import {
+ CopyButton,
+ Loading,
+ LocalNotificationBanner,
+ RouteDefinition,
+ notifyInfo,
+ useBankCoreApiContext,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { useSessionState } from "../../hooks/session.js";
+import { LoginForm } from "../LoginForm.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+import { AccountForm } from "../admin/AccountForm.js";
+
+export function ShowAccountDetails({
+ account,
+ routeClose,
+ onUpdateSuccess,
+ onAuthorizationRequired,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeHere,
+ routeMyAccountPassword,
+ routeConversionConfig,
+}: {
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition<{ account: string }>;
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+ onUpdateSuccess: () => void;
+ onAuthorizationRequired: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const {
+ lib: { bank },
+ } = useBankCoreApiContext();
+ const accountIsTheCurrentUser =
+ credentials.status === "loggedIn"
+ ? credentials.username === account
+ : false;
+
+ const [submitAccount, setSubmitAccount] = useState<
+ TalerCorebankApi.AccountReconfiguration | undefined
+ >();
+ const [notification, notify, handleError] = useLocalNotification();
+ const [, updateBankState] = useBankState();
+
+ const result = useAccountDetails(account);
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={account} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ async function doUpdate() {
+ if (!submitAccount || !creds) return;
+ await handleError(async () => {
+ const resp = await bank.updateAccount(
+ {
+ token: creds.token,
+ username: account,
+ },
+ submitAccount,
+ );
+
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Account updated`);
+ onUpdateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`The rights to change the account are not sufficient`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The username was not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_LEGAL_NAME:
+ return notify({
+ type: "error",
+ title: i18n.str`You can't change the legal name, please contact the your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return notify({
+ type: "error",
+ title: i18n.str`You can't change the debt limit, please contact the your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_CASHOUT:
+ return notify({
+ type: "error",
+ title: i18n.str`You can't change the cashout address, please contact the your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return notify({
+ type: "error",
+ title: i18n.str`No information for the selected authentication channel.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "update-account",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({ account }),
+ sent: AbsoluteTime.never(),
+ request: submitAccount,
+ });
+ return onAuthorizationRequired();
+ }
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: {
+ return notify({
+ type: "error",
+ title: i18n.str`Authentication channel is not supported.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: {
+ return notify({
+ type: "error",
+ title: i18n.str`Only the administrator can change the minimum cashout limit.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ const url = bank.getRevenueAPI(account);
+ url.username = account;
+ const baseURL = url.href;
+
+ const ac = parsePaytoUri(result.body.payto_uri);
+ const payto = !ac?.isKnown ? undefined : ac;
+ let accountLetter: string | undefined = undefined;
+ if (payto) {
+ switch (payto.targetType) {
+ case "iban": {
+ accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\niban=${payto.iban}\nreceiver-name=${result.body.name}\n`;
+ break;
+ }
+ case "x-taler-bank": {
+ accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naccount=${payto.account}\nhost=${payto.host}\nreceiver-name=${result.body.name}\n`;
+ break;
+ }
+ case "bitcoin": {
+ accountLetter = `account-info-url=${url.href}\naccount-type=${payto.targetType}\naddress=${payto.address}\nreceiver-name=${result.body.name}\n`;
+ break;
+ }
+ }
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} showDebug={true} />
+ {accountIsTheCurrentUser ? (
+ <ProfileNavigation
+ current="details"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeConversionConfig={routeConversionConfig}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ />
+ ) : (
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Account "{account}"</i18n.Translate>
+ </h1>
+ )}
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 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">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Change details</i18n.Translate>
+ </span>
+ </span>
+ </div>
+ </h2>
+ </div>
+
+ <AccountForm
+ focus={true}
+ username={account}
+ template={result.body}
+ purpose="update"
+ onChange={(a) => setSubmitAccount(a)}
+ >
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="update"
+ 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={doUpdate}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </div>
+ </AccountForm>
+ </div>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 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">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-semibold leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Merchant integration</i18n.Translate>
+ </span>
+ </span>
+ </div>
+ </h2>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Use this information to link your Taler Merchant Backoffice
+ account with the current bank account. You can start by copying
+ the values, then go to your merchant backoffice service provider,
+ login into your account and look for the "import" button in the
+ "bank account" section.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ {payto !== undefined && (
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
+ <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="account-type"
+ >
+ {i18n.str`Account type`}
+ </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="account-type"
+ id="account-type"
+ disabled={true}
+ value={account}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Method to use for wire transfer.
+ </i18n.Translate>
+ </p>
+ </div>
+ {((payto) => {
+ switch (payto.targetType) {
+ case "iban": {
+ return (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`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="iban"
+ id="iban"
+ disabled={true}
+ value={payto.iban}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ International Bank Account Number.
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+ }
+ case "x-taler-bank": {
+ return (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`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="iban"
+ id="iban"
+ disabled={true}
+ value={payto.account}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ International Bank Account Number.
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+ }
+ case "bitcoin": {
+ return (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`Address`}
+ </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="iban"
+ id="iban"
+ disabled={true}
+ value={"DE1231231231"}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ International Bank Account Number.
+ </i18n.Translate>
+ </p>
+ </div>
+ );
+ }
+ }
+ })(payto)}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`Owner's name`}
+ </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="iban"
+ id="iban"
+ disabled={true}
+ value={result.body.name}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Legal name of the person holding the account.
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="iban"
+ >
+ {i18n.str`Account info URL`}
+ </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="iban"
+ id="iban"
+ disabled={true}
+ value={baseURL}
+ autocomplete="off"
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ From where the merchant can download information about
+ incoming wire transfers to this account.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <CopyButton
+ getContent={() => accountLetter ?? ""}
+ class="flex text-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>Copy</i18n.Translate>
+ </CopyButton>
+ </div>
+ </div>
+ )}
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx
new file mode 100644
index 000000000..2724fba11
--- /dev/null
+++ b/packages/bank-ui/src/pages/account/UpdateAccountPassword.tsx
@@ -0,0 +1,319 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../../utils.js";
+import { doAutoFocus } from "../PaytoWireTransferForm.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+
+export function UpdateAccountPassword({
+ account: accountName,
+ routeClose,
+ onUpdateSuccess,
+ onAuthorizationRequired,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeMyAccountPassword,
+ routeConversionConfig,
+ focus,
+ routeHere,
+}: {
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition<{ account: string }>;
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+ focus?: boolean;
+ onAuthorizationRequired: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [current, setCurrent] = useState<string | undefined>();
+ const [password, setPassword] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+ const [, updateBankState] = useBankState();
+
+ const accountIsTheCurrentUser =
+ credentials.status === "loggedIn"
+ ? credentials.username === accountName
+ : false;
+
+ const errors = undefinedIfEmpty({
+ current: !accountIsTheCurrentUser
+ ? undefined
+ : !current
+ ? i18n.str`Required`
+ : undefined,
+ password: !password ? i18n.str`Required` : undefined,
+ repeat: !repeat
+ ? i18n.str`Required`
+ : password !== repeat
+ ? i18n.str`Repeated password doesn't match`
+ : undefined,
+ });
+ const [notification, notify, handleError] = useLocalNotification();
+
+ async function doChangePassword() {
+ if (!!errors || !password || !token) return;
+ await handleError(async () => {
+ const request = {
+ old_password: current,
+ new_password: password,
+ };
+ const resp = await api.updatePassword(
+ { username: accountName, token },
+ request,
+ );
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Password changed`);
+ onUpdateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`Not authorized to change the password, maybe the session is invalid.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_MISSING_OLD_PASSWORD:
+ return notify({
+ type: "error",
+ title: i18n.str`You need to provide the old password. If you don't have it contact your account administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_PATCH_BAD_OLD_PASSWORD:
+ return notify({
+ type: "error",
+ title: i18n.str`Your current password doesn't match, can't change to a new password.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "update-password",
+ id: String(resp.body.challenge_id),
+ location: routeHere.url({ account: accountName }),
+ sent: AbsoluteTime.never(),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ return (
+ <Fragment>
+ <LocalNotificationBanner notification={notification} />
+ {accountIsTheCurrentUser ? (
+ <ProfileNavigation
+ current="credentials"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
+ ) : (
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Account "{accountName}"</i18n.Translate>
+ </h1>
+ )}
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 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</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">
+ {accountIsTheCurrentUser ? (
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="password"
+ >
+ {i18n.str`Current password`}
+ <b style={{ color: "red" }}> *</b>
+ </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="current"
+ id="current-password"
+ data-error={!!errors?.current && current !== undefined}
+ value={current ?? ""}
+ onChange={(e) => {
+ setCurrent(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.current}
+ isDirty={current !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Your current password, for security
+ </i18n.Translate>
+ </p>
+ </div>
+ ) : undefined}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="password"
+ >
+ {i18n.str`New password`}
+ <b style={{ color: "red" }}> *</b>
+ </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);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </div>
+ </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`}
+ <b style={{ color: "red" }}> *</b>
+ </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">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="change"
+ 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>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/AccountForm.tsx b/packages/bank-ui/src/pages/admin/AccountForm.tsx
new file mode 100644
index 000000000..ba5da609f
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AccountForm.tsx
@@ -0,0 +1,859 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AmountString,
+ Amounts,
+ PaytoString,
+ TalerCorebankApi,
+ assertUnreachable,
+ buildPayto,
+ parsePaytoUri,
+ stringifyPaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ CopyButton,
+ ShowInputErrorLabel,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { ComponentChildren, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import {
+ ErrorMessageMappingFor,
+ TanChannel,
+ undefinedIfEmpty,
+ validateIBAN,
+ validateTalerBank,
+} from "../../utils.js";
+import {
+ InputAmount,
+ TextField,
+ doAutoFocus,
+} from "../PaytoWireTransferForm.js";
+import { getRandomPassword } from "../rnd.js";
+
+const EMAIL_REGEX =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
+
+export type AccountFormData = {
+ debit_threshold?: string;
+ min_cashout?: string;
+ isExchange?: boolean;
+ isPublic?: boolean;
+ name?: string;
+ username?: string;
+ payto_uri?: string;
+ cashout_payto_uri?: string;
+ email?: string;
+ phone?: string;
+ tan_channel?: TanChannel | "remove";
+};
+
+type ChangeByPurposeType = {
+ create: (a: TalerCorebankApi.RegisterAccountRequest | undefined) => void;
+ update: (a: TalerCorebankApi.AccountReconfiguration | undefined) => void;
+ show: undefined;
+};
+/**
+ * FIXME:
+ * is_public is missing on PATCH
+ * account email/password should require 2FA
+ *
+ *
+ * @param param0
+ * @returns
+ */
+export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({
+ template,
+ username,
+ purpose,
+ onChange,
+ focus,
+ children,
+}: {
+ focus?: boolean;
+ children: ComponentChildren;
+ username?: string;
+ template: TalerCorebankApi.AccountData | undefined;
+ onChange: ChangeByPurposeType[PurposeType];
+ purpose: PurposeType;
+}): VNode {
+ const { config, url } = useBankCoreApiContext();
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const [form, setForm] = useState<AccountFormData>({});
+
+ const [errors, setErrors] = useState<
+ ErrorMessageMappingFor<typeof defaultValue> | undefined
+ >(undefined);
+
+ const paytoType =
+ config.wire_type === "X_TALER_BANK"
+ ? ("x-taler-bank" as const)
+ : ("iban" as const);
+ const cashoutPaytoType: typeof paytoType = "iban" as const;
+
+ const defaultValue: AccountFormData = {
+ debit_threshold: Amounts.stringifyValue(
+ template?.debit_threshold ?? config.default_debit_threshold,
+ ),
+ min_cashout: Amounts.stringifyValue(
+ template?.min_cashout ?? `${config.currency}:0`,
+ ),
+ isExchange: template?.is_taler_exchange,
+ isPublic: template?.is_public,
+ name: template?.name ?? "",
+ cashout_payto_uri:
+ getAccountId(cashoutPaytoType, template?.cashout_payto_uri) ??
+ ("" as PaytoString),
+ payto_uri:
+ getAccountId(paytoType, template?.payto_uri) ?? ("" as PaytoString),
+ email: template?.contact_data?.email ?? "",
+ phone: template?.contact_data?.phone ?? "",
+ username: username ?? "",
+ tan_channel: template?.tan_channel,
+ };
+
+ const userIsAdmin =
+ credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator;
+
+ const editableUsername = purpose === "create";
+ const editableName =
+ purpose === "create" ||
+ (purpose === "update" && (config.allow_edit_name || userIsAdmin));
+
+ const isCashoutEnabled = config.allow_conversion;
+ const editableCashout =
+ purpose === "create" ||
+ (purpose === "update" &&
+ (config.allow_edit_cashout_payto_uri || userIsAdmin));
+ const editableThreshold =
+ userIsAdmin && (purpose === "create" || purpose === "update");
+ const editableMinCashout =
+ userIsAdmin && (purpose === "create" || purpose === "update");
+ const editableAccount = purpose === "create" && userIsAdmin;
+
+ function updateForm(newForm: typeof defaultValue): void {
+ const trimmedMinCashoutStr = newForm.min_cashout?.trim();
+ const parsedMinCashout = Amounts.parse(
+ `${config.currency}:${trimmedMinCashoutStr}`,
+ );
+ const trimmedDebitThresholdStr = newForm.debit_threshold?.trim();
+ const parsedDebitThreshold = Amounts.parse(
+ `${config.currency}:${trimmedDebitThresholdStr}`,
+ );
+
+ const errors = undefinedIfEmpty<
+ ErrorMessageMappingFor<typeof defaultValue>
+ >({
+ cashout_payto_uri: !newForm.cashout_payto_uri
+ ? undefined
+ : !editableCashout
+ ? undefined
+ : !newForm.cashout_payto_uri
+ ? undefined
+ : cashoutPaytoType === "iban"
+ ? validateIBAN(newForm.cashout_payto_uri, i18n)
+ : cashoutPaytoType === "x-taler-bank"
+ ? validateTalerBank(newForm.cashout_payto_uri, i18n)
+ : undefined,
+
+ payto_uri: !newForm.payto_uri
+ ? undefined
+ : !editableAccount
+ ? undefined
+ : !newForm.payto_uri
+ ? undefined
+ : paytoType === "iban"
+ ? validateIBAN(newForm.payto_uri, i18n)
+ : paytoType === "x-taler-bank"
+ ? validateTalerBank(newForm.payto_uri, i18n)
+ : undefined,
+
+ email: !newForm.email
+ ? undefined
+ : !EMAIL_REGEX.test(newForm.email)
+ ? i18n.str`Doesn't have the pattern of an email`
+ : undefined,
+ phone: !newForm.phone
+ ? undefined
+ : !newForm.phone.startsWith("+") // FIXME: better phone number check
+ ? i18n.str`Should start with +`
+ : !REGEX_JUST_NUMBERS_REGEX.test(newForm.phone)
+ ? i18n.str`Phone number can't have other than numbers`
+ : undefined,
+ debit_threshold: !editableThreshold
+ ? undefined
+ : !trimmedDebitThresholdStr
+ ? undefined
+ : !parsedDebitThreshold
+ ? i18n.str`Not valid`
+ : undefined,
+ min_cashout: !editableMinCashout
+ ? undefined
+ : !trimmedMinCashoutStr
+ ? undefined
+ : !parsedMinCashout
+ ? i18n.str`Not valid`
+ : undefined,
+ name: !editableName
+ ? undefined // disabled
+ : purpose === "update" && newForm.name === undefined ? undefined // the field hasn't been changed
+ : !newForm.name
+ ? i18n.str`Required`
+ : undefined,
+ username: !editableUsername
+ ? undefined
+ : !newForm.username
+ ? i18n.str`Required`
+ : undefined,
+ });
+ setErrors(errors);
+
+ setForm(newForm);
+ if (!onChange) return;
+
+ if (errors) {
+ onChange(undefined);
+ } else {
+ let cashout;
+ if (newForm.cashout_payto_uri)
+ switch (cashoutPaytoType) {
+ case "x-taler-bank": {
+ cashout = buildPayto(
+ "x-taler-bank",
+ url.host,
+ newForm.cashout_payto_uri,
+ );
+ break;
+ }
+ case "iban": {
+ cashout = buildPayto("iban", newForm.cashout_payto_uri, undefined);
+ break;
+ }
+ default:
+ assertUnreachable(cashoutPaytoType);
+ }
+ const cashoutURI = !cashout ? undefined : stringifyPaytoUri(cashout);
+ let internal;
+ if (newForm.payto_uri)
+ switch (paytoType) {
+ case "x-taler-bank": {
+ internal = buildPayto("x-taler-bank", url.host, newForm.payto_uri);
+ break;
+ }
+ case "iban": {
+ internal = buildPayto("iban", newForm.payto_uri, undefined);
+ break;
+ }
+ default:
+ assertUnreachable(paytoType);
+ }
+ const internalURI = !internal ? undefined : stringifyPaytoUri(internal);
+
+ const threshold = !parsedDebitThreshold
+ ? undefined
+ : Amounts.stringify(parsedDebitThreshold);
+ const minCashout = !parsedMinCashout
+ ? undefined
+ : Amounts.stringify(parsedMinCashout);
+
+ switch (purpose) {
+ case "create": {
+ // typescript doesn't correctly narrow a generic type
+ const callback = onChange as ChangeByPurposeType["create"];
+ const result: TalerCorebankApi.RegisterAccountRequest = {
+ name: newForm.name!,
+ password: getRandomPassword(),
+ username: newForm.username!,
+ contact_data: undefinedIfEmpty({
+ email: !newForm.email ? undefined : newForm.email,
+ phone: !newForm.phone ? undefined : newForm.phone,
+ }),
+ debit_threshold: threshold ?? config.default_debit_threshold,
+ min_cashout: minCashout,
+ cashout_payto_uri: cashoutURI,
+ payto_uri: internalURI,
+ is_public: newForm.isPublic,
+ is_taler_exchange: newForm.isExchange,
+ tan_channel:
+ newForm.tan_channel === "remove"
+ ? undefined
+ : newForm.tan_channel,
+ };
+ callback(result);
+ return;
+ }
+ case "update": {
+ // typescript doesn't correctly narrow a generic type
+ const callback = onChange as ChangeByPurposeType["update"];
+
+ const result: TalerCorebankApi.AccountReconfiguration = {
+ cashout_payto_uri: cashoutURI,
+ contact_data: undefinedIfEmpty({
+ email: !newForm.email ? undefined : newForm.email,
+ phone: !newForm.phone ? undefined : newForm.phone,
+ }),
+ debit_threshold: threshold,
+ min_cashout: minCashout,
+ is_public: newForm.isPublic,
+ name: newForm.name,
+ tan_channel:
+ newForm.tan_channel === "remove" ? null : newForm.tan_channel,
+ };
+ callback(result);
+ return;
+ }
+ case "show": {
+ return;
+ }
+ default: {
+ assertUnreachable(purpose);
+ }
+ }
+ }
+ }
+ return (
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="username"
+ >
+ {i18n.str`Login username`}
+ {editableUsername && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus && purpose === "create" ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="username"
+ id="username"
+ data-error={!!errors?.username && form.username !== undefined}
+ disabled={!editableUsername}
+ value={form.username ?? defaultValue.username}
+ onChange={(e) => {
+ form.username = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={form.username !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Account id for authentication</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="name"
+ >
+ {i18n.str`Full name`}
+ {editableName && <b style={{ color: "red" }}> *</b>}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="name"
+ data-error={!!errors?.name && form.name !== undefined}
+ id="name"
+ disabled={!editableName}
+ value={form.name ?? defaultValue.name}
+ onChange={(e) => {
+ form.name = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ // placeholder=""
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={form.name !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Name of the account holder</i18n.Translate>
+ </p>
+ </div>
+
+ {purpose === "create" ? undefined : (
+ <TextField
+ id="internal-account"
+ label={i18n.str`Internal account`}
+ help={
+ purpose === "create"
+ ? i18n.str`If empty a random account id will be assigned`
+ : i18n.str`Share this id to receive bank transfers`
+ }
+ error={errors?.payto_uri}
+ onChange={(e) => {
+ form.payto_uri = e as PaytoString;
+ updateForm(structuredClone(form));
+ }}
+ rightIcons={
+ <CopyButton
+ class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+ getContent={() =>
+ form.payto_uri ?? defaultValue.payto_uri ?? ""
+ }
+ />
+ }
+ value={(form.payto_uri ?? defaultValue.payto_uri) as PaytoString}
+ disabled={!editableAccount}
+ />
+ )}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="email"
+ >
+ {i18n.str`Email`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="email"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="email"
+ id="email"
+ data-error={!!errors?.email && form.email !== undefined}
+ disabled={purpose === "show"}
+ value={form.email ?? defaultValue.email}
+ onChange={(e) => {
+ form.email = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.email}
+ isDirty={form.email !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ To be used when second factor authentication is enabled
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="phone"
+ >
+ {i18n.str`Phone`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="phone"
+ id="phone"
+ disabled={purpose === "show"}
+ value={form.phone ?? defaultValue.phone}
+ data-error={!!errors?.phone && form.phone !== undefined}
+ onChange={(e) => {
+ form.phone = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.phone}
+ isDirty={form.phone !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ To be used when second factor authentication is enabled
+ </i18n.Translate>
+ </p>
+ </div>
+
+ {isCashoutEnabled && (
+ <TextField
+ id="cashout-account"
+ label={i18n.str`Cashout account`}
+ help={i18n.str`External account number where the money is going to be sent when doing cashouts`}
+ error={errors?.cashout_payto_uri}
+ onChange={(e) => {
+ form.cashout_payto_uri = e as PaytoString;
+ updateForm(structuredClone(form));
+ }}
+ value={
+ (form.cashout_payto_uri ??
+ defaultValue.cashout_payto_uri) as PaytoString
+ }
+ disabled={!editableCashout}
+ />
+ )}
+
+ <div class="sm:col-span-5">
+ <label
+ for="debit"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Max debt`}</label>
+ <InputAmount
+ name="debit"
+ left
+ currency={config.currency}
+ value={form.debit_threshold ?? defaultValue.debit_threshold}
+ onChange={
+ !editableThreshold
+ ? undefined
+ : (e) => {
+ form.debit_threshold = e as AmountString;
+ updateForm(structuredClone(form));
+ }
+ }
+ />
+ <ShowInputErrorLabel
+ message={
+ errors?.debit_threshold
+ ? String(errors?.debit_threshold)
+ : undefined
+ }
+ isDirty={form.debit_threshold !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ How much the balance can go below zero.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ for="minCashout"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum cashout`}</label>
+ <InputAmount
+ name="minCashout"
+ left
+ currency={config.currency}
+ value={form.min_cashout ?? defaultValue.min_cashout}
+ onChange={
+ !editableMinCashout
+ ? undefined
+ : (e) => {
+ form.min_cashout = e as AmountString;
+ updateForm(structuredClone(form));
+ }
+ }
+ />
+ <ShowInputErrorLabel
+ message={
+ errors?.min_cashout ? String(errors?.min_cashout) : undefined
+ }
+ isDirty={form.min_cashout !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Custom minimum cashout amount for this account.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Is this account public?</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name="is public"
+ data-enabled={
+ form.isPublic ?? defaultValue.isPublic ? "true" : "false"
+ }
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isPublic = !(form.isPublic ?? defaultValue.isPublic);
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={
+ form.isPublic ?? defaultValue.isPublic ? "true" : "false"
+ }
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Public accounts have their balance publicly accessible
+ </i18n.Translate>
+ </p>
+ </div>
+
+ {purpose !== "create" || !userIsAdmin ? undefined : (
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>
+ Is this account a payment provider?
+ </i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name="is exchange"
+ data-enabled={
+ form.isExchange ?? defaultValue.isExchange
+ ? "true"
+ : "false"
+ }
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isExchange = !form.isExchange;
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={
+ form.isExchange ?? defaultValue.isExchange
+ ? "true"
+ : "false"
+ }
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ {children}
+ </form>
+ );
+}
+
+function getAccountId(
+ type: "iban" | "x-taler-bank",
+ s: PaytoString | undefined,
+): string | undefined {
+ if (s === undefined) return undefined;
+ const p = parsePaytoUri(s);
+ if (p === undefined) return undefined;
+ if (!p.isKnown) return "<unknown>";
+ if (type === "iban" && p.targetType === "iban") return p.iban;
+ if (type === "x-taler-bank" && p.targetType === "x-taler-bank")
+ return p.account;
+ return "<unsupported>";
+}
+
+{
+ /* <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="cashout"
+ >
+ {}
+ </label>
+ <div class="mt-2">
+ <input
+ type="text"
+ ref={focus && purpose === "update" ? doAutoFocus : undefined}
+ data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined}
+ class="block w-full disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="cashout"
+ id="cashout"
+ disabled={purpose === "show"}
+ value={form.cashout_payto_uri ?? defaultValue.cashout_payto_uri}
+ onChange={(e) => {
+ form.cashout_payto_uri = e.currentTarget.value as PaytoString;
+ if (!form.cashout_payto_uri) {
+ form.cashout_payto_uri = undefined
+ }
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.cashout_payto_uri}
+ isDirty={form.cashout_payto_uri !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500" >
+ <i18n.Translate></i18n.Translate>
+ </p>
+ </div> */
+}
+
+// function PaytoField({
+// name,
+// label,
+// help,
+// type,
+// value,
+// disabled,
+// onChange,
+// error,
+// }: {
+// error: TranslatedString | undefined;
+// name: string;
+// label: TranslatedString;
+// help: TranslatedString;
+// onChange: (s: string) => void;
+// type: "iban" | "x-taler-bank" | "bitcoin";
+// disabled?: boolean;
+// value: string | undefined;
+// }): VNode {
+// if (type === "iban") {
+// return (
+// <div class="sm:col-span-5">
+// <label
+// class="block text-sm font-medium leading-6 text-gray-900"
+// for={name}
+// >
+// {label}
+// </label>
+// <div class="mt-2">
+// <div class="flex justify-between">
+// <input
+// type="text"
+// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+// name={name}
+// id={name}
+// disabled={disabled}
+// value={value ?? ""}
+// onChange={(e) => {
+// onChange(e.currentTarget.value);
+// }}
+// />
+// <CopyButton
+// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+// getContent={() => value ?? ""}
+// />
+// </div>
+// <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+// </div>
+// <p class="mt-2 text-sm text-gray-500">{help}</p>
+// </div>
+// );
+// }
+// if (type === "x-taler-bank") {
+// return (
+// <div class="sm:col-span-5">
+// <label
+// class="block text-sm font-medium leading-6 text-gray-900"
+// for={name}
+// >
+// {label}
+// </label>
+// <div class="mt-2">
+// <div class="flex justify-between">
+// <input
+// type="text"
+// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+// name={name}
+// id={name}
+// disabled={disabled}
+// value={value ?? ""}
+// onChange={(e) => {
+// onChange(e.currentTarget.value);
+// }}
+// />
+// <CopyButton
+// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+// getContent={() => value ?? ""}
+// />
+// </div>
+// <ShowInputErrorLabel message={error} isDirty={value !== undefined} />
+// </div>
+// <p class="mt-2 text-sm text-gray-500">
+// {help}
+// </p>
+// </div>
+// );
+// }
+// if (type === "bitcoin") {
+// return (
+// <div class="sm:col-span-5">
+// <label
+// class="block text-sm font-medium leading-6 text-gray-900"
+// for={name}
+// >
+// {label}
+// </label>
+// <div class="mt-2">
+// <div class="flex justify-between">
+// <input
+// type="text"
+// class="mr-4 w-full block-inline disabled:bg-gray-100 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+// name={name}
+// id={name}
+// disabled={disabled}
+// value={value ?? ""}
+// />
+// <CopyButton
+// class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 "
+// getContent={() => value ?? ""}
+// />
+// <ShowInputErrorLabel
+// message={error}
+// isDirty={value !== undefined}
+// />
+// </div>
+// </div>
+// <p class="mt-2 text-sm text-gray-500">
+// {/* <i18n.Translate>bitcoin address</i18n.Translate> */}
+// {help}
+// </p>
+// </div>
+// );
+// }
+// assertUnreachable(type);
+// }
diff --git a/packages/bank-ui/src/pages/admin/AccountList.tsx b/packages/bank-ui/src/pages/admin/AccountList.tsx
new file mode 100644
index 000000000..6402c2bcd
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AccountList.tsx
@@ -0,0 +1,234 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useBusinessAccounts } from "../../hooks/regional.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+
+interface Props {
+ routeCreate: RouteDefinition;
+
+ routeShowAccount: RouteDefinition<{ account: string }>;
+ routeRemoveAccount: RouteDefinition<{ account: string }>;
+ routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
+}
+
+export function AccountList({
+ routeCreate,
+ routeRemoveAccount,
+ routeShowAccount,
+ routeUpdatePasswordAccount,
+}: Props): VNode {
+ const result = useBusinessAccounts();
+ const { i18n } = useTranslationContext();
+ const { config } = useBankCoreApiContext();
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return <Fragment />;
+ default:
+ assertUnreachable(result.case);
+ }
+ }
+
+ const onGoStart = result.isFirstPage ? undefined : result.loadFirst;
+ const onGoNext = result.isLastPage ? undefined : result.loadNext;
+
+ const accounts = result.body;
+ return (
+ <Fragment>
+ <div class="px-4 sm:px-6 lg:px-8 mt-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>
+ </div>
+ <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
+ <a
+ href={routeCreate.url({})}
+ name="create account"
+ type="button"
+ class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Create account</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ <div class="mt-4 flow-root">
+ <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
+ <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
+ {!accounts.length ? (
+ <div>{/* FIXME: ADD empty list */}</div>
+ ) : (
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
+ >{i18n.str`Username`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Name`}</th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >{i18n.str`Balance`}</th>
+ <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
+ <span class="sr-only">{i18n.str`Actions`}</span>
+ </th>
+ </tr>
+ </thead>
+ <tbody class="divide-y divide-gray-200">
+ {accounts.map((item, idx) => {
+ const balance = !item.balance
+ ? undefined
+ : Amounts.parse(item.balance.amount);
+ const noBalance = Amounts.isZero(item.balance.amount);
+ const balanceIsDebit =
+ item.balance &&
+ item.balance.credit_debit_indicator == "debit";
+
+ return (
+ <tr key={idx}>
+ <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
+ <a
+ name={`show account ${item.username}`}
+ href={routeShowAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ {item.username}
+ </a>
+ </td>
+ <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
+ {item.name}
+ </td>
+ <td
+ data-negative={
+ noBalance
+ ? undefined
+ : balanceIsDebit
+ ? "true"
+ : "false"
+ }
+ class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600 "
+ >
+ {!balance ? (
+ i18n.str`Unknown`
+ ) : (
+ <span class="amount">
+ <RenderAmount
+ value={balance}
+ negative={balanceIsDebit}
+ spec={config.currency_specification}
+ />
+ </span>
+ )}
+ </td>
+ <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
+ <a
+ name={`update password ${item.username}`}
+ href={routeUpdatePasswordAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Change password</i18n.Translate>
+ </a>
+ <br />
+ {/* {config.allow_conversion ?
+ <Fragment>
+
+ <a
+ name={`show cashout ${item.username}`}
+ href={routeShowCashoutsAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Cashouts</i18n.Translate>
+ </a>
+ <br />
+ </Fragment>
+ : undefined} */}
+ {noBalance ? (
+ <a
+ name={`remove account ${item.username}`}
+ href={routeRemoveAccount.url({
+ account: item.username,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Remove</i18n.Translate>
+ </a>
+ ) : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ )}
+ </div>
+ <nav
+ class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg"
+ aria-label="Pagination"
+ >
+ <div class="flex flex-1 justify-between sm:justify-end">
+ <button
+ name="first page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onGoStart}
+ onClick={onGoStart}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ name="next page"
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onGoNext}
+ onClick={onGoNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/AdminHome.tsx b/packages/bank-ui/src/pages/admin/AdminHome.tsx
new file mode 100644
index 000000000..34c121235
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/AdminHome.tsx
@@ -0,0 +1,622 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ AmountString,
+ Amounts,
+ CurrencySpecification,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ RouteDefinition,
+ useBankCoreApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import {
+ format,
+ sub
+} from "date-fns";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { Transactions } from "../../components/Transactions/index.js";
+import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+import { WireTransfer } from "../WireTransfer.js";
+import { AccountList } from "./AccountList.js";
+
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+interface Props {
+ routeCreate: RouteDefinition;
+ routeDownloadStats: RouteDefinition;
+ routeCreateWireTransfer: RouteDefinition<{
+ account?: string;
+ subject?: string;
+ amount?: string;
+ }>;
+
+ routeShowAccount: RouteDefinition<{ account: string }>;
+ routeRemoveAccount: RouteDefinition<{ account: string }>;
+ routeUpdatePasswordAccount: RouteDefinition<{ account: string }>;
+ routeShowCashoutsAccount: RouteDefinition<{ account: string }>;
+ onAuthorizationRequired: () => void;
+}
+export function AdminHome({
+ routeCreate,
+ routeRemoveAccount,
+ routeShowAccount,
+ routeUpdatePasswordAccount,
+ routeDownloadStats,
+ routeCreateWireTransfer,
+ onAuthorizationRequired,
+}: Props): VNode {
+ return (
+ <Fragment>
+ <Metrics routeDownloadStats={routeDownloadStats} />
+ <WireTransfer
+ routeHere={routeCreateWireTransfer}
+ onAuthorizationRequired={onAuthorizationRequired}
+ />
+ <Transactions
+ account="admin"
+ routeCreateWireTransfer={routeCreateWireTransfer}
+ />
+ <AccountList
+ routeCreate={routeCreate}
+ routeRemoveAccount={routeRemoveAccount}
+ routeShowAccount={routeShowAccount}
+ routeUpdatePasswordAccount={routeUpdatePasswordAccount}
+ />
+ </Fragment>
+ );
+}
+
+function getDateForTimeframe(
+ date: AbsoluteTime,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+ locale: Locale,
+): string {
+ if (date.t_ms === "never") return "--";
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour:
+ return `${format(date.t_ms, "HH", { locale })}hs`;
+ case TalerCorebankApi.MonitorTimeframeParam.day:
+ return format(date.t_ms, "EEEE", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.month:
+ return format(date.t_ms, "MMMM", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.year:
+ return format(date.t_ms, "yyyy", { locale });
+ case TalerCorebankApi.MonitorTimeframeParam.decade:
+ return format(date.t_ms, "yyyy", { locale });
+ }
+ assertUnreachable(timeframe);
+}
+
+export function getTimeframesForDate(
+ time: Date,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+): { current: AbsoluteTime; previous: AbsoluteTime } {
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.day:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { days: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { days: 4 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.month:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { months: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { months: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.year:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 1 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 2 }).getTime(),
+ ),
+ };
+ case TalerCorebankApi.MonitorTimeframeParam.decade:
+ return {
+ current: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 10 }).getTime(),
+ ),
+ previous: AbsoluteTime.fromMilliseconds(
+ sub(time, { years: 20 }).getTime(),
+ ),
+ };
+ default:
+ assertUnreachable(timeframe);
+ }
+}
+
+function Metrics({
+ routeDownloadStats,
+}: {
+ routeDownloadStats: RouteDefinition;
+}): VNode {
+ const { i18n, dateLocale } = useTranslationContext();
+ const [metricType, setMetricType] =
+ useState<TalerCorebankApi.MonitorTimeframeParam>(
+ TalerCorebankApi.MonitorTimeframeParam.hour,
+ );
+ const { config } = useBankCoreApiContext();
+ const respInfo = useConversionInfo();
+ const params = getTimeframesForDate(new Date(), metricType);
+
+ const resp = useLastMonitorInfo(params.current, params.previous, metricType);
+ if (!resp) return <Fragment />;
+ if (resp instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resp} />;
+ }
+ if (!respInfo) return <Fragment />;
+ if (respInfo instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={respInfo} />;
+ }
+ if (respInfo.type === "fail") {
+ switch (respInfo.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default: {
+ assertUnreachable(respInfo.case);
+ }
+ }
+ }
+
+ if (resp.current.type !== "ok") {
+ switch (resp.current.case) {
+ case HttpStatusCode.BadRequest:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the current stats failed`}
+ >
+ <i18n.Translate>The request parameters are wrong</i18n.Translate>
+ </Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the current stats failed`}
+ >
+ <i18n.Translate>The user is unauthorized</i18n.Translate>
+ </Attention>
+ );
+ default: {
+ assertUnreachable(resp.current);
+ }
+ }
+ }
+ if (resp.previous.type !== "ok") {
+ switch (resp.previous.case) {
+ case HttpStatusCode.BadRequest:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the previous stats failed`}
+ >
+ <i18n.Translate>The request parameters are wrong</i18n.Translate>
+ </Attention>
+ );
+ case HttpStatusCode.Unauthorized:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`Querying for the previous stats failed`}
+ >
+ <i18n.Translate>The user is unauthorized</i18n.Translate>
+ </Attention>
+ );
+ default: {
+ assertUnreachable(resp.previous);
+ }
+ }
+ }
+ return (
+ <div class="px-4 mt-4">
+ <div class="sm:flex sm:items-center mb-4">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Transaction volume report</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+
+ <div class="sm:hidden">
+ <label for="tabs" class="sr-only">
+ <i18n.Translate>Select a section</i18n.Translate>
+ </label>
+ <select
+ id="tabs"
+ name="tabs"
+ class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
+ onChange={(e) => {
+ setMetricType(
+ parseInt(e.currentTarget
+ .value, 10) as TalerCorebankApi.MonitorTimeframeParam,
+ );
+ }}
+ >
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.hour}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.hour}
+ >
+ <i18n.Translate>Last hour</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.day}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.day}
+ >
+ <i18n.Translate>Previous day</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.month}
+ selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ >
+ <i18n.Translate>Last month</i18n.Translate>
+ </option>
+ <option
+ value={TalerCorebankApi.MonitorTimeframeParam.year}
+ selected={metricType == TalerCorebankApi.MonitorTimeframeParam.year}
+ >
+ <i18n.Translate>Last year</i18n.Translate>
+ </option>
+ </select>
+ </div>
+ <div class="hidden sm:block">
+ {/* FIXME: This should be LINKS */}
+ <nav
+ class="isolate flex divide-x divide-gray-200 rounded-lg shadow"
+ aria-label="Tabs"
+ >
+ <button
+ type="button"
+ name="set last hour"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.hour);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.hour
+ }
+ class="rounded-l-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last hour</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.hour
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set previous day"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.day);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.day
+ }
+ class=" text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Previous day</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.day
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set last month"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.month);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last month</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.month
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ <button
+ type="button"
+ name="set last year"
+ onClick={(e) => {
+ e.preventDefault();
+ setMetricType(TalerCorebankApi.MonitorTimeframeParam.year);
+ }}
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.year
+ }
+ class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10"
+ >
+ <span>
+ <i18n.Translate>Last Year</i18n.Translate>
+ </span>
+ <span
+ aria-hidden="true"
+ data-selected={
+ metricType == TalerCorebankApi.MonitorTimeframeParam.year
+ }
+ class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"
+ ></span>
+ </button>
+ </nav>
+ </div>
+
+ <div class="w-full flex justify-between">
+ <h1 class="text-base text-gray-900 mt-5">
+ {i18n.str`Trading volume on ${getDateForTimeframe(
+ params.current,
+ metricType,
+ dateLocale,
+ )} compared to ${getDateForTimeframe(
+ params.previous,
+ metricType,
+ dateLocale,
+ )}`}
+ </h1>
+ </div>
+ <dl class="mt-5 grid grid-cols-1 md:grid-cols-2 divide-y divide-gray-200 overflow-hidden rounded-lg bg-white shadow-lg md:divide-x md:divide-y-0">
+ {resp.current.body.type !== "with-conversions" ||
+ resp.previous.body.type !== "with-conversions" ? undefined : (
+ <Fragment>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Cashin</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an external account to an account in this
+ bank.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.cashinFiatVolume}
+ previous={resp.previous.body.cashinFiatVolume}
+ spec={respInfo.body.fiat_currency_specification}
+ />
+ </div>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Cashout</i18n.Translate>
+ </dt>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an account in this bank to an external
+ account.
+ </i18n.Translate>
+ </div>
+ <MetricValue
+ current={resp.current.body.cashoutFiatVolume}
+ previous={resp.previous.body.cashoutFiatVolume}
+ spec={respInfo.body.fiat_currency_specification}
+ />
+ </div>
+ </Fragment>
+ )}
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Payin</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from an account to a Taler exchange.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.talerInVolume}
+ previous={resp.previous.body.talerInVolume}
+ spec={config.currency_specification}
+ />
+ </div>
+ <div class="px-4 py-5 sm:p-6">
+ <dt class="text-base font-normal text-gray-900">
+ <i18n.Translate>Payout</i18n.Translate>
+ <div class="text-xs text-gray-500">
+ <i18n.Translate>
+ Transferred from a Taler exchange to another account.
+ </i18n.Translate>
+ </div>
+ </dt>
+ <MetricValue
+ current={resp.current.body.talerOutVolume}
+ previous={resp.previous.body.talerOutVolume}
+ spec={config.currency_specification}
+ />
+ </div>
+ </dl>
+ <div class="flex justify-end mt-4">
+ <a
+ href={routeDownloadStats.url({})}
+ name="download stats"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Download stats as CSV</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+}
+
+function MetricValue({
+ current,
+ previous,
+ spec,
+}: {
+ spec: CurrencySpecification;
+ current: AmountString | undefined;
+ previous: AmountString | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const cmp = current && previous ? Amounts.cmp(current, previous) : 0;
+ const cv = !current ? undefined : Amounts.stringifyValue(current);
+ const currAmount = !cv ? undefined : Number.parseFloat(cv);
+ const prevAmount = !previous
+ ? undefined
+ : Number.parseFloat(Amounts.stringifyValue(previous));
+
+ const rate =
+ !currAmount ||
+ Number.isNaN(currAmount) ||
+ !prevAmount ||
+ Number.isNaN(prevAmount)
+ ? 0
+ : cmp === -1
+ ? 1 - Math.round(currAmount) / Math.round(prevAmount)
+ : cmp === 1
+ ? Math.round(currAmount) / Math.round(prevAmount) - 1
+ : 0;
+
+ const negative = cmp === 0 ? undefined : cmp === -1;
+ const rateStr = `${(Math.abs(rate) * 100).toFixed(2)}%`;
+ return (
+ <Fragment>
+ <dd class="mt-1 block ">
+ <div class="flex justify-start text-2xl items-baseline font-semibold text-indigo-600">
+ {!current ? (
+ "-"
+ ) : (
+ <RenderAmount
+ value={Amounts.parseOrThrow(current)}
+ spec={spec}
+ hideSmall
+ />
+ )}
+ </div>
+ <div class="flex flex-col">
+ <div class="flex justify-end items-baseline text-2xl font-semibold text-indigo-600">
+ <small class="ml-2 text-sm font-medium text-gray-500">
+ <i18n.Translate>from</i18n.Translate>{" "}
+ {!previous ? (
+ "-"
+ ) : (
+ <RenderAmount
+ value={Amounts.parseOrThrow(previous)}
+ spec={spec}
+ hideSmall
+ />
+ )}
+ </small>
+ </div>
+ {!!rate && (
+ <span
+ data-negative={negative}
+ class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 text-green-800 data-[negative=true]:bg-red-100 px-2 py-1 text-xs font-medium data-[negative=true]:text-red-700 whitespace-pre"
+ >
+ {negative ? (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"
+ />
+ </svg>
+ ) : (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 19.5v-15m0 0l-6.75 6.75M12 4.5l6.75 6.75"
+ />
+ </svg>
+ )}
+
+ {negative ? (
+ <span class="sr-only">
+ <i18n.Translate>Decreased by</i18n.Translate>
+ </span>
+ ) : (
+ <span class="sr-only">
+ <i18n.Translate>Increased by</i18n.Translate>
+ </span>
+ )}
+ {rateStr}
+ </span>
+ )}
+ </div>
+ </dd>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx
new file mode 100644
index 000000000..68f39fb9f
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/CreateNewAccount.tsx
@@ -0,0 +1,226 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ HttpStatusCode,
+ TalerCorebankApi,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { AccountForm } from "./AccountForm.js";
+
+export function CreateNewAccount({
+ routeCancel,
+ onCreateSuccess,
+}: {
+ routeCancel: RouteDefinition;
+ onCreateSuccess: () => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { state: credentials } = useSessionState();
+ const token =
+ credentials.status !== "loggedIn" ? undefined : credentials.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [submitAccount, setSubmitAccount] = useState<
+ TalerCorebankApi.RegisterAccountRequest | undefined
+ >();
+ const [notification, notify, handleError] = useLocalNotification();
+
+ async function doCreate() {
+ if (!submitAccount || !token) return;
+ await handleError(async () => {
+ const resp = await api.createAccount(token, submitAccount);
+ if (resp.type === "ok") {
+ notifyInfo(
+ i18n.str`Account created with password "${submitAccount.password}".`,
+ );
+ onCreateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.BadRequest:
+ return notify({
+ type: "error",
+ title: i18n.str`Server replied that phone or email is invalid`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`The rights to perform the operation are not sufficient`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE:
+ return notify({
+ type: "error",
+ title: i18n.str`Account username is already taken`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE:
+ return notify({
+ type: "error",
+ title: i18n.str`Account id is already taken`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Bank ran out of bonus credit.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`Account username can't be used because is reserved`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT:
+ return notify({
+ type: "error",
+ title: i18n.str`Only admin is allow to set debt limit.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_MISSING_TAN_INFO:
+ return notify({
+ type: "error",
+ title: i18n.str`No information for the selected authentication channel.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED:
+ return notify({
+ type: "error",
+ title: i18n.str`Authentication channel is not supported.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL:
+ return notify({
+ type: "error",
+ title: i18n.str`Only admin can create accounts with second factor authentication.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_NON_ADMIN_SET_MIN_CASHOUT: {
+ return notify({
+ type: "error",
+ title: i18n.str`Only the administrator can change the minimum cashout limit.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Can't create accounts`}>
+ <i18n.Translate>
+ Only system admin can create accounts.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeCancel.url({})}
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ return (
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>New bank account</i18n.Translate>
+ </h2>
+ </div>
+ <AccountForm
+ template={undefined}
+ purpose="create"
+ onChange={(a) => {
+ setSubmitAccount(a);
+ }}
+ >
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeCancel.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="create"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={!submitAccount}
+ onClick={(e) => {
+ e.preventDefault();
+ doCreate();
+ }}
+ >
+ <i18n.Translate>Create</i18n.Translate>
+ </button>
+ </div>
+ </AccountForm>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/admin/DownloadStats.tsx b/packages/bank-ui/src/pages/admin/DownloadStats.tsx
new file mode 100644
index 000000000..8f6bb7c23
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/DownloadStats.tsx
@@ -0,0 +1,588 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AccessToken,
+ AmountString,
+ TalerCoreBankHttpClient,
+ TalerCorebankApi,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ LocalNotificationBanner,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { getTimeframesForDate } from "./AdminHome.js";
+
+interface Props {
+ routeCancel: RouteDefinition;
+}
+
+type Options = {
+ dayMetric: boolean;
+ hourMetric: boolean;
+ monthMetric: boolean;
+ yearMetric: boolean;
+ compareWithPrevious: boolean;
+ endOnFirstFail: boolean;
+ includeHeader: boolean;
+};
+
+/**
+ * Show histories of public accounts.
+ */
+export function DownloadStats({ routeCancel }: Props): VNode {
+ const { i18n } = useTranslationContext();
+
+ const { state: credentials } = useSessionState();
+ const creds =
+ credentials.status !== "loggedIn" || !credentials.isUserAdministrator
+ ? undefined
+ : credentials;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+
+ const [options, setOptions] = useState<Options>({
+ compareWithPrevious: true,
+ dayMetric: true,
+ endOnFirstFail: false,
+ hourMetric: true,
+ includeHeader: true,
+ monthMetric: true,
+ yearMetric: true,
+ });
+ const [lastStep, setLastStep] = useState<{ step: number; total: number }>();
+ const [downloaded, setDownloaded] = useState<string>();
+ const referenceDates = [new Date()];
+ const [notification, , handleError] = useLocalNotification();
+
+ if (!creds) {
+ return <div>only admin can download stats</div>;
+ }
+
+ return (
+ <div>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Download bank stats</i18n.Translate>
+ </h2>
+ </div>
+
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include hour metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`hour switch`}
+ data-enabled={options.hourMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ hourMetric: !options.hourMetric,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.hourMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include day metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`day switch`}
+ data-enabled={!!options.dayMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({ ...options, dayMetric: !options.dayMetric });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.dayMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include month metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`month switch`}
+ data-enabled={!!options.monthMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ monthMetric: !options.monthMetric,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.monthMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include year metric</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`year switch`}
+ data-enabled={!!options.yearMetric}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ yearMetric: !options.yearMetric,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.yearMetric}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Include table header</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`header switch`}
+ data-enabled={!!options.includeHeader}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ includeHeader: !options.includeHeader,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.includeHeader}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>
+ Add previous metric for compare
+ </i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`compare switch`}
+ data-enabled={!!options.compareWithPrevious}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ compareWithPrevious: !options.compareWithPrevious,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.compareWithPrevious}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ <div class="sm:col-span-5">
+ <div class="flex items-center justify-between">
+ <span class="flex flex-grow flex-col">
+ <span
+ class="text-sm text-black font-medium leading-6 "
+ id="availability-label"
+ >
+ <i18n.Translate>Fail on first error</i18n.Translate>
+ </span>
+ </span>
+ <button
+ type="button"
+ name={`fail switch`}
+ data-enabled={!!options.endOnFirstFail}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ setOptions({
+ ...options,
+ endOnFirstFail: !options.endOnFirstFail,
+ });
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={options.endOnFirstFail}
+ class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ ></span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="download"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ disabled={lastStep !== undefined}
+ onClick={async () => {
+ setDownloaded(undefined);
+ await handleError(async () => {
+ const csv = await fetchAllStatus(
+ api,
+ creds.token,
+ options,
+ referenceDates,
+ (step, total) => {
+ setLastStep({ step, total });
+ },
+ );
+ setDownloaded(csv);
+ });
+ setLastStep(undefined);
+ }}
+ >
+ <i18n.Translate>Download</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ {!lastStep || lastStep.step === lastStep.total ? (
+ <div class="h-5 mb-5" />
+ ) : (
+ <div>
+ <div class="relative mb-5 h-5 rounded-full bg-gray-200">
+ <div
+ class="h-full animate-pulse rounded-full bg-blue-500"
+ style={{
+ width: `${Math.round((lastStep.step / lastStep.total) * 100)}%`,
+ }}
+ >
+ <span class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
+ <i18n.Translate>
+ downloading...{" "}
+ {Math.round((lastStep.step / lastStep.total) * 100)}
+ </i18n.Translate>
+ </span>
+ </div>
+ </div>
+ </div>
+ )}
+ {!downloaded ? (
+ <div class="h-5 mb-5" />
+ ) : (
+ <a
+ href={
+ "data:text/plain;charset=utf-8," + encodeURIComponent(downloaded)
+ }
+ name="save file"
+ download={"bank-stats.csv"}
+ >
+ <Attention title={i18n.str`Download completed`}>
+ <i18n.Translate>
+ Click here to save the file in your computer.
+ </i18n.Translate>
+ </Attention>
+ </a>
+ )}
+ </div>
+ );
+}
+
+async function fetchAllStatus(
+ api: TalerCoreBankHttpClient,
+ token: AccessToken,
+ options: Options,
+ references: Date[],
+ progress: (current: number, total: number) => void,
+): Promise<string> {
+ const allMetrics: TalerCorebankApi.MonitorTimeframeParam[] = [];
+ if (options.hourMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.hour);
+ }
+ if (options.dayMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.day);
+ }
+ if (options.monthMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.month);
+ }
+ if (options.yearMetric) {
+ allMetrics.push(TalerCorebankApi.MonitorTimeframeParam.year);
+ }
+
+ /**
+ * convert request into frames
+ */
+ const allFrames = allMetrics.flatMap((timeframe) =>
+ references.map((reference) => ({
+ reference,
+ timeframe,
+ moment: getTimeframesForDate(reference, timeframe),
+ })),
+ );
+ const total = allFrames.length;
+
+ /**
+ * call API for info
+ */
+ const allInfo = await allFrames.reduce(
+ async (prev, frame, index) => {
+ const accumulatedMap = await prev;
+ progress(index, total);
+ // await delay()
+ const previous = options.compareWithPrevious
+ ? await api.getMonitor(token, {
+ timeframe: frame.timeframe,
+ date: frame.moment.previous,
+ })
+ : undefined;
+
+ if (previous && previous.type === "fail" && options.endOnFirstFail) {
+ throw TalerError.fromUncheckedDetail(previous.detail);
+ }
+
+ const current = await api.getMonitor(token, {
+ timeframe: frame.timeframe,
+ date: frame.moment.current,
+ });
+
+ if (current.type === "fail" && options.endOnFirstFail) {
+ throw TalerError.fromUncheckedDetail(current.detail);
+ }
+
+ const metricName =
+ TalerCorebankApi.MonitorTimeframeParam[allMetrics[index]];
+ accumulatedMap[metricName] = {
+ reference: frame.reference,
+ current: current.type !== "ok" ? undefined : current.body,
+ previous:
+ !previous || previous.type !== "ok" ? undefined : previous.body,
+ };
+ return accumulatedMap;
+ },
+ Promise.resolve({} as Record<string, Data>),
+ );
+ progress(total, total);
+
+ /**
+ * convert into table format
+ *
+ */
+ const table: Array<string[]> = [];
+ if (options.includeHeader) {
+ table.push([
+ "date",
+ "metric",
+ "reference",
+ "talerInCount",
+ "talerInVolume",
+ "talerOutCount",
+ "talerOutVolume",
+ "cashinCount",
+ "cashinFiatVolume",
+ "cashinRegionalVolume",
+ "cashoutCount",
+ "cashoutFiatVolume",
+ "cashoutRegionalVolume",
+ ]);
+ }
+ Object.entries(allInfo).forEach(([name, data]) => {
+ if (data.current) {
+ const row: TableRow = {
+ date: data.reference.getTime(),
+ metric: name,
+ reference: "current",
+ ...dataToRow(data.current),
+ };
+ table.push(Object.values(row) as string[]);
+ }
+
+ if (data.previous) {
+ const row: TableRow = {
+ date: data.reference.getTime(),
+ metric: name,
+ reference: "previous",
+ ...dataToRow(data.previous),
+ };
+ table.push(Object.values(row) as string[]);
+ }
+ });
+
+ const csv = table.reduce((acc, row) => {
+ return acc + row.join(",") + "\n";
+ }, "");
+
+ return csv;
+}
+
+type JustData = Omit<Omit<Omit<TableRow, "metric">, "date">, "reference">;
+function dataToRow(info: TalerCorebankApi.MonitorResponse): JustData {
+ return {
+ talerInCount: info.talerInCount,
+ talerInVolume: info.talerInVolume,
+ talerOutCount: info.talerOutCount,
+ talerOutVolume: info.talerOutVolume,
+ cashinCount: info.type === "no-conversions" ? undefined : info.cashinCount,
+ cashinFiatVolume:
+ info.type === "no-conversions" ? undefined : info.cashinFiatVolume,
+ cashinRegionalVolume:
+ info.type === "no-conversions" ? undefined : info.cashinRegionalVolume,
+ cashoutCount:
+ info.type === "no-conversions" ? undefined : info.cashoutCount,
+ cashoutFiatVolume:
+ info.type === "no-conversions" ? undefined : info.cashoutFiatVolume,
+ cashoutRegionalVolume:
+ info.type === "no-conversions" ? undefined : info.cashoutRegionalVolume,
+ };
+}
+
+type Data = {
+ reference: Date;
+ previous: TalerCorebankApi.MonitorResponse | undefined;
+ current: TalerCorebankApi.MonitorResponse | undefined;
+};
+type TableRow = {
+ date: number;
+ metric: string;
+ reference: "current" | "previous";
+ cashinCount?: number;
+ cashinRegionalVolume?: AmountString;
+ cashinFiatVolume?: AmountString;
+ cashoutCount?: number;
+ cashoutRegionalVolume?: AmountString;
+ cashoutFiatVolume?: AmountString;
+ talerInCount: number;
+ talerInVolume: AmountString;
+ talerOutCount: number;
+ talerOutVolume: AmountString;
+};
diff --git a/packages/bank-ui/src/pages/admin/RemoveAccount.tsx b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx
new file mode 100644
index 000000000..dbeebf719
--- /dev/null
+++ b/packages/bank-ui/src/pages/admin/RemoveAccount.tsx
@@ -0,0 +1,273 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useSessionState } from "../../hooks/session.js";
+import { undefinedIfEmpty } from "../../utils.js";
+import { LoginForm } from "../LoginForm.js";
+import { doAutoFocus } from "../PaytoWireTransferForm.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+
+export function RemoveAccount({
+ account,
+ routeCancel,
+ onUpdateSuccess,
+ onAuthorizationRequired,
+ focus,
+ routeHere,
+}: {
+ focus?: boolean;
+ routeHere: RouteDefinition<{ account: string }>;
+ onAuthorizationRequired: () => void;
+ routeCancel: RouteDefinition;
+ onUpdateSuccess: () => void;
+ account: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const [accountName, setAccountName] = useState<string | undefined>();
+
+ const { state } = useSessionState();
+ const token = state.status !== "loggedIn" ? undefined : state.token;
+ const {
+ lib: { bank: api },
+ } = useBankCoreApiContext();
+ const [notification, notify, handleError] = useLocalNotification();
+ const [, updateBankState] = useBankState();
+
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={account} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={account} />;
+ default:
+ assertUnreachable(result);
+ }
+ }
+
+ const balance = Amounts.parse(result.body.balance.amount);
+ if (!balance) {
+ return <div>there was an error reading the balance</div>;
+ }
+ const isBalanceEmpty = Amounts.isZero(balance);
+ if (!isBalanceEmpty) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Can't delete the account`}>
+ <i18n.Translate>
+ The account can't be delete while still holding some balance. First
+ make sure that the owner make a complete cashout.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeCancel.url({})}
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ async function doRemove() {
+ if (!token) return;
+ await handleError(async () => {
+ const resp = await api.deleteAccount({ username: account, token });
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Account removed`);
+ onUpdateSuccess();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized:
+ return notify({
+ type: "error",
+ title: i18n.str`No enough permission to delete the account.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`The username was not found.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT:
+ return notify({
+ type: "error",
+ title: i18n.str`Can't delete a reserved username.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_ACCOUNT_BALANCE_NOT_ZERO:
+ return notify({
+ type: "error",
+ title: i18n.str`Can't delete an account with balance different than zero.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "delete-account",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({ account }),
+ request: account,
+ });
+ return onAuthorizationRequired();
+ }
+ default: {
+ assertUnreachable(resp);
+ }
+ }
+ }
+ });
+ }
+
+ const errors = undefinedIfEmpty({
+ accountName: !accountName
+ ? i18n.str`Required`
+ : account !== accountName
+ ? i18n.str`Name doesn't match`
+ : undefined,
+ });
+
+ return (
+ <div>
+ <LocalNotificationBanner notification={notification} />
+
+ <Attention
+ type="warning"
+ title={i18n.str`You are going to remove the account`}
+ >
+ <i18n.Translate>This step can't be undone.</i18n.Translate>
+ </Attention>
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <div class="px-4 sm:px-0">
+ <h2 class="text-base font-semibold leading-7 text-gray-900">
+ <i18n.Translate>Deleting account "{account}"</i18n.Translate>
+ </h2>
+ </div>
+ <form
+ class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"
+ autoCapitalize="none"
+ autoCorrect="off"
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ >
+ <div class="px-4 py-6 sm:p-8">
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="password"
+ >
+ {i18n.str`Verification`}
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ name="password"
+ id="password"
+ data-error={
+ !!errors?.accountName && accountName !== undefined
+ }
+ value={accountName ?? ""}
+ onChange={(e) => {
+ setAccountName(e.currentTarget.value);
+ }}
+ placeholder={account}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.accountName}
+ isDirty={accountName !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Enter the account name that is going to be deleted
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeCancel.url({})}
+ name="cancel"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="delete"
+ class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
+ disabled={!!errors}
+ onClick={(e) => {
+ e.preventDefault();
+ doRemove();
+ }}
+ >
+ <i18n.Translate>Delete</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/index.stories.tsx b/packages/bank-ui/src/pages/index.stories.tsx
new file mode 100644
index 000000000..823def5d7
--- /dev/null
+++ b/packages/bank-ui/src/pages/index.stories.tsx
@@ -0,0 +1,20 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+export * as qr from "./QrCodeSection.stories.js";
+export * as po from "./PaymentOptions.stories.js";
+export * as ptf from "./PaytoWireTransferForm.stories.js";
+export * as frame from "./BankFrame.stories.js";
diff --git a/packages/bank-ui/src/pages/regional/ConversionConfig.tsx b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx
new file mode 100644
index 000000000..485ef5490
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/ConversionConfig.tsx
@@ -0,0 +1,1170 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TalerBankConversionApi,
+ TalerError,
+ TranslatedString,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ useLocalNotification,
+ useTranslationContext,
+ utils,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useSessionState } from "../../hooks/session.js";
+import {
+ TransferCalculation,
+ useCashinEstimator,
+ useCashoutEstimator,
+ useConversionInfo,
+} from "../../hooks/regional.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { undefinedIfEmpty } from "../../utils.js";
+import { InputAmount, RenderAmount } from "../PaytoWireTransferForm.js";
+import { ProfileNavigation } from "../ProfileNavigation.js";
+import {
+ FormErrors,
+ FormStatus,
+ FormValues,
+ RecursivePartial,
+ UIField,
+ useFormState,
+} from "../../hooks/form.js";
+
+interface Props {
+ routeMyAccountDetails: RouteDefinition;
+ routeMyAccountDelete: RouteDefinition;
+ routeMyAccountPassword: RouteDefinition;
+ routeMyAccountCashout: RouteDefinition;
+ routeConversionConfig: RouteDefinition;
+ routeCancel: RouteDefinition;
+ onUpdateSuccess: () => void;
+}
+
+type FormType = {
+ amount: AmountJson;
+ conv: TalerBankConversionApi.ConversionRate;
+};
+
+function useComponentState({
+ routeCancel,
+ routeConversionConfig,
+ routeMyAccountCashout,
+ routeMyAccountDelete,
+ routeMyAccountDetails,
+ routeMyAccountPassword,
+}: Props): utils.RecursiveState<VNode> {
+ const { i18n } = useTranslationContext();
+
+ const result = useConversionInfo();
+ const info =
+ result && !(result instanceof TalerError) && result.type === "ok"
+ ? result.body
+ : undefined;
+
+ const { state: credentials } = useSessionState();
+ const creds =
+ credentials.status !== "loggedIn" || !credentials.isUserAdministrator
+ ? undefined
+ : credentials;
+
+ if (!info) {
+ return <i18n.Translate>loading...</i18n.Translate>;
+ }
+
+ if (!creds) {
+ return <i18n.Translate>only admin can setup conversion</i18n.Translate>;
+ }
+
+ return function afterComponentLoads() {
+ const { i18n } = useTranslationContext();
+
+ const {
+ lib: { conversion },
+ } = useBankCoreApiContext();
+
+ const [notification, notify, handleError] = useLocalNotification();
+
+ const initalState: FormValues<FormType> = {
+ amount: "100",
+ conv: {
+ cashin_min_amount: info.conversion_rate.cashin_min_amount.split(":")[1],
+ cashin_tiny_amount:
+ info.conversion_rate.cashin_tiny_amount.split(":")[1],
+ cashin_fee: info.conversion_rate.cashin_fee.split(":")[1],
+ cashin_ratio: info.conversion_rate.cashin_ratio,
+ cashin_rounding_mode: info.conversion_rate.cashin_rounding_mode,
+ cashout_min_amount:
+ info.conversion_rate.cashout_min_amount.split(":")[1],
+ cashout_tiny_amount:
+ info.conversion_rate.cashout_tiny_amount.split(":")[1],
+ cashout_fee: info.conversion_rate.cashout_fee.split(":")[1],
+ cashout_ratio: info.conversion_rate.cashout_ratio,
+ cashout_rounding_mode: info.conversion_rate.cashout_rounding_mode,
+ },
+ };
+
+ const [form, status] = useFormState<FormType>(
+ initalState,
+ createFormValidator(i18n, info.regional_currency, info.fiat_currency),
+ );
+
+ const { estimateByDebit: calculateCashoutFromDebit } =
+ useCashoutEstimator();
+
+ const { estimateByDebit: calculateCashinFromDebit } = useCashinEstimator();
+
+ const [calculationResult, setCalc] = useState<{
+ cashin: TransferCalculation;
+ cashout: TransferCalculation;
+ }>();
+
+ useEffect(() => {
+ async function doAsync() {
+ await handleError(async () => {
+ if (!info) return;
+ if (!form.amount?.value || form.amount.error) return;
+ const in_amount = Amounts.parseOrThrow(
+ `${info.fiat_currency}:${form.amount.value}`,
+ );
+ const in_fee = Amounts.parseOrThrow(info.conversion_rate.cashin_fee);
+ const cashin = await calculateCashinFromDebit(in_amount, in_fee);
+
+ if (cashin === "amount-is-too-small") {
+ setCalc(undefined);
+ return;
+ }
+ // const out_amount = Amounts.parseOrThrow(`${info.regional_currency}:${form.amount.value}`)
+ const out_fee = Amounts.parseOrThrow(
+ info.conversion_rate.cashout_fee,
+ );
+ const cashout = await calculateCashoutFromDebit(
+ cashin.credit,
+ out_fee,
+ );
+
+ setCalc({ cashin, cashout });
+ });
+ }
+ doAsync();
+ }, [
+ form.amount?.value,
+ form.conv?.cashin_fee?.value,
+ form.conv?.cashout_fee?.value,
+ ]);
+
+ const [section, setSection] = useState<"detail" | "cashout" | "cashin">(
+ "detail",
+ );
+ const cashinCalc =
+ calculationResult?.cashin === "amount-is-too-small"
+ ? undefined
+ : calculationResult?.cashin;
+ const cashoutCalc =
+ calculationResult?.cashout === "amount-is-too-small"
+ ? undefined
+ : calculationResult?.cashout;
+ async function doUpdate() {
+ if (!creds) return;
+ await handleError(async () => {
+ if (status.status === "fail") return;
+ const resp = await conversion.updateConversionRate(
+ creds.token,
+ status.result.conv,
+ );
+ if (resp.type === "ok") {
+ setSection("detail");
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Unauthorized: {
+ return notify({
+ type: "error",
+ title: i18n.str`Wrong credentials`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ case HttpStatusCode.NotImplemented: {
+ return notify({
+ type: "error",
+ title: i18n.str`Conversion is disabled`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ default:
+ assertUnreachable(resp);
+ }
+ }
+ });
+ }
+
+ const in_ratio = Number.parseFloat(info.conversion_rate.cashin_ratio);
+ const out_ratio = Number.parseFloat(info.conversion_rate.cashout_ratio);
+
+ const both_high = in_ratio > 1 && out_ratio > 1;
+ const both_low = in_ratio < 1 && out_ratio < 1;
+
+ return (
+ <div>
+ <ProfileNavigation
+ current="conversion"
+ routeMyAccountCashout={routeMyAccountCashout}
+ routeMyAccountDelete={routeMyAccountDelete}
+ routeMyAccountDetails={routeMyAccountDetails}
+ routeMyAccountPassword={routeMyAccountPassword}
+ routeConversionConfig={routeConversionConfig}
+ />
+
+ <LocalNotificationBanner notification={notification} />
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 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>Conversion</i18n.Translate>
+ </h2>
+ <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4">
+ <label
+ data-enabled={section === "detail"}
+ class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]: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"
+ onChange={() => {
+ setSection("detail");
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Details</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+
+ <label
+ data-enabled={section === "cashout"}
+ class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600"
+ >
+ <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={() => {
+ setSection("cashout");
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Config cashout</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+ <label
+ data-enabled={section === "cashin"}
+ class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600"
+ >
+ <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={() => {
+ setSection("cashin");
+ }}
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900">
+ <i18n.Translate>Config cashin</i18n.Translate>
+ </span>
+ </span>
+ </span>
+ </label>
+ </div>
+ </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();
+ }}
+ >
+ {section == "cashin" && (
+ <ConversionForm
+ id="cashin"
+ inputCurrency={info.fiat_currency}
+ outputCurrency={info.regional_currency}
+ fee={form?.conv?.cashin_fee}
+ minimum={form?.conv?.cashin_min_amount}
+ ratio={form?.conv?.cashin_ratio}
+ rounding={form?.conv?.cashin_rounding_mode}
+ tiny={form?.conv?.cashin_tiny_amount}
+ />
+ )}
+
+ {section == "cashout" && (
+ <Fragment>
+ <ConversionForm
+ id="cashout"
+ inputCurrency={info.regional_currency}
+ outputCurrency={info.fiat_currency}
+ fee={form?.conv?.cashout_fee}
+ minimum={form?.conv?.cashout_min_amount}
+ ratio={form?.conv?.cashout_ratio}
+ rounding={form?.conv?.cashout_rounding_mode}
+ tiny={form?.conv?.cashout_tiny_amount}
+ />
+ </Fragment>
+ )}
+
+ {section == "detail" && (
+ <Fragment>
+ <div class="px-6 pt-6">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Cashin ratio</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ {info.conversion_rate.cashin_ratio}
+ </dd>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Cashout ratio</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ {info.conversion_rate.cashout_ratio}
+ </dd>
+ </div>
+ </div>
+
+ {both_low || both_high ? (
+ <div class="p-4">
+ <Attention title={i18n.str`Bad ratios`} type="warning">
+ <i18n.Translate>
+ One of the ratios should be higher or equal than 1 an
+ the other should be lower or equal than 1.
+ </i18n.Translate>
+ </Attention>
+ </div>
+ ) : undefined}
+
+ <div class="px-6 pt-6">
+ <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
+ for="amount"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Initial amount`}</label>
+ <InputAmount
+ name="amount"
+ left
+ currency={info.fiat_currency}
+ value={form.amount?.value ?? ""}
+ onChange={form.amount?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={form.amount?.error}
+ isDirty={form.amount?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Use it to test how the conversion will affect the
+ amount.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {!cashoutCalc || !cashinCalc ? undefined : (
+ <div class="px-6 pt-6">
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>
+ Sending to this bank
+ </i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashinCalc.debit}
+ negative
+ withColor
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ {Amounts.isZero(cashinCalc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between afu ">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Converted</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashinCalc.beforeFee}
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Cashin after fee</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={cashinCalc.credit}
+ withColor
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>
+ Sending from this bank
+ </i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashoutCalc.debit}
+ negative
+ withColor
+ spec={info.fiat_currency_specification}
+ />
+ </dd>
+ </div>
+
+ {Amounts.isZero(cashoutCalc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between afu">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Converted</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={cashoutCalc.beforeFee}
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Cashout after fee</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={cashoutCalc.credit}
+ withColor
+ spec={info.regional_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+
+ {cashoutCalc &&
+ status.status === "ok" &&
+ Amounts.cmp(status.result.amount, cashoutCalc.credit) <
+ 0 ? (
+ <div class="p-4">
+ <Attention
+ title={i18n.str`Bad configuration`}
+ type="warning"
+ >
+ <i18n.Translate>
+ This configuration allows users to cash out more of
+ what has been cashed in.
+ </i18n.Translate>
+ </Attention>
+ </div>
+ ) : undefined}
+ </div>
+ )}
+ </Fragment>
+ )}
+
+ <div class="flex items-center justify-between mt-4 gap-x-6 border-t border-gray-900/10 px-4 py-4">
+ <a
+ name="cancel"
+ href={routeCancel.url({})}
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ {section == "cashin" || section == "cashout" ? (
+ <Fragment>
+ <button
+ type="submit"
+ name="update conversion"
+ 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={async () => {
+ doUpdate();
+ }}
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+ </Fragment>
+ ) : (
+ <div />
+ )}
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+ };
+}
+
+export const ConversionConfig = utils.recursive(useComponentState);
+
+/**
+ *
+ * @param i18n
+ * @param regional
+ * @param fiat
+ * @returns form validator
+ */
+function createFormValidator(
+ i18n: InternationalizationAPI,
+ regional: string,
+ fiat: string,
+) {
+ return function check(state: FormValues<FormType>): FormStatus<FormType> {
+ const cashin_min_amount = Amounts.parse(
+ `${fiat}:${state.conv.cashin_min_amount}`,
+ );
+ const cashin_tiny_amount = Amounts.parse(
+ `${regional}:${state.conv.cashin_tiny_amount}`,
+ );
+ const cashin_fee = Amounts.parse(`${regional}:${state.conv.cashin_fee}`);
+
+ const cashout_min_amount = Amounts.parse(
+ `${regional}:${state.conv.cashout_min_amount}`,
+ );
+ const cashout_tiny_amount = Amounts.parse(
+ `${fiat}:${state.conv.cashout_tiny_amount}`,
+ );
+ const cashout_fee = Amounts.parse(`${fiat}:${state.conv.cashout_fee}`);
+
+ const am = Amounts.parse(`${fiat}:${state.amount}`);
+
+ const cashin_ratio = Number.parseFloat(state.conv.cashin_ratio ?? "");
+ const cashout_ratio = Number.parseFloat(state.conv.cashout_ratio ?? "");
+
+ const errors = undefinedIfEmpty<FormErrors<FormType>>({
+ conv: undefinedIfEmpty<FormErrors<FormType["conv"]>>({
+ cashin_min_amount: !state.conv.cashin_min_amount
+ ? i18n.str`required`
+ : !cashin_min_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashin_tiny_amount: !state.conv.cashin_tiny_amount
+ ? i18n.str`required`
+ : !cashin_tiny_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashin_fee: !state.conv.cashin_fee
+ ? i18n.str`required`
+ : !cashin_fee
+ ? i18n.str`invalid`
+ : undefined,
+
+ cashout_min_amount: !state.conv.cashout_min_amount
+ ? i18n.str`required`
+ : !cashout_min_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashout_tiny_amount: !state.conv.cashin_tiny_amount
+ ? i18n.str`required`
+ : !cashout_tiny_amount
+ ? i18n.str`invalid`
+ : undefined,
+ cashout_fee: !state.conv.cashin_fee
+ ? i18n.str`required`
+ : !cashout_fee
+ ? i18n.str`invalid`
+ : undefined,
+
+ cashin_rounding_mode: !state.conv.cashin_rounding_mode
+ ? i18n.str`required`
+ : undefined,
+ cashout_rounding_mode: !state.conv.cashout_rounding_mode
+ ? i18n.str`required`
+ : undefined,
+
+ cashin_ratio: !state.conv.cashin_ratio
+ ? i18n.str`required`
+ : Number.isNaN(cashin_ratio)
+ ? i18n.str`invalid`
+ : undefined,
+ cashout_ratio: !state.conv.cashout_ratio
+ ? i18n.str`required`
+ : Number.isNaN(cashout_ratio)
+ ? i18n.str`invalid`
+ : undefined,
+ }),
+
+ amount: !state.amount
+ ? i18n.str`required`
+ : !am
+ ? i18n.str`invalid`
+ : undefined,
+ });
+
+ const result: RecursivePartial<FormType> = {
+ amount: am,
+ conv: {
+ cashin_fee: !errors?.conv?.cashin_fee
+ ? Amounts.stringify(cashin_fee!)
+ : undefined,
+ cashin_min_amount: !errors?.conv?.cashin_min_amount
+ ? Amounts.stringify(cashin_min_amount!)
+ : undefined,
+ cashin_ratio: !errors?.conv?.cashin_ratio
+ ? String(cashin_ratio!)
+ : undefined,
+ cashin_rounding_mode: !errors?.conv?.cashin_rounding_mode
+ ? state.conv.cashin_rounding_mode!
+ : undefined,
+ cashin_tiny_amount: !errors?.conv?.cashin_tiny_amount
+ ? Amounts.stringify(cashin_tiny_amount!)
+ : undefined,
+ cashout_fee: !errors?.conv?.cashout_fee
+ ? Amounts.stringify(cashout_fee!)
+ : undefined,
+ cashout_min_amount: !errors?.conv?.cashout_min_amount
+ ? Amounts.stringify(cashout_min_amount!)
+ : undefined,
+ cashout_ratio: !errors?.conv?.cashout_ratio
+ ? String(cashout_ratio!)
+ : undefined,
+ cashout_rounding_mode: !errors?.conv?.cashout_rounding_mode
+ ? state.conv.cashout_rounding_mode!
+ : undefined,
+ cashout_tiny_amount: !errors?.conv?.cashout_tiny_amount
+ ? Amounts.stringify(cashout_tiny_amount!)
+ : undefined,
+ },
+ };
+ return errors === undefined
+ ? { status: "ok", result: result as FormType, errors }
+ : { status: "fail", result, errors };
+ };
+}
+
+function ConversionForm({
+ id,
+ inputCurrency,
+ outputCurrency,
+ fee,
+ minimum,
+ ratio,
+ rounding,
+ tiny,
+}: {
+ inputCurrency: string;
+ outputCurrency: string;
+ minimum: UIField | undefined;
+ tiny: UIField | undefined;
+ fee: UIField | undefined;
+ rounding: UIField | undefined;
+ ratio: UIField | undefined;
+ id: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <Fragment>
+ <div class="px-6 pt-6">
+ <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
+ for={`${id}_min_amount`}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Minimum amount`}</label>
+ <InputAmount
+ name={`${id}_min_amount`}
+ left
+ currency={inputCurrency}
+ value={minimum?.value ?? ""}
+ onChange={minimum?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={minimum?.error}
+ isDirty={minimum?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Only cashout operation above this threshold will be allowed
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for={`${id}_ratio`}
+ >
+ {i18n.str`Ratio`}
+ </label>
+ <div class="mt-2">
+ <input
+ type="number"
+ class="block 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="current"
+ id={`${id}_ratio`}
+ data-error={!!ratio?.error && ratio?.value !== undefined}
+ value={ratio?.value ?? ""}
+ onChange={(e) => {
+ ratio?.onUpdate(e.currentTarget.value);
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={ratio?.error}
+ isDirty={ratio?.value !== undefined}
+ />
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>Conversion ratio between currencies</i18n.Translate>
+ </p>
+ </div>
+
+ <div class="px-6 pt-4">
+ <Attention title={i18n.str`Example conversion`}>
+ <i18n.Translate>
+ 1 {inputCurrency} will be converted into {ratio?.value}{" "}
+ {outputCurrency}
+ </i18n.Translate>
+ </Attention>
+ </div>
+
+ <div class="px-6 pt-6">
+ <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
+ for={`${id}_tiny_amount`}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Rounding value`}</label>
+ <InputAmount
+ name={`${id}_tiny_amount`}
+ left
+ currency={outputCurrency}
+ value={tiny?.value ?? ""}
+ onChange={tiny?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={tiny?.error}
+ isDirty={tiny?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Smallest difference between two amounts after the ratio is
+ applied.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-6">
+ <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={`${id}_channel`}
+ >
+ {i18n.str`Rounding mode`}
+ </label>
+ <div class="mt-2 max-w-xl text-sm text-gray-500">
+ <div class="px-4 mt-4 grid grid-cols-1 gap-y-6">
+ <label
+ onClick={(e) => {
+ e.preventDefault();
+ rounding?.onUpdate("zero");
+ }}
+ data-selected={rounding?.value === "zero"}
+ class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="channel"
+ value="Newsletter"
+ class="sr-only"
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900 ">
+ <i18n.Translate>Zero</i18n.Translate>
+ </span>
+ <i18n.Translate>
+ Amount will be round below to the largest possible value
+ smaller than the input.
+ </i18n.Translate>
+ </span>
+ </span>
+ <svg
+ data-selected={rounding?.value === "zero"}
+ class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </label>
+
+ <label
+ onClick={(e) => {
+ e.preventDefault();
+ rounding?.onUpdate("up");
+ }}
+ data-selected={rounding?.value === "up"}
+ class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="channel"
+ value="Existing Customers"
+ class="sr-only"
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900 ">
+ <i18n.Translate>Up</i18n.Translate>
+ </span>
+ <i18n.Translate>
+ Amount will be round up to the smallest possible value
+ larger than the input.
+ </i18n.Translate>
+ </span>
+ </span>
+ <svg
+ data-selected={rounding?.value === "up"}
+ class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </label>
+ <label
+ onClick={(e) => {
+ e.preventDefault();
+ rounding?.onUpdate("nearest");
+ }}
+ data-selected={rounding?.value === "nearest"}
+ class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"
+ >
+ <input
+ type="radio"
+ name="channel"
+ value="Existing Customers"
+ class="sr-only"
+ />
+ <span class="flex flex-1">
+ <span class="flex flex-col">
+ <span class="block text-sm font-medium text-gray-900 ">
+ <i18n.Translate>Nearest</i18n.Translate>
+ </span>
+ <i18n.Translate>
+ Amount will be round to the closest possible value.
+ </i18n.Translate>
+ </span>
+ </span>
+ <svg
+ data-selected={rounding?.value === "nearest"}
+ class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </label>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="px-6 pt-4">
+ <Attention title={i18n.str`Examples`}>
+ <section class="grid grid-cols-1 gap-y-3 text-gray-600">
+ <details class="group text-sm">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.24 with rounding value 0.1
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.1 the possible values closest to
+ 1.24 are: 1.1, 1.2, 1.3, 1.4.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 mt-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ </details>
+ <details class="group ">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.26 with rounding value 0.1
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.1 the possible values closest to
+ 1.24 are: 1.1, 1.2, 1.3, 1.4.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ </details>
+ <details class="group ">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.24 with rounding value 0.3
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.3 the possible values closest to
+ 1.24 are: 0.9, 1.2, 1.5, 1.8.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.5
+ </i18n.Translate>
+ </p>
+ </details>
+ <details class="group ">
+ <summary class="flex cursor-pointer flex-row items-center justify-between ">
+ <i18n.Translate>
+ Rounding an amount of 1.26 with rounding value 0.3
+ </i18n.Translate>
+ <svg
+ class="h-6 w-6 rotate-0 transform group-open:rotate-180"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M19 9l-7 7-7-7"
+ ></path>
+ </svg>
+ </summary>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ Given the rounding value of 0.3 the possible values closest to
+ 1.24 are: 0.9, 1.2, 1.5, 1.8.
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "zero" mode the value will be rounded to 1.2
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "nearest" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ <p class="text-gray-900 my-4">
+ <i18n.Translate>
+ With the "up" mode the value will be rounded to 1.3
+ </i18n.Translate>
+ </p>
+ </details>
+ </section>
+ </Attention>
+ </div>
+
+ <div class="px-6 pt-6">
+ <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
+ for={`${id}_fee`}
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >{i18n.str`Fee`}</label>
+ <InputAmount
+ name={`${id}_fee`}
+ left
+ currency={outputCurrency}
+ value={fee?.value ?? ""}
+ onChange={fee?.onUpdate}
+ />
+ <ShowInputErrorLabel
+ message={fee?.error}
+ isDirty={fee?.value !== undefined}
+ />
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ Amount to be deducted before amount is credited.
+ </i18n.Translate>
+ </p>
+ </div>
+ </div>
+ </div>
+ </Fragment>
+ );
+}
diff --git a/packages/bank-ui/src/pages/regional/CreateCashout.tsx b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
new file mode 100644
index 000000000..c51b96b8b
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/CreateCashout.tsx
@@ -0,0 +1,732 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ TalerErrorCode,
+ TranslatedString,
+ assertUnreachable,
+ encodeCrock,
+ getRandomBytes,
+ parsePaytoUri,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ LocalNotificationBanner,
+ ShowInputErrorLabel,
+ notifyInfo,
+ useLocalNotification,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { useBankCoreApiContext } from "@gnu-taler/web-util/browser";
+import { useAccountDetails } from "../../hooks/account.js";
+import { useBankState } from "../../hooks/bank-state.js";
+import {
+ TransferCalculation,
+ useCashoutEstimator,
+ useConversionInfo,
+} from "../../hooks/regional.js";
+import { useSessionState } from "../../hooks/session.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { TanChannel, undefinedIfEmpty } from "../../utils.js";
+import { LoginForm } from "../LoginForm.js";
+import {
+ InputAmount,
+ RenderAmount,
+ doAutoFocus,
+} from "../PaytoWireTransferForm.js";
+
+interface Props {
+ account: string;
+ focus?: boolean;
+ onAuthorizationRequired: () => void;
+ onCashout: () => void;
+ routeClose: RouteDefinition;
+ routeHere: RouteDefinition;
+}
+
+type FormType = {
+ isDebit: boolean;
+ amount: string;
+ subject: string;
+ channel: TanChannel;
+};
+type ErrorFrom<T> = {
+ [P in keyof T]+?: string;
+};
+
+export function CreateCashout({
+ account: accountName,
+ onAuthorizationRequired,
+ onCashout,
+ focus,
+ routeHere,
+ routeClose,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const resultAccount = useAccountDetails(accountName);
+ const {
+ estimateByCredit: calculateFromCredit,
+ estimateByDebit: calculateFromDebit,
+ } = useCashoutEstimator();
+ const { state: credentials } = useSessionState();
+ const creds = credentials.status !== "loggedIn" ? undefined : credentials;
+ const [, updateBankState] = useBankState();
+
+ const {
+ lib: { bank: api },
+ config,
+ } = useBankCoreApiContext();
+ const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
+ const [notification, notify, handleError] = useLocalNotification();
+ const info = useConversionInfo();
+
+ if (!config.allow_conversion) {
+ return (
+ <Fragment>
+ <Attention type="warning" title={i18n.str`Unable to create a cashout`}>
+ <i18n.Translate>
+ The bank configuration does not support cashout operations.
+ </i18n.Translate>
+ </Attention>
+ <div class="mt-5 sm:mt-6">
+ <a
+ href={routeClose.url({})}
+ name="close"
+ class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </Fragment>
+ );
+ }
+
+ if (!resultAccount) {
+ return <Loading />;
+ }
+ if (resultAccount instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={resultAccount} />;
+ }
+ if (resultAccount.type === "fail") {
+ switch (resultAccount.case) {
+ case HttpStatusCode.Unauthorized:
+ return <LoginForm currentUser={accountName} />;
+ case HttpStatusCode.NotFound:
+ return <LoginForm currentUser={accountName} />;
+ default:
+ assertUnreachable(resultAccount);
+ }
+ }
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ const conversionInfo = info.body.conversion_rate;
+ if (!conversionInfo) {
+ return (
+ <div>conversion enabled but server replied without conversion_rate</div>
+ );
+ }
+
+ const {
+ fiat_currency,
+ regional_currency,
+ fiat_currency_specification,
+ regional_currency_specification,
+ } = info.body;
+ const regionalZero = Amounts.zeroOfCurrency(regional_currency);
+ const fiatZero = Amounts.zeroOfCurrency(fiat_currency);
+
+ const account = {
+ balance: Amounts.parseOrThrow(resultAccount.body.balance.amount),
+ balanceIsDebit:
+ resultAccount.body.balance.credit_debit_indicator == "debit",
+ debitThreshold: Amounts.parseOrThrow(resultAccount.body.debit_threshold),
+ minCashout: resultAccount.body.min_cashout === undefined ? regionalZero : Amounts.parseOrThrow(resultAccount.body.min_cashout)
+ };
+
+ const limit = account.balanceIsDebit
+ ? Amounts.sub(account.debitThreshold, account.balance).amount
+ : Amounts.add(account.balance, account.debitThreshold).amount;
+
+ const zeroCalc = {
+ debit: regionalZero,
+ credit: fiatZero,
+ beforeFee: fiatZero,
+ };
+ const [calculationResult, setCalculation] =
+ useState<TransferCalculation>(zeroCalc);
+ const sellFee = Amounts.parseOrThrow(conversionInfo.cashout_fee);
+ const sellRate = conversionInfo.cashout_ratio;
+ /**
+ * can be in regional currency or fiat currency
+ * depending on the isDebit flag
+ */
+ const inputAmount = Amounts.parseOrThrow(
+ `${form.isDebit ? regional_currency : fiat_currency}:${
+ !form.amount ? "0" : form.amount
+ }`,
+ );
+
+ useEffect(() => {
+ async function doAsync() {
+ await handleError(async () => {
+ const higerThanMin = form.isDebit
+ ? Amounts.cmp(inputAmount, conversionInfo.cashout_min_amount) === 1
+ : true;
+ const notZero = Amounts.isNonZero(inputAmount);
+ if (notZero && higerThanMin) {
+ const resp = await (form.isDebit
+ ? calculateFromDebit(inputAmount, sellFee)
+ : calculateFromCredit(inputAmount, sellFee));
+ setCalculation(resp);
+ } else {
+ setCalculation(zeroCalc);
+ }
+ });
+ }
+ doAsync();
+ }, [form.amount, form.isDebit]);
+
+ const calc =
+ calculationResult === "amount-is-too-small" ? zeroCalc : calculationResult;
+
+ const balanceAfter = Amounts.sub(account.balance, calc.debit).amount;
+
+ function updateForm(newForm: typeof form): void {
+ setForm(newForm);
+ }
+ const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({
+ subject: !form.subject ? i18n.str`Required` : undefined,
+ amount: !form.amount
+ ? i18n.str`Required`
+ : !inputAmount
+ ? i18n.str`Invalid`
+ : Amounts.cmp(limit, calc.debit) === -1
+ ? i18n.str`Balance is not enough`
+ : calculationResult === "amount-is-too-small"
+ ? i18n.str`Amount needs to be higher`
+ : Amounts.cmp(calc.debit, conversionInfo.cashout_min_amount) < 0
+ ? i18n.str`No account can't cashout less than ${
+ Amounts.stringifyValueWithSpec(
+ Amounts.parseOrThrow(conversionInfo.cashout_min_amount),
+ regional_currency_specification,
+ ).normal
+ }`
+ : Amounts.cmp(calc.debit, account.minCashout) < 0
+ ? i18n.str`Your account can't cashout less than ${
+ Amounts.stringifyValueWithSpec(
+ Amounts.parseOrThrow(account.minCashout),
+ regional_currency_specification,
+ ).normal
+ }`
+
+ : Amounts.isZero(calc.credit)
+ ? i18n.str`The total transfer at destination will be zero`
+ : undefined,
+ });
+ const trimmedAmountStr = form.amount?.trim();
+
+ async function createCashout() {
+ const request_uid = encodeCrock(getRandomBytes(32));
+ await handleError(async () => {
+ if (!creds || !form.subject) return;
+ const request = {
+ request_uid,
+ amount_credit: Amounts.stringify(calc.credit),
+ amount_debit: Amounts.stringify(calc.debit),
+ subject: form.subject,
+ };
+ const resp = await api.createCashout(creds, request);
+ if (resp.type === "ok") {
+ notifyInfo(i18n.str`Cashout created`);
+ onCashout();
+ } else {
+ switch (resp.case) {
+ case HttpStatusCode.Accepted: {
+ updateBankState("currentChallenge", {
+ operation: "create-cashout",
+ id: String(resp.body.challenge_id),
+ sent: AbsoluteTime.never(),
+ location: routeHere.url({}),
+ request,
+ });
+ return onAuthorizationRequired();
+ }
+ case HttpStatusCode.NotFound:
+ return notify({
+ type: "error",
+ title: i18n.str`Account not found`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED:
+ return notify({
+ type: "error",
+ title: i18n.str`Duplicated request detected, check if the operation succeeded or try again.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_BAD_CONVERSION:
+ return notify({
+ type: "error",
+ title: i18n.str`The conversion rate was incorrectly applied`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_UNALLOWED_DEBIT:
+ return notify({
+ type: "error",
+ title: i18n.str`The account does not have sufficient funds`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case HttpStatusCode.NotImplemented:
+ return notify({
+ type: "error",
+ title: i18n.str`Cashout are disabled`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONFIRM_INCOMPLETE:
+ return notify({
+ type: "error",
+ title: i18n.str`Missing cashout URI in the profile`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ case TalerErrorCode.BANK_CONVERSION_AMOUNT_TO_SMALL:
+ return notify({
+ type: "error",
+ title: i18n.str`The amount is less than the minimum allowed.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+
+ case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED:
+ return notify({
+ type: "error",
+ title: i18n.str`Sending the confirmation message failed, retry later or contact the administrator.`,
+ description: resp.detail.hint as TranslatedString,
+ debug: resp.detail,
+ when: AbsoluteTime.now(),
+ });
+ }
+ assertUnreachable(resp);
+ }
+ });
+ }
+ const cashoutDisabled =
+ config.supported_tan_channels.length < 1 ||
+ !resultAccount.body.cashout_payto_uri;
+
+ const cashoutAccount = !resultAccount.body.cashout_payto_uri
+ ? undefined
+ : parsePaytoUri(resultAccount.body.cashout_payto_uri);
+ const cashoutAccountName = !cashoutAccount
+ ? undefined
+ : cashoutAccount.targetPath;
+
+ const cashoutLegalName = !cashoutAccount
+ ? undefined
+ : cashoutAccount.params["receiver-name"];
+
+ return (
+ <div>
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <section class="mt-4 rounded-sm px-4 py-6 p-8 ">
+ <h2 id="summary-heading" class="font-medium text-lg">
+ <i18n.Translate>Cashout</i18n.Translate>
+ </h2>
+
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Conversion rate</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">{sellRate}</dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Balance</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={account.balance}
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Fee</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={sellFee}
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ {cashoutAccountName && cashoutLegalName ? (
+ <Fragment>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>To account</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">{cashoutAccountName}</dd>
+ </div>
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Legal name</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">{cashoutLegalName}</dd>
+ </div>
+ <p class="mt-2 text-sm text-gray-500">
+ <i18n.Translate>
+ If this name doesn't match the account holder's name your
+ transaction may fail.
+ </i18n.Translate>
+ </p>
+ </Fragment>
+ ) : (
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <Attention type="warning" title={i18n.str`No cashout account`}>
+ <i18n.Translate>
+ Before doing a cashout you need to complete your profile
+ </i18n.Translate>
+ </Attention>
+ </div>
+ )}
+ </dl>
+ </section>
+ <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">
+ {/* subject */}
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="subject"
+ >
+ {i18n.str`Transfer subject`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ <div class="mt-2">
+ <input
+ ref={focus ? doAutoFocus : undefined}
+ type="text"
+ class="block w-full rounded-md disabled:bg-gray-200 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="subject"
+ id="subject"
+ disabled={cashoutDisabled}
+ data-error={!!errors?.subject && form.subject !== undefined}
+ value={form.subject ?? ""}
+ onChange={(e) => {
+ form.subject = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ autocomplete="off"
+ />
+ <ShowInputErrorLabel
+ message={errors?.subject}
+ isDirty={form.subject !== undefined}
+ />
+ </div>
+ </div>
+
+ <div class="sm:col-span-5">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="subject"
+ >
+ {i18n.str`Currency`}
+ </label>
+
+ <div class="mt-2">
+ <button
+ type="button"
+ name="set 50"
+ class=" inline-flex p-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();
+ form.isDebit = true;
+ updateForm(structuredClone(form));
+ }}
+ >
+ {form.isDebit ? (
+ <svg
+ class="self-center flex-none 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>
+ ) : (
+ <svg
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5"
+ >
+ <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ </svg>
+ )}
+
+ <i18n.Translate>Send {regional_currency}</i18n.Translate>
+ </button>
+ <button
+ type="button"
+ name="set 25"
+ class=" -ml-px -mr-px inline-flex p-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();
+ form.isDebit = false;
+ updateForm(structuredClone(form));
+ }}
+ >
+ {!form.isDebit ? (
+ <svg
+ class="self-center flex-none 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>
+ ) : (
+ <svg
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-5 h-5"
+ >
+ <path d="M15 12H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
+ </svg>
+ )}
+
+ <i18n.Translate>Receive {fiat_currency}</i18n.Translate>
+ </button>
+ </div>
+ </div>
+
+ {/* amount */}
+ <div class="sm:col-span-5">
+ <div class="flex justify-between">
+ <label
+ class="block text-sm font-medium leading-6 text-gray-900"
+ for="amount"
+ >
+ {i18n.str`Amount`}
+ <b style={{ color: "red" }}> *</b>
+ </label>
+ {/* <button
+ type="button"
+ data-enabled={form.isDebit}
+ class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
+ role="switch"
+ aria-checked="false"
+ aria-labelledby="availability-label"
+ aria-describedby="availability-description"
+ onClick={() => {
+ form.isDebit = !form.isDebit;
+ updateForm(structuredClone(form));
+ }}
+ >
+ <span
+ aria-hidden="true"
+ data-enabled={form.isDebit}
+ 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 class="mt-2">
+ <InputAmount
+ name="amount"
+ left
+ currency={form.isDebit ? regional_currency : fiat_currency}
+ value={trimmedAmountStr}
+ onChange={
+ cashoutDisabled
+ ? undefined
+ : (value) => {
+ form.amount = value;
+ updateForm(structuredClone(form));
+ }
+ }
+ />
+ <ShowInputErrorLabel
+ message={errors?.amount}
+ isDirty={form.amount !== undefined}
+ />
+ </div>
+ </div>
+
+ {Amounts.isZero(calc.credit) ? undefined : (
+ <div class="sm:col-span-5">
+ <dl class="mt-4 space-y-4">
+ <div class="justify-between items-center flex ">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Total cost</i18n.Translate>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.debit}
+ negative
+ withColor
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Balance left</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={balanceAfter}
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+ {Amounts.isZero(sellFee) ||
+ Amounts.isZero(calc.beforeFee) ? undefined : (
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-sm text-gray-600">
+ <span>
+ <i18n.Translate>Before fee</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm text-gray-900">
+ <RenderAmount
+ value={calc.beforeFee}
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ )}
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-lg text-gray-900 font-medium">
+ <i18n.Translate>Total cashout transfer</i18n.Translate>
+ </dt>
+ <dd class="text-lg text-gray-900 font-medium">
+ <RenderAmount
+ value={calc.credit}
+ withColor
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8">
+ <a
+ href={routeClose.url({})}
+ name="cancel"
+ type="button"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </a>
+ <button
+ type="submit"
+ name="cashout"
+ 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();
+ createCashout();
+ }}
+ >
+ <i18n.Translate>Cashout</i18n.Translate>
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx
new file mode 100644
index 000000000..aba00ad7a
--- /dev/null
+++ b/packages/bank-ui/src/pages/regional/ShowCashoutDetails.tsx
@@ -0,0 +1,194 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AbsoluteTime,
+ Amounts,
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js";
+import { Time } from "../../components/Time.js";
+import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js";
+import { RouteDefinition } from "@gnu-taler/web-util/browser";
+import { RenderAmount } from "../PaytoWireTransferForm.js";
+
+interface Props {
+ id: string;
+ routeClose: RouteDefinition;
+}
+export function ShowCashoutDetails({ id, routeClose }: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const cid = Number.parseInt(id, 10);
+
+ const result = useCashoutDetails(Number.isNaN(cid) ? undefined : cid);
+ const info = useConversionInfo();
+
+ if (Number.isNaN(cid)) {
+ return (
+ <Attention
+ type="danger"
+ title={i18n.str`Cashout id should be a number`}
+ />
+ );
+ }
+ if (!result) {
+ return <Loading />;
+ }
+ if (result instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={result} />;
+ }
+ if (result.type === "fail") {
+ switch (result.case) {
+ case HttpStatusCode.NotFound:
+ return (
+ <Attention
+ type="warning"
+ title={i18n.str`This cashout not found. Maybe already aborted.`}
+ ></Attention>
+ );
+ case HttpStatusCode.NotImplemented:
+ return (
+ <Attention type="warning" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ default:
+ assertUnreachable(result);
+ }
+ }
+ if (!info) {
+ return <Loading />;
+ }
+
+ if (info instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={info} />;
+ }
+ if (info.type === "fail") {
+ switch (info.case) {
+ case HttpStatusCode.NotImplemented: {
+ return (
+ <Attention type="danger" title={i18n.str`Cashout are disabled`}>
+ <i18n.Translate>
+ Cashout should be enable by configuration and the conversion rate
+ should be initialized with fee, ratio and rounding mode.
+ </i18n.Translate>
+ </Attention>
+ );
+ }
+ default:
+ assertUnreachable(info.case);
+ }
+ }
+
+ const { fiat_currency_specification, regional_currency_specification } =
+ info.body;
+
+ return (
+ <div>
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-6 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg">
+ <section class="rounded-sm px-4">
+ <h2 id="summary-heading" class="font-medium text-lg">
+ <i18n.Translate>Cashout detail</i18n.Translate>
+ </h2>
+ <dl class="mt-8 space-y-4">
+ <div class="justify-between items-center flex">
+ <dt class="text-sm text-gray-600">
+ <i18n.Translate>Subject</i18n.Translate>
+ </dt>
+ <dd class="text-sm ">{result.body.subject}</dd>
+ </div>
+ </dl>
+ </section>
+ <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
+ <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">
+ <dl class="space-y-4">
+ {result.body.creation_time.t_s !== "never" ? (
+ <div class="justify-between items-center flex ">
+ <dt class=" text-gray-600">
+ <i18n.Translate>Created</i18n.Translate>
+ </dt>
+ <dd class="text-sm ">
+ <Time
+ format="dd/MM/yyyy HH:mm:ss"
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ result.body.creation_time,
+ )}
+ // relative={Duration.fromSpec({ days: 1 })}
+ />
+ </dd>
+ </div>
+ ) : undefined}
+
+ <div class="flex justify-between items-center border-t-2 afu pt-4">
+ <dt class="text-gray-600">
+ <i18n.Translate>Debited</i18n.Translate>
+ </dt>
+ <dd class=" font-medium">
+ <RenderAmount
+ value={Amounts.parseOrThrow(result.body.amount_debit)}
+ negative
+ withColor
+ spec={regional_currency_specification}
+ />
+ </dd>
+ </div>
+
+ <div class="flex items-center justify-between border-t-2 afu pt-4">
+ <dt class="flex items-center text-gray-600">
+ <span>
+ <i18n.Translate>Credited</i18n.Translate>
+ </span>
+ </dt>
+ <dd class="text-sm ">
+ <RenderAmount
+ value={Amounts.parseOrThrow(result.body.amount_credit)}
+ withColor
+ spec={fiat_currency_specification}
+ />
+ </dd>
+ </div>
+ </dl>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <a
+ href={routeClose.url({})}
+ name="close"
+ class="text-sm font-semibold leading-6 text-gray-900"
+ >
+ <i18n.Translate>Close</i18n.Translate>
+ </a>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/bank-ui/src/pages/rnd.ts b/packages/bank-ui/src/pages/rnd.ts
new file mode 100644
index 000000000..d66fb005b
--- /dev/null
+++ b/packages/bank-ui/src/pages/rnd.ts
@@ -0,0 +1,2907 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util";
+
+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",
+ "weed",
+ "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 {
+ return encodeCrock(getRandomBytes(16));
+}