commit 062939d9cc016a186a282f7a48492c3e01cd740c
parent b3c747151bb3f50d28bf6205cafa4b7dd6ae2b1c
Author: Sebastian <sebasjm@gmail.com>
Date: Thu, 21 Sep 2023 10:31:10 -0300
admin refactor
Diffstat:
15 files changed, 1830 insertions(+), 1823 deletions(-)
diff --git a/packages/demobank-ui/src/components/Routing.tsx b/packages/demobank-ui/src/components/Routing.tsx
@@ -19,14 +19,14 @@ import { VNode, h } from "preact";
import { Route, Router, route } from "preact-router";
import { useEffect } from "preact/hooks";
import { BankFrame } from "../pages/BankFrame.js";
-import { BusinessAccount } from "../pages/BusinessAccount.js";
+import { BusinessAccount } from "../pages/business/Home.js";
import { HomePage, WithdrawalOperationPage } from "../pages/HomePage.js";
import { PublicHistoriesPage } from "../pages/PublicHistoriesPage.js";
import { RegistrationPage } from "../pages/RegistrationPage.js";
import { Test } from "../pages/Test.js";
import { useBackendContext } from "../context/backend.js";
import { LoginForm } from "../pages/LoginForm.js";
-import { AdminPage } from "../pages/AdminPage.js";
+import { AdminHome } from "../pages/admin/Home.js";
export function Routing(): VNode {
const history = createHashHistory();
@@ -34,6 +34,7 @@ export function Routing(): VNode {
if (backend.state.status === "loggedOut") {
return <BankFrame
+ account={undefined}
goToBusinessAccount={() => {
route("/business");
}}
@@ -63,7 +64,7 @@ export function Routing(): VNode {
</Router>
</BankFrame>
}
- const isAdmin = backend.state.isUserAdministrator
+ const { isUserAdministrator, username } = backend.state
return (
<BankFrame
@@ -108,14 +109,15 @@ export function Routing(): VNode {
<Route
path="/account"
component={() => {
- if (isAdmin) {
- return <AdminPage
+ if (isUserAdministrator) {
+ return <AdminHome
onRegister={() => {
route("/register");
}}
/>;
} else {
return <HomePage
+ account={username}
onPendingOperationFound={(wopid) => {
route(`/operation/${wopid}`);
}}
@@ -130,6 +132,7 @@ export function Routing(): VNode {
path="/business"
component={() => (
<BusinessAccount
+ account={username}
onClose={() => {
route("/account");
}}
diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx
@@ -1,1042 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-
-import { Amounts, HttpStatusCode, TranslatedString, parsePaytoUri } from "@gnu-taler/taler-util";
-import {
- ErrorType,
- HttpResponsePaginated,
- RequestError,
- notify,
- notifyError,
- notifyInfo,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { Cashouts } from "../components/Cashouts/index.js";
-import { useBackendContext } from "../context/backend.js";
-import { useAccountDetails } from "../hooks/access.js";
-import {
- useAdminAccountAPI,
- useBusinessAccountDetails,
- useBusinessAccounts,
-} from "../hooks/circuit.js";
-import {
- buildRequestErrorMessage,
- PartialButDefined,
- RecursivePartial,
- undefinedIfEmpty,
- validateIBAN,
- WithIntermediate,
-} from "../utils.js";
-import { ShowCashoutDetails } from "./BusinessAccount.js";
-import { handleNotOkResult } from "./HomePage.js";
-import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
-import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
-
-const charset =
- "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
-const upperIdx = charset.indexOf("A");
-
-function randomPassword(): string {
- const random = Array.from({ length: 16 }).map(() => {
- return charset.charCodeAt(Math.random() * charset.length);
- });
- // first char can't be upper
- const charIdx = charset.indexOf(String.fromCharCode(random[0]));
- random[0] =
- charIdx > upperIdx ? charset.charCodeAt(charIdx - upperIdx) : random[0];
- return String.fromCharCode(...random);
-}
-
-interface Props {
- onRegister: () => void;
-}
-/**
- * Query account information and show QR code if there is pending withdrawal
- */
-export function AdminPage({ onRegister }: Props): VNode {
- const [account, setAccount] = useState<string | undefined>();
- const [showDetails, setShowDetails] = useState<string | undefined>();
- const [showCashouts, setShowCashouts] = useState<string | undefined>();
- const [updatePassword, setUpdatePassword] = useState<string | undefined>();
- const [removeAccount, setRemoveAccount] = useState<string | undefined>();
- const [showCashoutDetails, setShowCashoutDetails] = useState<
- string | undefined
- >();
-
- const [createAccount, setCreateAccount] = useState(false);
-
- const result = useBusinessAccounts({ account });
- const { i18n } = useTranslationContext();
-
- if (result.loading) return <div />;
- if (!result.ok) {
- return handleNotOkResult(i18n, onRegister)(result);
- }
-
- const { customers } = result.data;
-
- if (showCashoutDetails) {
- return (
- <ShowCashoutDetails
- id={showCashoutDetails}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onCancel={() => {
- setShowCashoutDetails(undefined);
- }}
- />
- );
- }
-
- if (showCashouts) {
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Cashout for account {showCashouts}</i18n.Translate>
- </h1>
- </div>
- <Cashouts
- account={showCashouts}
- onSelected={(id) => {
- setShowCashouts(id);
- setShowCashouts(undefined);
- }}
- />
- <p>
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Close`}
- onClick={async (e) => {
- e.preventDefault();
- setShowCashouts(undefined);
- }}
- />
- </p>
- </div>
- );
- }
-
- if (showDetails) {
- return (
- <ShowAccountDetails
- account={showDetails}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onChangePassword={() => {
- setUpdatePassword(showDetails);
- setShowDetails(undefined);
- }}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Account updated`);
- setShowDetails(undefined);
- }}
- onClear={() => {
- setShowDetails(undefined);
- }}
- />
- );
- }
- if (removeAccount) {
- return (
- <RemoveAccount
- account={removeAccount}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Account removed`);
- setRemoveAccount(undefined);
- }}
- onClear={() => {
- setRemoveAccount(undefined);
- }}
- />
- );
- }
- if (updatePassword) {
- return (
- <UpdateAccountPassword
- account={updatePassword}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Password changed`);
- setUpdatePassword(undefined);
- }}
- onClear={() => {
- setUpdatePassword(undefined);
- }}
- />
- );
- }
- if (createAccount) {
- return (
- <CreateNewAccount
- onClose={() => setCreateAccount(false)}
- onCreateSuccess={(password) => {
- notifyInfo(
- i18n.str`Account created with password "${password}". The user must change the password on the next login.`,
- );
- setCreateAccount(false);
- }}
- />
- );
- }
-
- return (
- <Fragment>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Admin panel</i18n.Translate>
- </h1>
- </div>
-
- <p>
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <div></div>
- <div>
- <input
- class="pure-button pure-button-primary content"
- type="submit"
- value={i18n.str`Create account`}
- onClick={async (e) => {
- e.preventDefault();
-
- setCreateAccount(true);
- }}
- />
- </div>
- </div>
- </p>
-
- <AdminAccount onRegister={onRegister} />
- <section
- id="main"
- style={{ width: 600, marginLeft: "auto", marginRight: "auto" }}
- >
- {!customers.length ? (
- <div></div>
- ) : (
- <article>
- <h2>{i18n.str`Accounts:`}</h2>
- <div class="results">
- <table class="pure-table pure-table-striped">
- <thead>
- <tr>
- <th>{i18n.str`Username`}</th>
- <th>{i18n.str`Name`}</th>
- <th>{i18n.str`Balance`}</th>
- <th>{i18n.str`Actions`}</th>
- </tr>
- </thead>
- <tbody>
- {customers.map((item, idx) => {
- const balance = !item.balance
- ? undefined
- : Amounts.parse(item.balance.amount);
- const balanceIsDebit =
- item.balance &&
- item.balance.credit_debit_indicator == "debit";
- return (
- <tr key={idx}>
- <td>
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setShowDetails(item.username);
- }}
- >
- {item.username}
- </a>
- </td>
- <td>{item.name}</td>
- <td>
- {!balance ? (
- i18n.str`unknown`
- ) : (
- <span class="amount">
- {balanceIsDebit ? <b>-</b> : null}
- <span class="value">{`${Amounts.stringifyValue(
- balance,
- )}`}</span>
-
- <span class="currency">{`${balance.currency}`}</span>
- </span>
- )}
- </td>
- <td>
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setUpdatePassword(item.username);
- }}
- >
- change password
- </a>
-
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setShowCashouts(item.username);
- }}
- >
- cashouts
- </a>
-
- <a
- href="#"
- onClick={(e) => {
- e.preventDefault();
- setRemoveAccount(item.username);
- }}
- >
- remove
- </a>
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
- </article>
- )}
- </section>
- </Fragment>
- );
-}
-
-function AdminAccount({ onRegister }: { onRegister: () => void }): VNode {
- const { i18n } = useTranslationContext();
- const r = useBackendContext();
- const account = r.state.status === "loggedIn" ? r.state.username : "admin";
- const result = useAccountDetails(account);
-
- if (!result.ok) {
- return handleNotOkResult(i18n, onRegister)(result);
- }
- const { data } = result;
- const balance = Amounts.parseOrThrow(data.balance.amount);
- const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
- const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
- const limit = balanceIsDebit
- ? Amounts.sub(debitThreshold, balance).amount
- : Amounts.add(balance, debitThreshold).amount;
- if (!balance) return <Fragment />;
- return (
- <Fragment>
- <section id="assets">
- <div class="asset-summary">
- <h2>{i18n.str`Bank account balance`}</h2>
- {!balance ? (
- <div class="large-amount" style={{ color: "gray" }}>
- Waiting server response...
- </div>
- ) : (
- <div class="large-amount amount">
- {balanceIsDebit ? <b>-</b> : null}
- <span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
-
- <span class="currency">{`${balance.currency}`}</span>
- </div>
- )}
- </div>
- </section>
- <PaytoWireTransferForm
- focus
- limit={limit}
- onSuccess={() => {
- notifyInfo(i18n.str`Wire transfer created!`);
- }}
- onCancel={undefined}
- />
- </Fragment>
- );
-}
-
-const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
-const EMAIL_REGEX =
- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
-
-function initializeFromTemplate(
- account: SandboxBackend.Circuit.CircuitAccountData | undefined,
-): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> {
- const emptyAccount = {
- cashout_address: undefined,
- iban: undefined,
- name: undefined,
- username: undefined,
- contact_data: undefined,
- };
- const emptyContact = {
- email: undefined,
- phone: undefined,
- };
-
- const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> =
- structuredClone(account) ?? emptyAccount;
- if (typeof initial.contact_data === "undefined") {
- initial.contact_data = emptyContact;
- }
- initial.contact_data.email;
- return initial as any;
-}
-
-export function UpdateAccountPassword({
- account,
- onClear,
- onUpdateSuccess,
- onLoadNotOk,
-}: {
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
- onClear: () => void;
- onUpdateSuccess: () => void;
- account: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useBusinessAccountDetails(account);
- const { changePassword } = useAdminAccountAPI();
- const [password, setPassword] = useState<string | undefined>();
- const [repeat, setRepeat] = useState<string | undefined>();
-
- if (!result.ok) {
- if (result.loading || result.type === ErrorType.TIMEOUT) {
- return onLoadNotOk(result);
- }
- if (result.status === HttpStatusCode.NotFound) {
- return <div>account not found</div>;
- }
- return onLoadNotOk(result);
- }
-
- const errors = undefinedIfEmpty({
- password: !password ? i18n.str`required` : undefined,
- repeat: !repeat
- ? i18n.str`required`
- : password !== repeat
- ? i18n.str`password doesn't match`
- : undefined,
- });
-
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Update password for {account}</i18n.Translate>
- </h1>
- </div>
-
- <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
- <form class="pure-form">
- <fieldset>
- <label>{i18n.str`Password`}</label>
- <input
- type="password"
- value={password ?? ""}
- onChange={(e) => {
- setPassword(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.password}
- isDirty={password !== undefined}
- />
- </fieldset>
- <fieldset>
- <label>{i18n.str`Repeat password`}</label>
- <input
- type="password"
- value={repeat ?? ""}
- onChange={(e) => {
- setRepeat(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.repeat}
- isDirty={repeat !== undefined}
- />
- </fieldset>
- </form>
- <p>
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <div>
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Close`}
- onClick={async (e) => {
- e.preventDefault();
- onClear();
- }}
- />
- </div>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={!!errors}
- type="submit"
- value={i18n.str`Confirm`}
- onClick={async (e) => {
- e.preventDefault();
- if (!!errors || !password) return;
- try {
- const r = await changePassword(account, {
- new_password: password,
- });
- onUpdateSuccess();
- } catch (error) {
- if (error instanceof RequestError) {
- notify(buildRequestErrorMessage(i18n, error.cause));
- } else {
- notifyError(i18n.str`Operation failed, please report`, (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString)
- }
- }
- }}
- />
- </div>
- </div>
- </p>
- </div>
- </div>
- );
-}
-
-function CreateNewAccount({
- onClose,
- onCreateSuccess,
-}: {
- onClose: () => void;
- onCreateSuccess: (password: string) => void;
-}): VNode {
- const { i18n } = useTranslationContext();
- const { createAccount } = useAdminAccountAPI();
- const [submitAccount, setSubmitAccount] = useState<
- SandboxBackend.Circuit.CircuitAccountData | undefined
- >();
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>New account</i18n.Translate>
- </h1>
- </div>
-
- <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
- <AccountForm
- template={undefined}
- purpose="create"
- onChange={(a) => {
- setSubmitAccount(a);
- }}
- />
-
- <p>
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <div>
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Close`}
- onClick={async (e) => {
- e.preventDefault();
- onClose();
- }}
- />
- </div>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={!submitAccount}
- type="submit"
- value={i18n.str`Confirm`}
- onClick={async (e) => {
- e.preventDefault();
-
- if (!submitAccount) return;
- try {
- const account: SandboxBackend.Circuit.CircuitAccountRequest =
- {
- cashout_address: submitAccount.cashout_address,
- contact_data: submitAccount.contact_data,
- internal_iban: submitAccount.iban,
- name: submitAccount.name,
- username: submitAccount.username,
- password: randomPassword(),
- };
-
- await createAccount(account);
- onCreateSuccess(account.password);
- } catch (error) {
- if (error instanceof RequestError) {
- notify(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Forbidden
- ? i18n.str`The rights to perform the operation are not sufficient`
- : status === HttpStatusCode.BadRequest
- ? i18n.str`Input data was invalid`
- : status === HttpStatusCode.Conflict
- ? i18n.str`At least one registration detail was not available`
- : undefined,
- }),
- );
- } else {
- notifyError(
- i18n.str`Operation failed, please report`,
- (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString
- )
- }
- }
- }}
- />
- </div>
- </div>
- </p>
- </div>
- </div>
- );
-}
-
-export function ShowAccountDetails({
- account,
- onClear,
- onUpdateSuccess,
- onLoadNotOk,
- onChangePassword,
-}: {
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
- onClear?: () => void;
- onChangePassword: () => void;
- onUpdateSuccess: () => void;
- account: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useBusinessAccountDetails(account);
- const { updateAccount } = useAdminAccountAPI();
- const [update, setUpdate] = useState(false);
- const [submitAccount, setSubmitAccount] = useState<
- SandboxBackend.Circuit.CircuitAccountData | undefined
- >();
-
- if (!result.ok) {
- if (result.loading || result.type === ErrorType.TIMEOUT) {
- return onLoadNotOk(result);
- }
- if (result.status === HttpStatusCode.NotFound) {
- return <div>account not found</div>;
- }
- return onLoadNotOk(result);
- }
-
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Business account details</i18n.Translate>
- </h1>
- </div>
- <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
- <AccountForm
- template={result.data}
- purpose={update ? "update" : "show"}
- onChange={(a) => setSubmitAccount(a)}
- />
-
- <p class="buttons-account">
- <div
- style={{
- display: "flex",
- justifyContent: "space-between",
- flexFlow: "wrap-reverse",
- }}
- >
- <div>
- {onClear ? (
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Close`}
- onClick={async (e) => {
- e.preventDefault();
- onClear();
- }}
- />
- ) : undefined}
- </div>
- <div style={{ display: "flex" }}>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={update && !submitAccount}
- type="submit"
- value={i18n.str`Change password`}
- onClick={async (e) => {
- e.preventDefault();
- onChangePassword();
- }}
- />
- </div>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={update && !submitAccount}
- type="submit"
- value={update ? i18n.str`Confirm` : i18n.str`Update`}
- onClick={async (e) => {
- e.preventDefault();
-
- if (!update) {
- setUpdate(true);
- } else {
- if (!submitAccount) return;
- try {
- await updateAccount(account, {
- cashout_address: submitAccount.cashout_address,
- contact_data: submitAccount.contact_data,
- });
- onUpdateSuccess();
- } catch (error) {
- if (error instanceof RequestError) {
- notify(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Forbidden
- ? i18n.str`The rights to change the account are not sufficient`
- : status === HttpStatusCode.NotFound
- ? i18n.str`The username was not found`
- : undefined,
- }),
- );
- } else {
- notifyError(
- i18n.str`Operation failed, please report`,
- (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString
- )
- }
- }
- }
- }}
- />
- </div>
- </div>
- </div>
- </p>
- </div>
- </div>
- );
-}
-
-function RemoveAccount({
- account,
- onClear,
- onUpdateSuccess,
- onLoadNotOk,
-}: {
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
- onClear: () => void;
- onUpdateSuccess: () => void;
- account: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const result = useAccountDetails(account);
- const { deleteAccount } = useAdminAccountAPI();
-
- if (!result.ok) {
- if (result.loading || result.type === ErrorType.TIMEOUT) {
- return onLoadNotOk(result);
- }
- if (result.status === HttpStatusCode.NotFound) {
- return <div>account not found</div>;
- }
- return onLoadNotOk(result);
- }
-
- const balance = Amounts.parse(result.data.balance.amount);
- if (!balance) {
- return <div>there was an error reading the balance</div>;
- }
- const isBalanceEmpty = Amounts.isZero(balance);
- return (
- <div>
- <div>
- <h1 class="nav welcome-text">
- <i18n.Translate>Remove account: {account}</i18n.Translate>
- </h1>
- </div>
- {/* {FXME: SHOW WARNING} */}
- {/* {!isBalanceEmpty && (
- <ErrorBannerFloat
- error={{
- title: i18n.str`Can't delete the account`,
- description: i18n.str`Balance is not empty`,
- }}
- onClear={() => saveError(undefined)}
- />
- )} */}
-
- <p>
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <div>
- <input
- class="pure-button"
- type="submit"
- value={i18n.str`Cancel`}
- onClick={async (e) => {
- e.preventDefault();
- onClear();
- }}
- />
- </div>
- <div>
- <input
- id="select-exchange"
- class="pure-button pure-button-primary content"
- disabled={!isBalanceEmpty}
- type="submit"
- value={i18n.str`Confirm`}
- onClick={async (e) => {
- e.preventDefault();
- try {
- const r = await deleteAccount(account);
- onUpdateSuccess();
- } catch (error) {
- if (error instanceof RequestError) {
- notify(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.Forbidden
- ? i18n.str`The administrator specified a institutional username`
- : status === HttpStatusCode.NotFound
- ? i18n.str`The username was not found`
- : status === HttpStatusCode.PreconditionFailed
- ? i18n.str`Balance was not zero`
- : undefined,
- }),
- );
- } else {
- notifyError(i18n.str`Operation failed, please report`,
- (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString);
- }
- }
- }}
- />
- </div>
- </div>
- </p>
- </div>
- );
-}
-/**
- * Create valid account object to update or create
- * Take template as initial values for the form
- * Purpose indicate if all field al read only (show), part of them (update)
- * or none (create)
- * @param param0
- * @returns
- */
-function AccountForm({
- template,
- purpose,
- onChange,
-}: {
- template: SandboxBackend.Circuit.CircuitAccountData | undefined;
- onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void;
- purpose: "create" | "update" | "show";
-}): VNode {
- const initial = initializeFromTemplate(template);
- const [form, setForm] = useState(initial);
- const [errors, setErrors] = useState<
- RecursivePartial<typeof initial> | undefined
- >(undefined);
- const { i18n } = useTranslationContext();
-
- function updateForm(newForm: typeof initial): void {
- const parsed = !newForm.cashout_address
- ? undefined
- : parsePaytoUri(newForm.cashout_address);
-
- const errors = undefinedIfEmpty<RecursivePartial<typeof initial>>({
- cashout_address: !newForm.cashout_address
- ? i18n.str`required`
- : !parsed
- ? i18n.str`does not follow the pattern`
- : !parsed.isKnown || parsed.targetType !== "iban"
- ? i18n.str`only "IBAN" target are supported`
- : !IBAN_REGEX.test(parsed.iban)
- ? i18n.str`IBAN should have just uppercased letters and numbers`
- : validateIBAN(parsed.iban, i18n),
- contact_data: undefinedIfEmpty({
- email: !newForm.contact_data?.email
- ? i18n.str`required`
- : !EMAIL_REGEX.test(newForm.contact_data.email)
- ? i18n.str`it should be an email`
- : undefined,
- phone: !newForm.contact_data?.phone
- ? i18n.str`required`
- : !newForm.contact_data.phone.startsWith("+")
- ? i18n.str`should start with +`
- : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone)
- ? i18n.str`phone number can't have other than numbers`
- : undefined,
- }),
- iban: !newForm.iban
- ? undefined //optional field
- : !IBAN_REGEX.test(newForm.iban)
- ? i18n.str`IBAN should have just uppercased letters and numbers`
- : validateIBAN(newForm.iban, i18n),
- name: !newForm.name ? i18n.str`required` : undefined,
- username: !newForm.username ? i18n.str`required` : undefined,
- });
- setErrors(errors);
- setForm(newForm);
- onChange(errors === undefined ? (newForm as any) : undefined);
- }
-
- return (
- <form class="pure-form">
- <fieldset>
- <label for="username">
- {i18n.str`Username`}
- {purpose === "create" && <b style={{ color: "red" }}>*</b>}
- </label>
- <input
- name="username"
- type="text"
- disabled={purpose !== "create"}
- value={form.username}
- onChange={(e) => {
- form.username = e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />{" "}
- <ShowInputErrorLabel
- message={errors?.username}
- isDirty={form.username !== undefined}
- />
- </fieldset>
- <fieldset>
- <label>
- {i18n.str`Name`}
- {purpose === "create" && <b style={{ color: "red" }}>*</b>}
- </label>
- <input
- disabled={purpose !== "create"}
- value={form.name ?? ""}
- onChange={(e) => {
- form.name = e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.name}
- isDirty={form.name !== undefined}
- />
- </fieldset>
- {purpose !== "create" && (
- <fieldset>
- <label>{i18n.str`Internal IBAN`}</label>
- <input
- disabled={true}
- value={form.iban ?? ""}
- onChange={(e) => {
- form.iban = e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.iban}
- isDirty={form.iban !== undefined}
- />
- </fieldset>
- )}
- <fieldset>
- <label>
- {i18n.str`Email`}
- {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
- </label>
- <input
- disabled={purpose === "show"}
- value={form.contact_data.email ?? ""}
- onChange={(e) => {
- form.contact_data.email = e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.contact_data?.email}
- isDirty={form.contact_data.email !== undefined}
- />
- </fieldset>
- <fieldset>
- <label>
- {i18n.str`Phone`}
- {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
- </label>
- <input
- disabled={purpose === "show"}
- value={form.contact_data.phone ?? ""}
- onChange={(e) => {
- form.contact_data.phone = e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.contact_data?.phone}
- isDirty={form.contact_data?.phone !== undefined}
- />
- </fieldset>
- <fieldset>
- <label>
- {i18n.str`Cashout address`}
- {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
- </label>
- <input
- disabled={purpose === "show"}
- value={(form.cashout_address ?? "").substring("payto://iban/".length)}
- onChange={(e) => {
- form.cashout_address = "payto://iban/" + e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.cashout_address}
- isDirty={form.cashout_address !== undefined}
- />
- </fieldset>
- </form>
- );
-}
diff --git a/packages/demobank-ui/src/pages/BusinessAccount.tsx b/packages/demobank-ui/src/pages/BusinessAccount.tsx
@@ -1,758 +0,0 @@
-/*
- This file is part of GNU Taler
- (C) 2022 Taler Systems S.A.
-
- GNU Taler is free software; you can redistribute it and/or modify it under the
- terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3, or (at your option) any later version.
-
- GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
- A PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
- */
-import {
- AmountJson,
- Amounts,
- HttpStatusCode,
- TranslatedString
-} from "@gnu-taler/taler-util";
-import {
- HttpResponse,
- HttpResponsePaginated,
- RequestError,
- notify,
- notifyError,
- notifyInfo,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
-import { useEffect, useState } from "preact/hooks";
-import { Cashouts } from "../components/Cashouts/index.js";
-import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
-import { useBackendContext } from "../context/backend.js";
-import { useAccountDetails } from "../hooks/access.js";
-import {
- useCashoutDetails,
- useCircuitAccountAPI,
- useEstimator,
- useRatiosAndFeeConfig,
-} from "../hooks/circuit.js";
-import {
- TanChannel,
- buildRequestErrorMessage,
- undefinedIfEmpty,
-} from "../utils.js";
-import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js";
-import { handleNotOkResult } from "./HomePage.js";
-import { LoginForm } from "./LoginForm.js";
-import { Amount } from "./PaytoWireTransferForm.js";
-
-interface Props {
- onClose: () => void;
- onRegister: () => void;
- onLoadNotOk: () => void;
-}
-export function BusinessAccount({
- onClose,
- onLoadNotOk,
- onRegister,
-}: Props): VNode {
- const { i18n } = useTranslationContext();
- const backend = useBackendContext();
- const [updatePassword, setUpdatePassword] = useState(false);
- const [newCashout, setNewcashout] = useState(false);
- const [showCashoutDetails, setShowCashoutDetails] = useState<
- string | undefined
- >();
-
- if (backend.state.status === "loggedOut") {
- return <LoginForm onRegister={onRegister} />;
- }
-
- if (newCashout) {
- return (
- <CreateCashout
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onCancel={() => {
- setNewcashout(false);
- }}
- onComplete={(id) => {
- notifyInfo(
- i18n.str`Cashout created. You need to confirm the operation to complete the transaction.`,
- );
- setNewcashout(false);
- setShowCashoutDetails(id);
- }}
- />
- );
- }
- if (showCashoutDetails) {
- return (
- <ShowCashoutDetails
- id={showCashoutDetails}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onCancel={() => {
- setShowCashoutDetails(undefined);
- }}
- />
- );
- }
- if (updatePassword) {
- return (
- <UpdateAccountPassword
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Password changed`);
- setUpdatePassword(false);
- }}
- onClear={() => {
- setUpdatePassword(false);
- }}
- />
- );
- }
- return (
- <div>
- <ShowAccountDetails
- account={backend.state.username}
- onLoadNotOk={handleNotOkResult(i18n, onRegister)}
- onUpdateSuccess={() => {
- notifyInfo(i18n.str`Account updated`);
- }}
- onChangePassword={() => {
- setUpdatePassword(true);
- }}
- onClear={onClose}
- />
- <section style={{ marginTop: "2em" }}>
- <div class="active">
- <h3>{i18n.str`Latest cashouts`}</h3>
- <Cashouts
- account={backend.state.username}
- onSelected={(id) => {
- setShowCashoutDetails(id);
- }}
- />
- </div>
- <br />
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <div />
- <input
- class="pure-button pure-button-primary content"
- type="submit"
- value={i18n.str`New cashout`}
- onClick={async (e) => {
- e.preventDefault();
- setNewcashout(true);
- }}
- />
- </div>
- </section>
- </div>
- );
-}
-
-interface PropsCashout {
- account: string;
- onComplete: (id: string) => void;
- onCancel: () => void;
- onLoadNotOk: <T>(
- error:
- | HttpResponsePaginated<T, SandboxBackend.SandboxError>
- | HttpResponse<T, SandboxBackend.SandboxError>,
- ) => VNode;
-}
-
-type FormType = {
- isDebit: boolean;
- amount: string;
- subject: string;
- channel: TanChannel;
-};
-type ErrorFrom<T> = {
- [P in keyof T]+?: string;
-};
-
-// check #7719
-function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse<
- SandboxBackend.Circuit.Config & { hasChanged?: boolean },
- SandboxBackend.SandboxError
-> {
- const result = useRatiosAndFeeConfig();
- const [oldResult, setOldResult] = useState<
- SandboxBackend.Circuit.Config | undefined
- >(undefined);
- const dataFromBackend = result.ok ? result.data : undefined;
- useEffect(() => {
- // save only the first result of /config to the backend
- if (!dataFromBackend || oldResult !== undefined) return;
- setOldResult(dataFromBackend);
- }, [dataFromBackend]);
-
- if (!result.ok) return result;
-
- const data = !oldResult ? result.data : oldResult;
- const hasChanged =
- oldResult &&
- (result.data.name !== oldResult.name ||
- result.data.version !== oldResult.version ||
- result.data.ratios_and_fees.buy_at_ratio !==
- oldResult.ratios_and_fees.buy_at_ratio ||
- result.data.ratios_and_fees.buy_in_fee !==
- oldResult.ratios_and_fees.buy_in_fee ||
- result.data.ratios_and_fees.sell_at_ratio !==
- oldResult.ratios_and_fees.sell_at_ratio ||
- result.data.ratios_and_fees.sell_out_fee !==
- oldResult.ratios_and_fees.sell_out_fee ||
- result.data.fiat_currency !== oldResult.fiat_currency);
-
- return {
- ...result,
- data: { ...data, hasChanged },
- };
-}
-
-function CreateCashout({
- account,
- onComplete,
- onCancel,
- onLoadNotOk,
-}: PropsCashout): VNode {
- const { i18n } = useTranslationContext();
- const ratiosResult = useRatiosAndFeeConfig();
- const result = useAccountDetails(account);
- const {
- estimateByCredit: calculateFromCredit,
- estimateByDebit: calculateFromDebit,
- } = useEstimator();
- const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
-
- const { createCashout } = useCircuitAccountAPI();
- if (!result.ok) return onLoadNotOk(result);
- if (!ratiosResult.ok) return onLoadNotOk(ratiosResult);
- const config = ratiosResult.data;
-
- const balance = Amounts.parseOrThrow(result.data.balance.amount);
- const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
- const zero = Amounts.zeroOfCurrency(balance.currency);
- const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
- const limit = balanceIsDebit
- ? Amounts.sub(debitThreshold, balance).amount
- : Amounts.add(balance, debitThreshold).amount;
-
- const zeroCalc = { debit: zero, credit: zero, beforeFee: zero };
- const [calc, setCalc] = useState(zeroCalc);
- const sellRate = config.ratios_and_fees.sell_at_ratio;
- const sellFee = !config.ratios_and_fees.sell_out_fee
- ? zero
- : Amounts.parseOrThrow(
- `${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
- );
- const fiatCurrency = config.fiat_currency;
-
- if (!sellRate || sellRate < 0) return <div>error rate</div>;
-
- const amount = Amounts.parseOrThrow(
- `${!form.isDebit ? fiatCurrency : balance.currency}:${
- !form.amount ? "0" : form.amount
- }`,
- );
-
- useEffect(() => {
- if (form.isDebit) {
- calculateFromDebit(amount, sellFee, sellRate)
- .then((r) => {
- setCalc(r);
- })
- .catch((error) => {
- notify(
- error instanceof RequestError
- ? buildRequestErrorMessage(i18n, error.cause)
- : {
- type: "error",
- title: i18n.str`Could not estimate the cashout`,
- description: error.message as TranslatedString
- },
- );
- });
- } else {
- calculateFromCredit(amount, sellFee, sellRate)
- .then((r) => {
- setCalc(r);
- })
- .catch((error) => {
- notify(
- error instanceof RequestError
- ? buildRequestErrorMessage(i18n, error.cause)
- : {
- type: "error",
- title: i18n.str`Could not estimate the cashout`,
- description: error.message,
- },
- );
- });
- }
- }, [form.amount, form.isDebit]);
-
- const balanceAfter = Amounts.sub(balance, calc.debit).amount;
-
- function updateForm(newForm: typeof form): void {
- setForm(newForm);
- }
- const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({
- amount: !form.amount
- ? i18n.str`required`
- : !amount
- ? i18n.str`could not be parsed`
- : Amounts.cmp(limit, calc.debit) === -1
- ? i18n.str`balance is not enough`
- : Amounts.cmp(calc.beforeFee, sellFee) === -1
- ? i18n.str`the total amount to transfer does not cover the fees`
- : Amounts.isZero(calc.credit)
- ? i18n.str`the total transfer at destination will be zero`
- : undefined,
- channel: !form.channel ? i18n.str`required` : undefined,
- });
-
- return (
- <div>
- <h1>New cashout</h1>
- <form class="pure-form">
- <fieldset>
- <label>{i18n.str`Subject`}</label>
- <input
- value={form.subject ?? ""}
- onChange={(e) => {
- form.subject = e.currentTarget.value;
- updateForm(structuredClone(form));
- }}
- />
- <ShowInputErrorLabel
- message={errors?.subject}
- isDirty={form.subject !== undefined}
- />
- </fieldset>
- <fieldset>
- <label for="amount">
- {form.isDebit
- ? i18n.str`Amount to send`
- : i18n.str`Amount to receive`}
-
- </label>
- <div style={{ display: "flex" }}>
- <Amount
- name="amount"
- currency={amount.currency}
- value={form.amount}
- onChange={(v) => {
- form.amount = v;
- updateForm(structuredClone(form));
- }}
- error={errors?.amount}
- />
- <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
- <input
- class="toggle-checkbox"
- type="checkbox"
- name="asd"
- onChange={(e): void => {
- console.log("asdasd", form.isDebit);
- form.isDebit = !form.isDebit;
- updateForm(structuredClone(form));
- }}
- />
- <div class="toggle-switch"></div>
- </label>
- </div>
- </fieldset>
- <fieldset>
- <label>{i18n.str`Conversion rate`}</label>
- <input value={sellRate} disabled />
- </fieldset>
- <fieldset>
- <label for="balance-now">{i18n.str`Balance now`}</label>
- <Amount
- name="banace-now"
- currency={balance.currency}
- value={Amounts.stringifyValue(balance)}
- />
- </fieldset>
- <fieldset>
- <label for="total-cost"
- style={{ fontWeight: "bold", color: "red" }}
- >{i18n.str`Total cost`}</label>
- <Amount
- name="total-cost"
- currency={balance.currency}
- value={Amounts.stringifyValue(calc.debit)}
- />
- </fieldset>
- <fieldset>
- <label for="balance-after">{i18n.str`Balance after`}</label>
- <Amount
- name="balance-after"
- currency={balance.currency}
- value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
- />
- </fieldset>{" "}
- {Amounts.isZero(sellFee) ? undefined : (
- <Fragment>
- <fieldset>
- <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label>
- <Amount
- name="amount-conversion"
- currency={fiatCurrency}
- value={Amounts.stringifyValue(calc.beforeFee)}
- />
- </fieldset>
-
- <fieldset>
- <label form="cashout-fee">{i18n.str`Cashout fee`}</label>
- <Amount
- name="cashout-fee"
- currency={fiatCurrency}
- value={Amounts.stringifyValue(sellFee)}
- />
- </fieldset>
- </Fragment>
- )}
- <fieldset>
- <label for="total"
- style={{ fontWeight: "bold", color: "green" }}
- >{i18n.str`Total cashout transfer`}</label>
- <Amount
- name="total"
- currency={fiatCurrency}
- value={Amounts.stringifyValue(calc.credit)}
- />
- </fieldset>
- <fieldset>
- <label>{i18n.str`Confirmation channel`}</label>
-
- <div class="channel">
- <input
- class={
- "pure-button content " +
- (form.channel === TanChannel.EMAIL
- ? "pure-button-primary"
- : "pure-button-secondary")
- }
- type="submit"
- value={i18n.str`Email`}
- onClick={async (e) => {
- e.preventDefault();
- form.channel = TanChannel.EMAIL;
- updateForm(structuredClone(form));
- }}
- />
- <input
- class={
- "pure-button content " +
- (form.channel === TanChannel.SMS
- ? "pure-button-primary"
- : "pure-button-secondary")
- }
- type="submit"
- value={i18n.str`SMS`}
- onClick={async (e) => {
- e.preventDefault();
- form.channel = TanChannel.SMS;
- updateForm(structuredClone(form));
- }}
- />
- <input
- class={
- "pure-button content " +
- (form.channel === TanChannel.FILE
- ? "pure-button-primary"
- : "pure-button-secondary")
- }
- type="submit"
- value={i18n.str`FILE`}
- onClick={async (e) => {
- e.preventDefault();
- form.channel = TanChannel.FILE;
- updateForm(structuredClone(form));
- }}
- />
- </div>
- <ShowInputErrorLabel
- message={errors?.channel}
- isDirty={form.channel !== undefined}
- />
- </fieldset>
- <br />
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <button
- class="pure-button pure-button-secondary btn-cancel"
- onClick={(e) => {
- e.preventDefault();
- onCancel();
- }}
- >
- {i18n.str`Cancel`}
- </button>
-
- <button
- class="pure-button pure-button-primary btn-register"
- type="submit"
- disabled={!!errors}
- onClick={async (e) => {
- e.preventDefault();
-
- if (errors) return;
- try {
- const res = await createCashout({
- amount_credit: Amounts.stringify(calc.credit),
- amount_debit: Amounts.stringify(calc.debit),
- subject: form.subject,
- tan_channel: form.channel,
- });
- onComplete(res.data.uuid);
- } catch (error) {
- if (error instanceof RequestError) {
- notify(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.BadRequest
- ? i18n.str`The exchange rate was incorrectly applied`
- : status === HttpStatusCode.Forbidden
- ? i18n.str`A institutional user tried the operation`
- : status === HttpStatusCode.Conflict
- ? i18n.str`Need a contact data where to send the TAN`
- : status === HttpStatusCode.PreconditionFailed
- ? i18n.str`The account does not have sufficient funds`
- : undefined,
- onServerError: (status) =>
- status === HttpStatusCode.ServiceUnavailable
- ? i18n.str`The bank does not support the TAN channel for this operation`
- : undefined,
- }),
- );
- } else {
- notifyError(
- i18n.str`Operation failed, please report`,
- (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString
- )
- }
- }
- }}
- >
- {i18n.str`Create`}
- </button>
- </div>
- </form>
- </div>
- );
-}
-
-interface ShowCashoutProps {
- id: string;
- onCancel: () => void;
- onLoadNotOk: <T>(
- error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
- ) => VNode;
-}
-export function ShowCashoutDetails({
- id,
- onCancel,
- onLoadNotOk,
-}: ShowCashoutProps): VNode {
- const { i18n } = useTranslationContext();
- const result = useCashoutDetails(id);
- const { abortCashout, confirmCashout } = useCircuitAccountAPI();
- const [code, setCode] = useState<string | undefined>(undefined);
- if (!result.ok) return onLoadNotOk(result);
- const errors = undefinedIfEmpty({
- code: !code ? i18n.str`required` : undefined,
- });
- const isPending = String(result.data.status).toUpperCase() === "PENDING";
- return (
- <div>
- <h1>Cashout details {id}</h1>
- <form class="pure-form">
- <fieldset>
- <label>
- <i18n.Translate>Subject</i18n.Translate>
- </label>
- <input readOnly value={result.data.subject} />
- </fieldset>
- <fieldset>
- <label>
- <i18n.Translate>Created</i18n.Translate>
- </label>
- <input readOnly value={result.data.creation_time ?? ""} />
- </fieldset>
- <fieldset>
- <label>
- <i18n.Translate>Confirmed</i18n.Translate>
- </label>
- <input readOnly value={result.data.confirmation_time ?? ""} />
- </fieldset>
- <fieldset>
- <label>
- <i18n.Translate>Debited</i18n.Translate>
- </label>
- <input readOnly value={result.data.amount_debit} />
- </fieldset>
- <fieldset>
- <label>
- <i18n.Translate>Credit</i18n.Translate>
- </label>
- <input readOnly value={result.data.amount_credit} />
- </fieldset>
- <fieldset>
- <label>
- <i18n.Translate>Status</i18n.Translate>
- </label>
- <input readOnly value={result.data.status} />
- </fieldset>
- <fieldset>
- <label>
- <i18n.Translate>Destination</i18n.Translate>
- </label>
- <input readOnly value={result.data.cashout_address} />
- </fieldset>
- {isPending ? (
- <fieldset>
- <label>
- <i18n.Translate>Code</i18n.Translate>
- </label>
- <input
- value={code ?? ""}
- onChange={(e) => {
- setCode(e.currentTarget.value);
- }}
- />
- <ShowInputErrorLabel
- message={errors?.code}
- isDirty={code !== undefined}
- />
- </fieldset>
- ) : undefined}
- </form>
- <br />
- <div style={{ display: "flex", justifyContent: "space-between" }}>
- <button
- class="pure-button pure-button-secondary btn-cancel"
- onClick={(e) => {
- e.preventDefault();
- onCancel();
- }}
- >
- {i18n.str`Back`}
- </button>
- {isPending ? (
- <div>
- <button
- type="submit"
- class="pure-button pure-button-primary button-error"
- onClick={async (e) => {
- e.preventDefault();
- try {
- await abortCashout(id);
- onCancel();
- } catch (error) {
- if (error instanceof RequestError) {
- notify(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.NotFound
- ? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
- : status === HttpStatusCode.PreconditionFailed
- ? i18n.str`Cashout was already confimed`
- : undefined,
- }),
- );
- } else {
- notifyError(
- i18n.str`Operation failed, please report`,
- (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString
- )
- }
- }
- }}
- >
- {i18n.str`Abort`}
- </button>
-
- <button
- type="submit"
- disabled={!code}
- class="pure-button pure-button-primary "
- onClick={async (e) => {
- e.preventDefault();
- try {
- if (!code) return;
- const rest = await confirmCashout(id, {
- tan: code,
- });
- } catch (error) {
- if (error instanceof RequestError) {
- notify(
- buildRequestErrorMessage(i18n, error.cause, {
- onClientError: (status) =>
- status === HttpStatusCode.NotFound
- ? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
- : status === HttpStatusCode.PreconditionFailed
- ? i18n.str`Cashout was already confimed`
- : status === HttpStatusCode.Conflict
- ? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
- : status === HttpStatusCode.Forbidden
- ? i18n.str`Invalid code`
- : undefined,
- }),
- );
- } else {
- notifyError(
- i18n.str`Operation failed, please report`,
- (error instanceof Error
- ? error.message
- : JSON.stringify(error)) as TranslatedString
- )
- }
- }
- }}
- >
- {i18n.str`Confirm`}
- </button>
- </div>
- ) : (
- <div />
- )}
- </div>
- </div>
- );
-}
-
-const MAX_AMOUNT_DIGIT = 2;
-/**
- * Truncate the amount of digits to display
- * in the form based on the fee calculations
- *
- * Backend must have the same truncation
- * @param a
- * @returns
- */
-function truncate(a: AmountJson): AmountJson {
- const str = Amounts.stringify(a);
- const idx = str.indexOf(".");
- if (idx === -1) {
- return a;
- }
- const truncated = str.substring(0, idx + 1 + MAX_AMOUNT_DIGIT);
- return Amounts.parseOrThrow(truncated);
-}
-
-export function assertUnreachable(x: never): never {
- throw new Error("Didn't expect to get here");
-}
diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx
@@ -35,7 +35,7 @@ import { useBackendContext } from "../context/backend.js";
import { getInitialBackendBaseURL } from "../hooks/backend.js";
import { useSettings } from "../hooks/settings.js";
import { AccountPage } from "./AccountPage/index.js";
-import { AdminPage } from "./AdminPage.js";
+import { AdminHome } from "./admin/Home.js";
import { LoginForm } from "./LoginForm.js";
import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
import { error } from "console";
@@ -54,31 +54,24 @@ const logger = new Logger("AccountPage");
*/
export function HomePage({
onRegister,
+ account,
onPendingOperationFound,
}: {
+ account: string,
onPendingOperationFound: (id: string) => void;
onRegister: () => void;
}): VNode {
- const backend = useBackendContext();
const [settings] = useSettings();
const { i18n } = useTranslationContext();
- if (backend.state.status === "loggedOut") {
- return <LoginForm onRegister={onRegister} />;
- }
-
if (settings.currentWithdrawalOperationId) {
onPendingOperationFound(settings.currentWithdrawalOperationId);
return <Loading />;
}
- if (backend.state.isUserAdministrator) {
- return <AdminPage onRegister={onRegister} />;
- }
-
return (
<AccountPage
- account={backend.state.username}
+ account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
/>
);
@@ -105,8 +98,8 @@ export function WithdrawalOperationPage({
if (!parsedUri) {
notifyError(
- i18n.str`The Withdrawal URI is not valid: "${uri}"`,
- undefined
+ i18n.str`The Withdrawal URI is not valid`,
+ uri as TranslatedString
);
return <Loading />;
}
diff --git a/packages/demobank-ui/src/pages/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx
@@ -0,0 +1,143 @@
+import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode,h } from "preact";
+import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
+import { useState } from "preact/hooks";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { buildRequestErrorMessage } from "../utils.js";
+import { AccountForm } from "./admin/AccountForm.js";
+
+export function ShowAccountDetails({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+ onChangePassword,
+ }: {
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ onClear?: () => void;
+ onChangePassword: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+ }): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useBusinessAccountDetails(account);
+ const { updateAccount } = useAdminAccountAPI();
+ const [update, setUpdate] = useState(false);
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+
+ if (!result.ok) {
+ if (result.loading || result.type === ErrorType.TIMEOUT) {
+ return onLoadNotOk(result);
+ }
+ if (result.status === HttpStatusCode.NotFound) {
+ return <div>account not found</div>;
+ }
+ return onLoadNotOk(result);
+ }
+
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Business account details</i18n.Translate>
+ </h1>
+ </div>
+ <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
+ <AccountForm
+ template={result.data}
+ purpose={update ? "update" : "show"}
+ onChange={(a) => setSubmitAccount(a)}
+ />
+
+ <p class="buttons-account">
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ flexFlow: "wrap-reverse",
+ }}
+ >
+ <div>
+ {onClear ? (
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ ) : undefined}
+ </div>
+ <div style={{ display: "flex" }}>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={update && !submitAccount}
+ type="submit"
+ value={i18n.str`Change password`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onChangePassword();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={update && !submitAccount}
+ type="submit"
+ value={update ? i18n.str`Confirm` : i18n.str`Update`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (!update) {
+ setUpdate(true);
+ } else {
+ if (!submitAccount) return;
+ try {
+ await updateAccount(account, {
+ cashout_address: submitAccount.cashout_address,
+ contact_data: submitAccount.contact_data,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The rights to change the account are not sufficient`
+ : status === HttpStatusCode.NotFound
+ ? i18n.str`The username was not found`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx
@@ -0,0 +1,131 @@
+import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
+import { useState } from "preact/hooks";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { VNode,h ,Fragment} from "preact";
+import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
+import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
+
+export function UpdateAccountPassword({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+ }: {
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+ }): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useBusinessAccountDetails(account);
+ const { changePassword } = useAdminAccountAPI();
+ const [password, setPassword] = useState<string | undefined>();
+ const [repeat, setRepeat] = useState<string | undefined>();
+
+ if (!result.ok) {
+ if (result.loading || result.type === ErrorType.TIMEOUT) {
+ return onLoadNotOk(result);
+ }
+ if (result.status === HttpStatusCode.NotFound) {
+ return <div>account not found</div>;
+ }
+ return onLoadNotOk(result);
+ }
+
+ const errors = undefinedIfEmpty({
+ password: !password ? i18n.str`required` : undefined,
+ repeat: !repeat
+ ? i18n.str`required`
+ : password !== repeat
+ ? i18n.str`password doesn't match`
+ : undefined,
+ });
+
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Update password for {account}</i18n.Translate>
+ </h1>
+ </div>
+
+ <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
+ <form class="pure-form">
+ <fieldset>
+ <label>{i18n.str`Password`}</label>
+ <input
+ type="password"
+ value={password ?? ""}
+ onChange={(e) => {
+ setPassword(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.password}
+ isDirty={password !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Repeat password`}</label>
+ <input
+ type="password"
+ value={repeat ?? ""}
+ onChange={(e) => {
+ setRepeat(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.repeat}
+ isDirty={repeat !== undefined}
+ />
+ </fieldset>
+ </form>
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!!errors}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+ if (!!errors || !password) return;
+ try {
+ const r = await changePassword(account, {
+ new_password: password,
+ });
+ onUpdateSuccess();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(buildRequestErrorMessage(i18n, error.cause));
+ } else {
+ notifyError(i18n.str`Operation failed, please report`, (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString)
+ }
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ </div>
+ );
+ }
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx
@@ -317,7 +317,8 @@ export function WithdrawalConfirmationQuestion({
</div>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
- <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{Amounts.stringifyValue(details.amount)}</dd>
+ <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">To be added</dd>
+ {/* Amounts.stringifyValue(details.amount) */}
</div>
</dl>
</div>
diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx
@@ -100,10 +100,6 @@ export function WithdrawalQRCode({
}
if (data.confirmation_done) {
- if (!settings.showWithdrawalSuccess) {
- clearCurrentWithdrawal()
- onContinue()
- }
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">
diff --git a/packages/demobank-ui/src/pages/admin/Account.tsx b/packages/demobank-ui/src/pages/admin/Account.tsx
@@ -0,0 +1,56 @@
+import { Amounts } from "@gnu-taler/taler-util";
+import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { useAccountDetails } from "../../hooks/access.js";
+import { useBackendContext } from "../../context/backend.js";
+import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+
+export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode {
+ const { i18n } = useTranslationContext();
+ const r = useBackendContext();
+ const account = r.state.status === "loggedIn" ? r.state.username : "admin";
+ const result = useAccountDetails(account);
+
+ if (!result.ok) {
+ return handleNotOkResult(i18n, onRegister)(result);
+ }
+ const { data } = result;
+ const balance = Amounts.parseOrThrow(data.balance.amount);
+ const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
+ const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+ if (!balance) return <Fragment />;
+ return (
+ <Fragment>
+ <section id="assets">
+ <div class="asset-summary">
+ <h2>{i18n.str`Bank account balance`}</h2>
+ {!balance ? (
+ <div class="large-amount" style={{ color: "gray" }}>
+ Waiting server response...
+ </div>
+ ) : (
+ <div class="large-amount amount">
+ {balanceIsDebit ? <b>-</b> : null}
+ <span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
+
+ <span class="currency">{`${balance.currency}`}</span>
+ </div>
+ )}
+ </div>
+ </section>
+ <PaytoWireTransferForm
+ focus
+ limit={limit}
+ onSuccess={() => {
+ notifyInfo(i18n.str`Wire transfer created!`);
+ }}
+ onCancel={undefined}
+ />
+ </Fragment>
+ );
+ }
+
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx
@@ -0,0 +1,219 @@
+import { VNode,h } from "preact";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { PartialButDefined, RecursivePartial, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js";
+import { useState } from "preact/hooks";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+import { parsePaytoUri } from "@gnu-taler/taler-util";
+
+const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/;
+const EMAIL_REGEX =
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/;
+
+/**
+ * Create valid account object to update or create
+ * Take template as initial values for the form
+ * Purpose indicate if all field al read only (show), part of them (update)
+ * or none (create)
+ * @param param0
+ * @returns
+ */
+export function AccountForm({
+ template,
+ purpose,
+ onChange,
+ }: {
+ template: SandboxBackend.Circuit.CircuitAccountData | undefined;
+ onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void;
+ purpose: "create" | "update" | "show";
+ }): VNode {
+ const initial = initializeFromTemplate(template);
+ const [form, setForm] = useState(initial);
+ const [errors, setErrors] = useState<
+ RecursivePartial<typeof initial> | undefined
+ >(undefined);
+ const { i18n } = useTranslationContext();
+
+ function updateForm(newForm: typeof initial): void {
+ const parsed = !newForm.cashout_address
+ ? undefined
+ : parsePaytoUri(newForm.cashout_address);
+
+ const errors = undefinedIfEmpty<RecursivePartial<typeof initial>>({
+ cashout_address: !newForm.cashout_address
+ ? i18n.str`required`
+ : !parsed
+ ? i18n.str`does not follow the pattern`
+ : !parsed.isKnown || parsed.targetType !== "iban"
+ ? i18n.str`only "IBAN" target are supported`
+ : !IBAN_REGEX.test(parsed.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : validateIBAN(parsed.iban, i18n),
+ contact_data: undefinedIfEmpty({
+ email: !newForm.contact_data?.email
+ ? i18n.str`required`
+ : !EMAIL_REGEX.test(newForm.contact_data.email)
+ ? i18n.str`it should be an email`
+ : undefined,
+ phone: !newForm.contact_data?.phone
+ ? i18n.str`required`
+ : !newForm.contact_data.phone.startsWith("+")
+ ? i18n.str`should start with +`
+ : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone)
+ ? i18n.str`phone number can't have other than numbers`
+ : undefined,
+ }),
+ iban: !newForm.iban
+ ? undefined //optional field
+ : !IBAN_REGEX.test(newForm.iban)
+ ? i18n.str`IBAN should have just uppercased letters and numbers`
+ : validateIBAN(newForm.iban, i18n),
+ name: !newForm.name ? i18n.str`required` : undefined,
+ username: !newForm.username ? i18n.str`required` : undefined,
+ });
+ setErrors(errors);
+ setForm(newForm);
+ onChange(errors === undefined ? (newForm as any) : undefined);
+ }
+
+ return (
+ <form class="pure-form">
+ <fieldset>
+ <label for="username">
+ {i18n.str`Username`}
+ {purpose === "create" && <b style={{ color: "red" }}>*</b>}
+ </label>
+ <input
+ name="username"
+ type="text"
+ disabled={purpose !== "create"}
+ value={form.username}
+ onChange={(e) => {
+ form.username = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />{" "}
+ <ShowInputErrorLabel
+ message={errors?.username}
+ isDirty={form.username !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>
+ {i18n.str`Name`}
+ {purpose === "create" && <b style={{ color: "red" }}>*</b>}
+ </label>
+ <input
+ disabled={purpose !== "create"}
+ value={form.name ?? ""}
+ onChange={(e) => {
+ form.name = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.name}
+ isDirty={form.name !== undefined}
+ />
+ </fieldset>
+ {purpose !== "create" && (
+ <fieldset>
+ <label>{i18n.str`Internal IBAN`}</label>
+ <input
+ disabled={true}
+ value={form.iban ?? ""}
+ onChange={(e) => {
+ form.iban = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.iban}
+ isDirty={form.iban !== undefined}
+ />
+ </fieldset>
+ )}
+ <fieldset>
+ <label>
+ {i18n.str`Email`}
+ {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
+ </label>
+ <input
+ disabled={purpose === "show"}
+ value={form.contact_data.email ?? ""}
+ onChange={(e) => {
+ form.contact_data.email = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data?.email}
+ isDirty={form.contact_data.email !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>
+ {i18n.str`Phone`}
+ {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
+ </label>
+ <input
+ disabled={purpose === "show"}
+ value={form.contact_data.phone ?? ""}
+ onChange={(e) => {
+ form.contact_data.phone = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.contact_data?.phone}
+ isDirty={form.contact_data?.phone !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label>
+ {i18n.str`Cashout address`}
+ {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
+ </label>
+ <input
+ disabled={purpose === "show"}
+ value={(form.cashout_address ?? "").substring("payto://iban/".length)}
+ onChange={(e) => {
+ form.cashout_address = "payto://iban/" + e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.cashout_address}
+ isDirty={form.cashout_address !== undefined}
+ />
+ </fieldset>
+ </form>
+ );
+ }
+
+ function initializeFromTemplate(
+ account: SandboxBackend.Circuit.CircuitAccountData | undefined,
+ ): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> {
+ const emptyAccount = {
+ cashout_address: undefined,
+ iban: undefined,
+ name: undefined,
+ username: undefined,
+ contact_data: undefined,
+ };
+ const emptyContact = {
+ email: undefined,
+ phone: undefined,
+ };
+
+ const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> =
+ structuredClone(account) ?? emptyAccount;
+ if (typeof initial.contact_data === "undefined") {
+ initial.contact_data = emptyContact;
+ }
+ initial.contact_data.email;
+ return initial as any;
+ }
+
+
+
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx
@@ -0,0 +1,120 @@
+import { h, VNode } from "preact";
+import { useBusinessAccounts } from "../../hooks/circuit.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { AccountAction } from "./Home.js";
+import { Amounts } from "@gnu-taler/taler-util";
+import { useTranslationContext } from "@gnu-taler/web-util/browser";
+
+interface Props {
+ onAction: (type: AccountAction, account: string) => void;
+ account: string | undefined;
+ onRegister: () => void;
+
+}
+
+export function AccountList({ account, onAction, onRegister }: Props): VNode {
+ const result = useBusinessAccounts({ account });
+ const { i18n } = useTranslationContext();
+
+ if (result.loading) return <div />;
+ if (!result.ok) {
+ return handleNotOkResult(i18n, onRegister)(result);
+ }
+
+ const { customers } = result.data;
+ return <section
+ id="main"
+ style={{ width: 600, marginLeft: "auto", marginRight: "auto" }}
+ >
+ {!customers.length ? (
+ <div></div>
+ ) : (
+ <article>
+ <h2>{i18n.str`Accounts:`}</h2>
+ <div class="results">
+ <table class="pure-table pure-table-striped">
+ <thead>
+ <tr>
+ <th>{i18n.str`Username`}</th>
+ <th>{i18n.str`Name`}</th>
+ <th>{i18n.str`Balance`}</th>
+ <th>{i18n.str`Actions`}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {customers.map((item, idx) => {
+ const balance = !item.balance
+ ? undefined
+ : Amounts.parse(item.balance.amount);
+ const balanceIsDebit =
+ item.balance &&
+ item.balance.credit_debit_indicator == "debit";
+ return (
+ <tr key={idx}>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onAction("show-details", item.username)
+ }}
+ >
+ {item.username}
+ </a>
+ </td>
+ <td>{item.name}</td>
+ <td>
+ {!balance ? (
+ i18n.str`unknown`
+ ) : (
+ <span class="amount">
+ {balanceIsDebit ? <b>-</b> : null}
+ <span class="value">{`${Amounts.stringifyValue(
+ balance,
+ )}`}</span>
+
+ <span class="currency">{`${balance.currency}`}</span>
+ </span>
+ )}
+ </td>
+ <td>
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onAction("update-password", item.username)
+ }}
+ >
+ change password
+ </a>
+
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onAction("show-cashout", item.username)
+ }}
+ >
+ cashouts
+ </a>
+
+ <a
+ href="#"
+ onClick={(e) => {
+ e.preventDefault();
+ onAction("remove-account", item.username)
+ }}
+ >
+ remove
+ </a>
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </article>
+ )}
+ </section>
+}
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx
@@ -0,0 +1,107 @@
+import { RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode, h, Fragment } from "preact";
+import { useAdminAccountAPI } from "../../hooks/circuit.js";
+import { useState } from "preact/hooks";
+import { buildRequestErrorMessage } from "../../utils.js";
+import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { getRandomPassword } from "../rnd.js";
+import { AccountForm } from "./AccountForm.js";
+
+export function CreateNewAccount({
+ onClose,
+ onCreateSuccess,
+}: {
+ onClose: () => void;
+ onCreateSuccess: (password: string) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { createAccount } = useAdminAccountAPI();
+ const [submitAccount, setSubmitAccount] = useState<
+ SandboxBackend.Circuit.CircuitAccountData | undefined
+ >();
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>New account</i18n.Translate>
+ </h1>
+ </div>
+
+ <div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
+ <AccountForm
+ template={undefined}
+ purpose="create"
+ onChange={(a) => {
+ setSubmitAccount(a);
+ }}
+ />
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClose();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!submitAccount}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (!submitAccount) return;
+ try {
+ const account: SandboxBackend.Circuit.CircuitAccountRequest =
+ {
+ cashout_address: submitAccount.cashout_address,
+ contact_data: submitAccount.contact_data,
+ internal_iban: submitAccount.iban,
+ name: submitAccount.name,
+ username: submitAccount.username,
+ password: getRandomPassword(),
+ };
+
+ await createAccount(account);
+ onCreateSuccess(account.password);
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The rights to perform the operation are not sufficient`
+ : status === HttpStatusCode.BadRequest
+ ? i18n.str`Input data was invalid`
+ : status === HttpStatusCode.Conflict
+ ? i18n.str`At least one registration detail was not available`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/demobank-ui/src/pages/admin/Home.tsx b/packages/demobank-ui/src/pages/admin/Home.tsx
@@ -0,0 +1,162 @@
+import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { ShowCashoutDetails } from "../business/Home.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { ShowAccountDetails } from "../ShowAccountDetails.js";
+import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
+import { AdminAccount } from "./Account.js";
+import { AccountList } from "./AccountList.js";
+import { CreateNewAccount } from "./CreateNewAccount.js";
+import { RemoveAccount } from "./RemoveAccount.js";
+
+/**
+ * Query account information and show QR code if there is pending withdrawal
+ */
+interface Props {
+ onRegister: () => void;
+}
+export type AccountAction = "show-details" |
+ "show-cashout" |
+ "update-password" |
+ "remove-account" |
+ "show-cashouts-details";
+
+export function AdminHome({ onRegister }: Props): VNode {
+ const [action, setAction] = useState<{
+ type: AccountAction,
+ account: string
+ }>()
+
+ const [createAccount, setCreateAccount] = useState(false);
+
+ const { i18n } = useTranslationContext();
+
+ if (action) {
+ switch (action.type) {
+ case "show-details": return <ShowCashoutDetails
+ id={action.account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onCancel={() => {
+ setAction(undefined);
+ }}
+ />
+ case "show-cashout": return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Cashout for account {action.account}</i18n.Translate>
+ </h1>
+ </div>
+ <Cashouts
+ account={action.account}
+ onSelected={(id) => {
+ setAction({
+ type: "show-cashouts-details",
+ account: action.account
+ });
+ }}
+ />
+ <p>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Close`}
+ onClick={async (e) => {
+ e.preventDefault();
+ setAction(undefined);
+ }}
+ />
+ </p>
+ </div>
+ )
+ case "update-password": return <UpdateAccountPassword
+ account={action.account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Password changed`);
+ setAction(undefined);
+ }}
+ onClear={() => {
+ setAction(undefined);
+ }}
+ />
+ case "remove-account": return <RemoveAccount
+ account={action.account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Account removed`);
+ setAction(undefined);
+ }}
+ onClear={() => {
+ setAction(undefined);
+ }}
+ />
+ case "show-cashouts-details": return <ShowAccountDetails
+ account={action.account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onChangePassword={() => {
+ setAction({
+ type: "update-password",
+ account: action.account,
+ })
+ }}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Account updated`);
+ setAction(undefined);
+ }}
+ onClear={() => {
+ setAction(undefined);
+ }}
+ />
+ }
+ }
+
+ if (createAccount) {
+ return (
+ <CreateNewAccount
+ onClose={() => setCreateAccount(false)}
+ onCreateSuccess={(password) => {
+ notifyInfo(
+ i18n.str`Account created with password "${password}". The user must change the password on the next login.`,
+ );
+ setCreateAccount(false);
+ }}
+ />
+ );
+ }
+
+ return (
+ <Fragment>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Admin panel</i18n.Translate>
+ </h1>
+ </div>
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div></div>
+ <div>
+ <input
+ class="pure-button pure-button-primary content"
+ type="submit"
+ value={i18n.str`Create account`}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ setCreateAccount(true);
+ }}
+ />
+ </div>
+ </div>
+ </p>
+
+ <AdminAccount onRegister={onRegister} />
+
+ <AccountList account={undefined} onAction={(type,account) => setAction({account, type})} onRegister={onRegister}/>
+
+ </Fragment>
+ );
+}
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx
@@ -0,0 +1,112 @@
+import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { VNode,h,Fragment } from "preact";
+import { useAccountDetails } from "../../hooks/access.js";
+import { useAdminAccountAPI } from "../../hooks/circuit.js";
+import { Amounts, HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
+import { buildRequestErrorMessage } from "../../utils.js";
+
+export function RemoveAccount({
+ account,
+ onClear,
+ onUpdateSuccess,
+ onLoadNotOk,
+ }: {
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+ onClear: () => void;
+ onUpdateSuccess: () => void;
+ account: string;
+ }): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useAccountDetails(account);
+ const { deleteAccount } = useAdminAccountAPI();
+
+ if (!result.ok) {
+ if (result.loading || result.type === ErrorType.TIMEOUT) {
+ return onLoadNotOk(result);
+ }
+ if (result.status === HttpStatusCode.NotFound) {
+ return <div>account not found</div>;
+ }
+ return onLoadNotOk(result);
+ }
+
+ const balance = Amounts.parse(result.data.balance.amount);
+ if (!balance) {
+ return <div>there was an error reading the balance</div>;
+ }
+ const isBalanceEmpty = Amounts.isZero(balance);
+ return (
+ <div>
+ <div>
+ <h1 class="nav welcome-text">
+ <i18n.Translate>Remove account: {account}</i18n.Translate>
+ </h1>
+ </div>
+ {/* {FXME: SHOW WARNING} */}
+ {/* {!isBalanceEmpty && (
+ <ErrorBannerFloat
+ error={{
+ title: i18n.str`Can't delete the account`,
+ description: i18n.str`Balance is not empty`,
+ }}
+ onClear={() => saveError(undefined)}
+ />
+ )} */}
+
+ <p>
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div>
+ <input
+ class="pure-button"
+ type="submit"
+ value={i18n.str`Cancel`}
+ onClick={async (e) => {
+ e.preventDefault();
+ onClear();
+ }}
+ />
+ </div>
+ <div>
+ <input
+ id="select-exchange"
+ class="pure-button pure-button-primary content"
+ disabled={!isBalanceEmpty}
+ type="submit"
+ value={i18n.str`Confirm`}
+ onClick={async (e) => {
+ e.preventDefault();
+ try {
+ const r = await deleteAccount(account);
+ onUpdateSuccess();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.Forbidden
+ ? i18n.str`The administrator specified a institutional username`
+ : status === HttpStatusCode.NotFound
+ ? i18n.str`The username was not found`
+ : status === HttpStatusCode.PreconditionFailed
+ ? i18n.str`Balance was not zero`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString);
+ }
+ }
+ }}
+ />
+ </div>
+ </div>
+ </p>
+ </div>
+ );
+ }
+
+\ No newline at end of file
diff --git a/packages/demobank-ui/src/pages/business/Home.tsx b/packages/demobank-ui/src/pages/business/Home.tsx
@@ -0,0 +1,757 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022 Taler Systems S.A.
+
+ GNU Taler is free software; you can redistribute it and/or modify it under the
+ terms of the GNU General Public License as published by the Free Software
+ Foundation; either version 3, or (at your option) any later version.
+
+ GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with
+ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+import {
+ AmountJson,
+ Amounts,
+ HttpStatusCode,
+ TranslatedString
+} from "@gnu-taler/taler-util";
+import {
+ HttpResponse,
+ HttpResponsePaginated,
+ RequestError,
+ notify,
+ notifyError,
+ notifyInfo,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useEffect, useState } from "preact/hooks";
+import { Cashouts } from "../../components/Cashouts/index.js";
+import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
+import { useBackendContext } from "../../context/backend.js";
+import { useAccountDetails } from "../../hooks/access.js";
+import {
+ useCashoutDetails,
+ useCircuitAccountAPI,
+ useEstimator,
+ useRatiosAndFeeConfig,
+} from "../../hooks/circuit.js";
+import {
+ TanChannel,
+ buildRequestErrorMessage,
+ undefinedIfEmpty,
+} from "../../utils.js";
+import { handleNotOkResult } from "../HomePage.js";
+import { LoginForm } from "../LoginForm.js";
+import { Amount } from "../PaytoWireTransferForm.js";
+import { ShowAccountDetails } from "../ShowAccountDetails.js";
+import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
+
+interface Props {
+ account: string,
+ onClose: () => void;
+ onRegister: () => void;
+ onLoadNotOk: () => void;
+}
+export function BusinessAccount({
+ onClose,
+ account,
+ onLoadNotOk,
+ onRegister,
+}: Props): VNode {
+ const { i18n } = useTranslationContext();
+ const [updatePassword, setUpdatePassword] = useState(false);
+ const [newCashout, setNewcashout] = useState(false);
+ const [showCashoutDetails, setShowCashoutDetails] = useState<
+ string | undefined
+ >();
+
+
+ if (newCashout) {
+ return (
+ <CreateCashout
+ account={account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onCancel={() => {
+ setNewcashout(false);
+ }}
+ onComplete={(id) => {
+ notifyInfo(
+ i18n.str`Cashout created. You need to confirm the operation to complete the transaction.`,
+ );
+ setNewcashout(false);
+ setShowCashoutDetails(id);
+ }}
+ />
+ );
+ }
+ if (showCashoutDetails) {
+ return (
+ <ShowCashoutDetails
+ id={showCashoutDetails}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onCancel={() => {
+ setShowCashoutDetails(undefined);
+ }}
+ />
+ );
+ }
+ if (updatePassword) {
+ return (
+ <UpdateAccountPassword
+ account={account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Password changed`);
+ setUpdatePassword(false);
+ }}
+ onClear={() => {
+ setUpdatePassword(false);
+ }}
+ />
+ );
+ }
+ return (
+ <div>
+ <ShowAccountDetails
+ account={account}
+ onLoadNotOk={handleNotOkResult(i18n, onRegister)}
+ onUpdateSuccess={() => {
+ notifyInfo(i18n.str`Account updated`);
+ }}
+ onChangePassword={() => {
+ setUpdatePassword(true);
+ }}
+ onClear={onClose}
+ />
+ <section style={{ marginTop: "2em" }}>
+ <div class="active">
+ <h3>{i18n.str`Latest cashouts`}</h3>
+ <Cashouts
+ account={account}
+ onSelected={(id) => {
+ setShowCashoutDetails(id);
+ }}
+ />
+ </div>
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <div />
+ <input
+ class="pure-button pure-button-primary content"
+ type="submit"
+ value={i18n.str`New cashout`}
+ onClick={async (e) => {
+ e.preventDefault();
+ setNewcashout(true);
+ }}
+ />
+ </div>
+ </section>
+ </div>
+ );
+}
+
+interface PropsCashout {
+ account: string;
+ onComplete: (id: string) => void;
+ onCancel: () => void;
+ onLoadNotOk: <T>(
+ error:
+ | HttpResponsePaginated<T, SandboxBackend.SandboxError>
+ | HttpResponse<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+}
+
+type FormType = {
+ isDebit: boolean;
+ amount: string;
+ subject: string;
+ channel: TanChannel;
+};
+type ErrorFrom<T> = {
+ [P in keyof T]+?: string;
+};
+
+// check #7719
+function useRatiosAndFeeConfigWithChangeDetection(): HttpResponse<
+ SandboxBackend.Circuit.Config & { hasChanged?: boolean },
+ SandboxBackend.SandboxError
+> {
+ const result = useRatiosAndFeeConfig();
+ const [oldResult, setOldResult] = useState<
+ SandboxBackend.Circuit.Config | undefined
+ >(undefined);
+ const dataFromBackend = result.ok ? result.data : undefined;
+ useEffect(() => {
+ // save only the first result of /config to the backend
+ if (!dataFromBackend || oldResult !== undefined) return;
+ setOldResult(dataFromBackend);
+ }, [dataFromBackend]);
+
+ if (!result.ok) return result;
+
+ const data = !oldResult ? result.data : oldResult;
+ const hasChanged =
+ oldResult &&
+ (result.data.name !== oldResult.name ||
+ result.data.version !== oldResult.version ||
+ result.data.ratios_and_fees.buy_at_ratio !==
+ oldResult.ratios_and_fees.buy_at_ratio ||
+ result.data.ratios_and_fees.buy_in_fee !==
+ oldResult.ratios_and_fees.buy_in_fee ||
+ result.data.ratios_and_fees.sell_at_ratio !==
+ oldResult.ratios_and_fees.sell_at_ratio ||
+ result.data.ratios_and_fees.sell_out_fee !==
+ oldResult.ratios_and_fees.sell_out_fee ||
+ result.data.fiat_currency !== oldResult.fiat_currency);
+
+ return {
+ ...result,
+ data: { ...data, hasChanged },
+ };
+}
+
+function CreateCashout({
+ account,
+ onComplete,
+ onCancel,
+ onLoadNotOk,
+}: PropsCashout): VNode {
+ const { i18n } = useTranslationContext();
+ const ratiosResult = useRatiosAndFeeConfig();
+ const result = useAccountDetails(account);
+ const {
+ estimateByCredit: calculateFromCredit,
+ estimateByDebit: calculateFromDebit,
+ } = useEstimator();
+ const [form, setForm] = useState<Partial<FormType>>({ isDebit: true });
+
+ const { createCashout } = useCircuitAccountAPI();
+ if (!result.ok) return onLoadNotOk(result);
+ if (!ratiosResult.ok) return onLoadNotOk(ratiosResult);
+ const config = ratiosResult.data;
+
+ const balance = Amounts.parseOrThrow(result.data.balance.amount);
+ const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
+ const zero = Amounts.zeroOfCurrency(balance.currency);
+ const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
+ const limit = balanceIsDebit
+ ? Amounts.sub(debitThreshold, balance).amount
+ : Amounts.add(balance, debitThreshold).amount;
+
+ const zeroCalc = { debit: zero, credit: zero, beforeFee: zero };
+ const [calc, setCalc] = useState(zeroCalc);
+ const sellRate = config.ratios_and_fees.sell_at_ratio;
+ const sellFee = !config.ratios_and_fees.sell_out_fee
+ ? zero
+ : Amounts.parseOrThrow(
+ `${balance.currency}:${config.ratios_and_fees.sell_out_fee}`,
+ );
+ const fiatCurrency = config.fiat_currency;
+
+ if (!sellRate || sellRate < 0) return <div>error rate</div>;
+
+ const amount = Amounts.parseOrThrow(
+ `${!form.isDebit ? fiatCurrency : balance.currency}:${
+ !form.amount ? "0" : form.amount
+ }`,
+ );
+
+ useEffect(() => {
+ if (form.isDebit) {
+ calculateFromDebit(amount, sellFee, sellRate)
+ .then((r) => {
+ setCalc(r);
+ })
+ .catch((error) => {
+ notify(
+ error instanceof RequestError
+ ? buildRequestErrorMessage(i18n, error.cause)
+ : {
+ type: "error",
+ title: i18n.str`Could not estimate the cashout`,
+ description: error.message as TranslatedString
+ },
+ );
+ });
+ } else {
+ calculateFromCredit(amount, sellFee, sellRate)
+ .then((r) => {
+ setCalc(r);
+ })
+ .catch((error) => {
+ notify(
+ error instanceof RequestError
+ ? buildRequestErrorMessage(i18n, error.cause)
+ : {
+ type: "error",
+ title: i18n.str`Could not estimate the cashout`,
+ description: error.message,
+ },
+ );
+ });
+ }
+ }, [form.amount, form.isDebit]);
+
+ const balanceAfter = Amounts.sub(balance, calc.debit).amount;
+
+ function updateForm(newForm: typeof form): void {
+ setForm(newForm);
+ }
+ const errors = undefinedIfEmpty<ErrorFrom<typeof form>>({
+ amount: !form.amount
+ ? i18n.str`required`
+ : !amount
+ ? i18n.str`could not be parsed`
+ : Amounts.cmp(limit, calc.debit) === -1
+ ? i18n.str`balance is not enough`
+ : Amounts.cmp(calc.beforeFee, sellFee) === -1
+ ? i18n.str`the total amount to transfer does not cover the fees`
+ : Amounts.isZero(calc.credit)
+ ? i18n.str`the total transfer at destination will be zero`
+ : undefined,
+ channel: !form.channel ? i18n.str`required` : undefined,
+ });
+
+ return (
+ <div>
+ <h1>New cashout</h1>
+ <form class="pure-form">
+ <fieldset>
+ <label>{i18n.str`Subject`}</label>
+ <input
+ value={form.subject ?? ""}
+ onChange={(e) => {
+ form.subject = e.currentTarget.value;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.subject}
+ isDirty={form.subject !== undefined}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="amount">
+ {form.isDebit
+ ? i18n.str`Amount to send`
+ : i18n.str`Amount to receive`}
+
+ </label>
+ <div style={{ display: "flex" }}>
+ <Amount
+ name="amount"
+ currency={amount.currency}
+ value={form.amount}
+ onChange={(v) => {
+ form.amount = v;
+ updateForm(structuredClone(form));
+ }}
+ error={errors?.amount}
+ />
+ <label class="toggle" style={{ marginLeft: 4, marginTop: 0 }}>
+ <input
+ class="toggle-checkbox"
+ type="checkbox"
+ name="asd"
+ onChange={(e): void => {
+ console.log("asdasd", form.isDebit);
+ form.isDebit = !form.isDebit;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <div class="toggle-switch"></div>
+ </label>
+ </div>
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Conversion rate`}</label>
+ <input value={sellRate} disabled />
+ </fieldset>
+ <fieldset>
+ <label for="balance-now">{i18n.str`Balance now`}</label>
+ <Amount
+ name="banace-now"
+ currency={balance.currency}
+ value={Amounts.stringifyValue(balance)}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="total-cost"
+ style={{ fontWeight: "bold", color: "red" }}
+ >{i18n.str`Total cost`}</label>
+ <Amount
+ name="total-cost"
+ currency={balance.currency}
+ value={Amounts.stringifyValue(calc.debit)}
+ />
+ </fieldset>
+ <fieldset>
+ <label for="balance-after">{i18n.str`Balance after`}</label>
+ <Amount
+ name="balance-after"
+ currency={balance.currency}
+ value={balanceAfter ? Amounts.stringifyValue(balanceAfter) : ""}
+ />
+ </fieldset>{" "}
+ {Amounts.isZero(sellFee) ? undefined : (
+ <Fragment>
+ <fieldset>
+ <label for="amount-conversiojn">{i18n.str`Amount after conversion`}</label>
+ <Amount
+ name="amount-conversion"
+ currency={fiatCurrency}
+ value={Amounts.stringifyValue(calc.beforeFee)}
+ />
+ </fieldset>
+
+ <fieldset>
+ <label form="cashout-fee">{i18n.str`Cashout fee`}</label>
+ <Amount
+ name="cashout-fee"
+ currency={fiatCurrency}
+ value={Amounts.stringifyValue(sellFee)}
+ />
+ </fieldset>
+ </Fragment>
+ )}
+ <fieldset>
+ <label for="total"
+ style={{ fontWeight: "bold", color: "green" }}
+ >{i18n.str`Total cashout transfer`}</label>
+ <Amount
+ name="total"
+ currency={fiatCurrency}
+ value={Amounts.stringifyValue(calc.credit)}
+ />
+ </fieldset>
+ <fieldset>
+ <label>{i18n.str`Confirmation channel`}</label>
+
+ <div class="channel">
+ <input
+ class={
+ "pure-button content " +
+ (form.channel === TanChannel.EMAIL
+ ? "pure-button-primary"
+ : "pure-button-secondary")
+ }
+ type="submit"
+ value={i18n.str`Email`}
+ onClick={async (e) => {
+ e.preventDefault();
+ form.channel = TanChannel.EMAIL;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <input
+ class={
+ "pure-button content " +
+ (form.channel === TanChannel.SMS
+ ? "pure-button-primary"
+ : "pure-button-secondary")
+ }
+ type="submit"
+ value={i18n.str`SMS`}
+ onClick={async (e) => {
+ e.preventDefault();
+ form.channel = TanChannel.SMS;
+ updateForm(structuredClone(form));
+ }}
+ />
+ <input
+ class={
+ "pure-button content " +
+ (form.channel === TanChannel.FILE
+ ? "pure-button-primary"
+ : "pure-button-secondary")
+ }
+ type="submit"
+ value={i18n.str`FILE`}
+ onClick={async (e) => {
+ e.preventDefault();
+ form.channel = TanChannel.FILE;
+ updateForm(structuredClone(form));
+ }}
+ />
+ </div>
+ <ShowInputErrorLabel
+ message={errors?.channel}
+ isDirty={form.channel !== undefined}
+ />
+ </fieldset>
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <button
+ class="pure-button pure-button-secondary btn-cancel"
+ onClick={(e) => {
+ e.preventDefault();
+ onCancel();
+ }}
+ >
+ {i18n.str`Cancel`}
+ </button>
+
+ <button
+ class="pure-button pure-button-primary btn-register"
+ type="submit"
+ disabled={!!errors}
+ onClick={async (e) => {
+ e.preventDefault();
+
+ if (errors) return;
+ try {
+ const res = await createCashout({
+ amount_credit: Amounts.stringify(calc.credit),
+ amount_debit: Amounts.stringify(calc.debit),
+ subject: form.subject,
+ tan_channel: form.channel,
+ });
+ onComplete(res.data.uuid);
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.BadRequest
+ ? i18n.str`The exchange rate was incorrectly applied`
+ : status === HttpStatusCode.Forbidden
+ ? i18n.str`A institutional user tried the operation`
+ : status === HttpStatusCode.Conflict
+ ? i18n.str`Need a contact data where to send the TAN`
+ : status === HttpStatusCode.PreconditionFailed
+ ? i18n.str`The account does not have sufficient funds`
+ : undefined,
+ onServerError: (status) =>
+ status === HttpStatusCode.ServiceUnavailable
+ ? i18n.str`The bank does not support the TAN channel for this operation`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ >
+ {i18n.str`Create`}
+ </button>
+ </div>
+ </form>
+ </div>
+ );
+}
+
+interface ShowCashoutProps {
+ id: string;
+ onCancel: () => void;
+ onLoadNotOk: <T>(
+ error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
+ ) => VNode;
+}
+export function ShowCashoutDetails({
+ id,
+ onCancel,
+ onLoadNotOk,
+}: ShowCashoutProps): VNode {
+ const { i18n } = useTranslationContext();
+ const result = useCashoutDetails(id);
+ const { abortCashout, confirmCashout } = useCircuitAccountAPI();
+ const [code, setCode] = useState<string | undefined>(undefined);
+ if (!result.ok) return onLoadNotOk(result);
+ const errors = undefinedIfEmpty({
+ code: !code ? i18n.str`required` : undefined,
+ });
+ const isPending = String(result.data.status).toUpperCase() === "PENDING";
+ return (
+ <div>
+ <h1>Cashout details {id}</h1>
+ <form class="pure-form">
+ <fieldset>
+ <label>
+ <i18n.Translate>Subject</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.subject} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Created</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.creation_time ?? ""} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Confirmed</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.confirmation_time ?? ""} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Debited</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.amount_debit} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Credit</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.amount_credit} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Status</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.status} />
+ </fieldset>
+ <fieldset>
+ <label>
+ <i18n.Translate>Destination</i18n.Translate>
+ </label>
+ <input readOnly value={result.data.cashout_address} />
+ </fieldset>
+ {isPending ? (
+ <fieldset>
+ <label>
+ <i18n.Translate>Code</i18n.Translate>
+ </label>
+ <input
+ value={code ?? ""}
+ onChange={(e) => {
+ setCode(e.currentTarget.value);
+ }}
+ />
+ <ShowInputErrorLabel
+ message={errors?.code}
+ isDirty={code !== undefined}
+ />
+ </fieldset>
+ ) : undefined}
+ </form>
+ <br />
+ <div style={{ display: "flex", justifyContent: "space-between" }}>
+ <button
+ class="pure-button pure-button-secondary btn-cancel"
+ onClick={(e) => {
+ e.preventDefault();
+ onCancel();
+ }}
+ >
+ {i18n.str`Back`}
+ </button>
+ {isPending ? (
+ <div>
+ <button
+ type="submit"
+ class="pure-button pure-button-primary button-error"
+ onClick={async (e) => {
+ e.preventDefault();
+ try {
+ await abortCashout(id);
+ onCancel();
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.NotFound
+ ? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
+ : status === HttpStatusCode.PreconditionFailed
+ ? i18n.str`Cashout was already confimed`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ >
+ {i18n.str`Abort`}
+ </button>
+
+ <button
+ type="submit"
+ disabled={!code}
+ class="pure-button pure-button-primary "
+ onClick={async (e) => {
+ e.preventDefault();
+ try {
+ if (!code) return;
+ const rest = await confirmCashout(id, {
+ tan: code,
+ });
+ } catch (error) {
+ if (error instanceof RequestError) {
+ notify(
+ buildRequestErrorMessage(i18n, error.cause, {
+ onClientError: (status) =>
+ status === HttpStatusCode.NotFound
+ ? i18n.str`Cashout not found. It may be also mean that it was already aborted.`
+ : status === HttpStatusCode.PreconditionFailed
+ ? i18n.str`Cashout was already confimed`
+ : status === HttpStatusCode.Conflict
+ ? i18n.str`Confirmation failed. Maybe the user changed their cash-out address between the creation and the confirmation`
+ : status === HttpStatusCode.Forbidden
+ ? i18n.str`Invalid code`
+ : undefined,
+ }),
+ );
+ } else {
+ notifyError(
+ i18n.str`Operation failed, please report`,
+ (error instanceof Error
+ ? error.message
+ : JSON.stringify(error)) as TranslatedString
+ )
+ }
+ }
+ }}
+ >
+ {i18n.str`Confirm`}
+ </button>
+ </div>
+ ) : (
+ <div />
+ )}
+ </div>
+ </div>
+ );
+}
+
+const MAX_AMOUNT_DIGIT = 2;
+/**
+ * Truncate the amount of digits to display
+ * in the form based on the fee calculations
+ *
+ * Backend must have the same truncation
+ * @param a
+ * @returns
+ */
+function truncate(a: AmountJson): AmountJson {
+ const str = Amounts.stringify(a);
+ const idx = str.indexOf(".");
+ if (idx === -1) {
+ return a;
+ }
+ const truncated = str.substring(0, idx + 1 + MAX_AMOUNT_DIGIT);
+ return Amounts.parseOrThrow(truncated);
+}
+
+export function assertUnreachable(x: never): never {
+ throw new Error("Didn't expect to get here");
+}