taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 062939d9cc016a186a282f7a48492c3e01cd740c
parent b3c747151bb3f50d28bf6205cafa4b7dd6ae2b1c
Author: Sebastian <sebasjm@gmail.com>
Date:   Thu, 21 Sep 2023 10:31:10 -0300

admin refactor

Diffstat:
Mpackages/demobank-ui/src/components/Routing.tsx | 13++++++++-----
Dpackages/demobank-ui/src/pages/AdminPage.tsx | 1042-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/BusinessAccount.tsx | 758-------------------------------------------------------------------------------
Mpackages/demobank-ui/src/pages/HomePage.tsx | 19++++++-------------
Apackages/demobank-ui/src/pages/ShowAccountDetails.tsx | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/UpdateAccountPassword.tsx | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx | 3++-
Mpackages/demobank-ui/src/pages/WithdrawalQRCode.tsx | 4----
Apackages/demobank-ui/src/pages/admin/Account.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/admin/AccountForm.tsx | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/admin/AccountList.tsx | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/admin/CreateNewAccount.tsx | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/admin/Home.tsx | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/admin/RemoveAccount.tsx | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/business/Home.tsx | 757+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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> - &nbsp; - <span class="currency">{`${balance.currency}`}</span> - </span> - )} - </td> - <td> - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setUpdatePassword(item.username); - }} - > - change password - </a> - &nbsp; - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setShowCashouts(item.username); - }} - > - cashouts - </a> - &nbsp; - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setRemoveAccount(item.username); - }} - > - remove - </a> - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - </article> - )} - </section> - </Fragment> - ); -} - -function AdminAccount({ onRegister }: { onRegister: () => void }): VNode { - const { i18n } = useTranslationContext(); - const r = useBackendContext(); - const account = r.state.status === "loggedIn" ? r.state.username : "admin"; - const result = useAccountDetails(account); - - if (!result.ok) { - return handleNotOkResult(i18n, onRegister)(result); - } - const { data } = result; - const balance = Amounts.parseOrThrow(data.balance.amount); - const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold); - const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit"; - const limit = balanceIsDebit - ? Amounts.sub(debitThreshold, balance).amount - : Amounts.add(balance, debitThreshold).amount; - if (!balance) return <Fragment />; - return ( - <Fragment> - <section id="assets"> - <div class="asset-summary"> - <h2>{i18n.str`Bank account balance`}</h2> - {!balance ? ( - <div class="large-amount" style={{ color: "gray" }}> - Waiting server response... - </div> - ) : ( - <div class="large-amount amount"> - {balanceIsDebit ? <b>-</b> : null} - <span class="value">{`${Amounts.stringifyValue(balance)}`}</span> - &nbsp; - <span class="currency">{`${balance.currency}`}</span> - </div> - )} - </div> - </section> - <PaytoWireTransferForm - focus - limit={limit} - onSuccess={() => { - notifyInfo(i18n.str`Wire transfer created!`); - }} - 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> - &nbsp; - <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> + &nbsp; + <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> + &nbsp; + <span class="currency">{`${balance.currency}`}</span> + </span> + )} + </td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onAction("update-password", item.username) + }} + > + change password + </a> + &nbsp; + <a + href="#" + onClick={(e) => { + e.preventDefault(); + onAction("show-cashout", item.username) + }} + > + cashouts + </a> + &nbsp; + <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> + &nbsp; + <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"); +}