diff options
Diffstat (limited to 'packages/demobank-ui/src/pages')
-rw-r--r-- | packages/demobank-ui/src/pages/AccountPage.tsx | 283 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/AdminPage.tsx | 707 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/BankFrame.tsx | 42 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/HomePage.tsx | 149 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/LoginForm.tsx | 188 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PaymentOptions.tsx | 33 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx | 317 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/PublicHistoriesPage.tsx | 93 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/QrCodeSection.tsx | 9 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/RegistrationPage.tsx | 176 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/Routing.tsx | 84 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/WalletWithdrawForm.tsx | 259 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx | 466 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/WithdrawalQRCode.tsx | 111 |
14 files changed, 1787 insertions, 1130 deletions
diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx index 8d29bd933..769e85804 100644 --- a/packages/demobank-ui/src/pages/AccountPage.tsx +++ b/packages/demobank-ui/src/pages/AccountPage.tsx @@ -14,206 +14,52 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, HttpStatusCode, Logger } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { useEffect } from "preact/hooks"; -import useSWR, { SWRConfig, useSWRConfig } from "swr"; -import { useBackendContext } from "../context/backend.js"; -import { PageStateType, usePageContext } from "../context/pageState.js"; -import { BackendInfo } from "../hooks/backend.js"; -import { bankUiSettings } from "../settings.js"; -import { getIbanFromPayto, prepareHeaders } from "../utils.js"; -import { BankFrame } from "./BankFrame.js"; -import { LoginForm } from "./LoginForm.js"; -import { PaymentOptions } from "./PaymentOptions.js"; +import { Amounts, parsePaytoUri } from "@gnu-taler/taler-util"; +import { + HttpResponsePaginated, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Cashouts } from "../components/Cashouts/index.js"; import { Transactions } from "../components/Transactions/index.js"; -import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; - -export function AccountPage(): VNode { - const backend = useBackendContext(); - const { i18n } = useTranslationContext(); - - if (backend.state.status === "loggedOut") { - return ( - <BankFrame> - <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> - <LoginForm /> - </BankFrame> - ); - } - - return ( - <SWRWithCredentials info={backend.state}> - <Account accountLabel={backend.state.username} /> - </SWRWithCredentials> - ); -} - -/** - * Factor out login credentials. - */ -function SWRWithCredentials({ - children, - info, -}: { - children: ComponentChildren; - info: BackendInfo; -}): VNode { - const { username, password, url: backendUrl } = info; - const headers = prepareHeaders(username, password); - return ( - <SWRConfig - value={{ - fetcher: (url: string) => { - return fetch(new URL(url, backendUrl).href, { headers }).then((r) => { - if (!r.ok) throw { status: r.status, json: r.json() }; +import { useAccountDetails } from "../hooks/access.js"; +import { PaymentOptions } from "./PaymentOptions.js"; - return r.json(); - }); - }, - }} - > - {children as any} - </SWRConfig> - ); +interface Props { + account: string; + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; } - -const logger = new Logger("AccountPage"); - /** - * Show only the account's balance. NOTE: the backend state - * is mostly needed to provide the user's credentials to POST - * to the bank. + * Query account information and show QR code if there is pending withdrawal */ -function Account({ accountLabel }: { accountLabel: string }): VNode { - const { cache } = useSWRConfig(); - - // Getting the bank account balance: - const endpoint = `access-api/accounts/${accountLabel}`; - const { data, error, mutate } = useSWR(endpoint, { - // refreshInterval: 0, - // revalidateIfStale: false, - // revalidateOnMount: false, - // revalidateOnFocus: false, - // revalidateOnReconnect: false, - }); - const backend = useBackendContext(); - const { pageState, pageStateSetter: setPageState } = usePageContext(); - const { withdrawalId, talerWithdrawUri, timestamp } = pageState; +export function AccountPage({ account, onLoadNotOk }: Props): VNode { + const result = useAccountDetails(account); const { i18n } = useTranslationContext(); - useEffect(() => { - mutate(); - }, [timestamp]); - /** - * This part shows a list of transactions: with 5 elements by - * default and offers a "load more" button. - */ - // const [txPageNumber, setTxPageNumber] = useTransactionPageNumber(); - // const txsPages = []; - // for (let i = 0; i <= txPageNumber; i++) { - // txsPages.push(<Transactions accountLabel={accountLabel} pageNumber={i} />); - // } - - if (typeof error !== "undefined") { - logger.error("account error", error, endpoint); - /** - * FIXME: to minimize the code, try only one invocation - * of pageStateSetter, after having decided the error - * message in the case-branch. - */ - switch (error.status) { - case 404: { - backend.clear(); - setPageState((prevState: PageStateType) => ({ - ...prevState, - - error: { - title: i18n.str`Username or account label '${accountLabel}' not found. Won't login.`, - }, - })); - - /** - * 404 should never stick to the cache, because they - * taint successful future registrations. How? After - * registering, the user gets navigated to this page, - * therefore a previous 404 on this SWR key (the requested - * resource) would still appear as valid and cause this - * page not to be shown! A typical case is an attempted - * login of a unregistered user X, and then a registration - * attempt of the same user X: in this case, the failed - * login would cache a 404 error to X's profile, resulting - * in the legitimate request after the registration to still - * be flagged as 404. Clearing the cache should prevent - * this. */ - (cache as any).clear(); - return <p>Profile not found...</p>; - } - case HttpStatusCode.Unauthorized: - case HttpStatusCode.Forbidden: { - backend.clear(); - setPageState((prevState: PageStateType) => ({ - ...prevState, - error: { - title: i18n.str`Wrong credentials given.`, - }, - })); - return <p>Wrong credentials...</p>; - } - default: { - backend.clear(); - setPageState((prevState: PageStateType) => ({ - ...prevState, - error: { - title: i18n.str`Account information could not be retrieved.`, - debug: JSON.stringify(error), - }, - })); - return <p>Unknown problem...</p>; - } - } + if (!result.ok) { + return onLoadNotOk(result); } - const balance = !data ? undefined : Amounts.parse(data.balance.amount); - const errorParsingBalance = data && !balance; - const accountNumber = !data ? undefined : getIbanFromPayto(data.paytoUri); - const balanceIsDebit = data && data.balance.credit_debit_indicator == "debit"; - /** - * This block shows the withdrawal QR code. - * - * A withdrawal operation replaces everything in the page and - * (ToDo:) starts polling the backend until either the wallet - * selected a exchange and reserve public key, or a error / abort - * happened. - * - * After reaching one of the above states, the user should be - * brought to this ("Account") page where they get informed about - * the outcome. - */ - if (talerWithdrawUri && withdrawalId) { - logger.trace("Bank created a new Taler withdrawal"); + const { data } = result; + const balance = Amounts.parse(data.balance.amount); + const errorParsingBalance = !balance; + const payto = parsePaytoUri(data.paytoUri); + if (!payto || !payto.isKnown || payto.targetType !== "iban") { return ( - <BankFrame> - <WithdrawalQRCode - withdrawalId={withdrawalId} - talerWithdrawUri={talerWithdrawUri} - /> - </BankFrame> + <div>Payto from server is not valid "{data.paytoUri}"</div> ); } - const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance); + const accountNumber = payto.iban; + const balanceIsDebit = data.balance.credit_debit_indicator == "debit"; return ( - <BankFrame> + <Fragment> <div> <h1 class="nav welcome-text"> <i18n.Translate> Welcome, - {accountNumber - ? `${accountLabel} (${accountNumber})` - : accountLabel} - ! + {accountNumber ? `${account} (${accountNumber})` : account}! </i18n.Translate> </h1> </div> @@ -239,7 +85,10 @@ function Account({ accountLabel }: { accountLabel: string }): VNode { ) : ( <div class="large-amount amount"> {balanceIsDebit ? <b>-</b> : null} - <span class="value">{`${balanceValue}`}</span> + <span class="value">{`${Amounts.stringifyValue( + balance, + )}`}</span> + <span class="currency">{`${balance.currency}`}</span> </div> )} @@ -248,34 +97,56 @@ function Account({ accountLabel }: { accountLabel: string }): VNode { <section id="payments"> <div class="payments"> <h2>{i18n.str`Payments`}</h2> - <PaymentOptions currency={balance?.currency} /> + <PaymentOptions currency={balance.currency} /> </div> </section> </Fragment> )} - <section id="main"> - <article> - <h2>{i18n.str`Latest transactions:`}</h2> - <Transactions - balanceValue={balanceValue} - pageNumber={0} - accountLabel={accountLabel} - /> - </article> + + <section style={{ marginTop: "2em" }}> + <Moves account={account} /> </section> - </BankFrame> + </Fragment> ); } -// function useTransactionPageNumber(): [number, StateUpdater<number>] { -// const ret = useNotNullLocalStorage("transaction-page", "0"); -// const retObj = JSON.parse(ret[0]); -// const retSetter: StateUpdater<number> = function (val) { -// const newVal = -// val instanceof Function -// ? JSON.stringify(val(retObj)) -// : JSON.stringify(val); -// ret[1](newVal); -// }; -// return [retObj, retSetter]; -// } +function Moves({ account }: { account: string }): VNode { + const [tab, setTab] = useState<"transactions" | "cashouts">("transactions"); + const { i18n } = useTranslationContext(); + return ( + <article> + <div class="payments"> + <div class="tab"> + <button + class={tab === "transactions" ? "tablinks active" : "tablinks"} + onClick={(): void => { + setTab("transactions"); + }} + > + {i18n.str`Transactions`} + </button> + <button + class={tab === "cashouts" ? "tablinks active" : "tablinks"} + onClick={(): void => { + setTab("cashouts"); + }} + > + {i18n.str`Cashouts`} + </button> + </div> + {tab === "transactions" && ( + <div class="active"> + <h3>{i18n.str`Latest transactions`}</h3> + <Transactions account={account} /> + </div> + )} + {tab === "cashouts" && ( + <div class="active"> + <h3>{i18n.str`Latest cashouts`}</h3> + <Cashouts account={account} /> + </div> + )} + </div> + </article> + ); +} diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx new file mode 100644 index 000000000..9efd37f12 --- /dev/null +++ b/packages/demobank-ui/src/pages/AdminPage.tsx @@ -0,0 +1,707 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util"; +import { + HttpResponsePaginated, + RequestError, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorMessage, usePageContext } from "../context/pageState.js"; +import { + useAccountDetails, + useAccounts, + useAdminAccountAPI, +} from "../hooks/circuit.js"; +import { + PartialButDefined, + undefinedIfEmpty, + WithIntermediate, +} from "../utils.js"; +import { ErrorBanner } from "./BankFrame.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; + +const charset = + "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const upperIdx = charset.indexOf("A"); + +function randomPassword(): string { + const random = Array.from({ length: 16 }).map(() => { + return charset.charCodeAt(Math.random() * charset.length); + }); + // first char can't be upper + const charIdx = charset.indexOf(String.fromCharCode(random[0])); + random[0] = + charIdx > upperIdx ? charset.charCodeAt(charIdx - upperIdx) : random[0]; + return String.fromCharCode(...random); +} + +interface Props { + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; +} +/** + * Query account information and show QR code if there is pending withdrawal + */ +export function AdminPage({ onLoadNotOk }: Props): VNode { + const [account, setAccount] = useState<string | undefined>(); + const [showDetails, setShowDetails] = useState<string | undefined>(); + const [updatePassword, setUpdatePassword] = useState<string | undefined>(); + const [createAccount, setCreateAccount] = useState(false); + const { pageStateSetter } = usePageContext(); + + function showInfoMessage(info: TranslatedString): void { + pageStateSetter((prev) => ({ + ...prev, + info, + })); + } + + const result = useAccounts({ account }); + const { i18n } = useTranslationContext(); + + if (result.loading) return <div />; + if (!result.ok) { + return onLoadNotOk(result); + } + + const { customers } = result.data; + + if (showDetails) { + return ( + <ShowAccountDetails + account={showDetails} + onLoadNotOk={onLoadNotOk} + onUpdateSuccess={() => { + showInfoMessage(i18n.str`Account updated`); + setShowDetails(undefined); + }} + onClear={() => { + setShowDetails(undefined); + }} + /> + ); + } + if (updatePassword) { + return ( + <UpdateAccountPassword + account={updatePassword} + onLoadNotOk={onLoadNotOk} + onUpdateSuccess={() => { + showInfoMessage(i18n.str`Password changed`); + setUpdatePassword(undefined); + }} + onClear={() => { + setUpdatePassword(undefined); + }} + /> + ); + } + if (createAccount) { + return ( + <CreateNewAccount + onClose={() => setCreateAccount(false)} + onCreateSuccess={(password) => { + showInfoMessage( + i18n.str`Account created with password "${password}"`, + ); + setCreateAccount(false); + }} + /> + ); + } + return ( + <Fragment> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div></div> + <div> + <input + class="pure-button pure-button-primary content" + type="submit" + value={i18n.str`Create account`} + onClick={async (e) => { + e.preventDefault(); + + setCreateAccount(true); + }} + /> + </div> + </div> + </p> + + <section id="main"> + <article> + <h2>{i18n.str`Accounts:`}</h2> + <div class="results"> + <table class="pure-table pure-table-striped"> + <thead> + <tr> + <th>{i18n.str`Username`}</th> + <th>{i18n.str`Name`}</th> + <th></th> + </tr> + </thead> + <tbody> + {customers.map((item, idx) => { + return ( + <tr key={idx}> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowDetails(item.username); + }} + > + {item.username} + </a> + </td> + <td>{item.name}</td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setUpdatePassword(item.username); + }} + > + change password + </a> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + </article> + </section> + </Fragment> + ); +} + +const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; + +function initializeFromTemplate( + account: SandboxBackend.Circuit.CircuitAccountData | undefined, +): WithIntermediate<SandboxBackend.Circuit.CircuitAccountData> { + const emptyAccount = { + cashout_address: undefined, + iban: undefined, + name: undefined, + username: undefined, + contact_data: undefined, + }; + const emptyContact = { + email: undefined, + phone: undefined, + }; + + const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> = + structuredClone(account) ?? emptyAccount; + if (typeof initial.contact_data === "undefined") { + initial.contact_data = emptyContact; + } + initial.contact_data.email; + return initial as any; +} + +function UpdateAccountPassword({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { changePassword } = useAdminAccountAPI(); + const [password, setPassword] = useState<string | undefined>(); + const [repeat, setRepeat] = useState<string | undefined>(); + const [error, saveError] = useState<ErrorMessage | undefined>(); + + if (result.clientError) { + if (result.isNotfound) return <div>account not found</div>; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + const errors = undefinedIfEmpty({ + password: !password ? i18n.str`required` : undefined, + repeat: !repeat + ? i18n.str`required` + : password !== repeat + ? i18n.str`password doesn't match` + : undefined, + }); + + return ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + + <form class="pure-form"> + <fieldset> + <label for="username">{i18n.str`Username`}</label> + <input name="username" type="text" readOnly value={account} /> + </fieldset> + <fieldset> + <label>{i18n.str`Password`}</label> + <input + type="password" + value={password ?? ""} + onChange={(e) => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Repeast password`}</label> + <input + type="password" + value={repeat ?? ""} + onChange={(e) => { + setRepeat(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.repeat} + isDirty={repeat !== undefined} + /> + </fieldset> + </form> + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClear(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={!!errors} + type="submit" + value={i18n.str`Confirm`} + onClick={async (e) => { + e.preventDefault(); + if (!!errors || !password) return; + try { + const r = await changePassword(account, { + new_password: password, + }); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + +function CreateNewAccount({ + onClose, + onCreateSuccess, +}: { + onClose: () => void; + onCreateSuccess: (password: string) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { createAccount } = useAdminAccountAPI(); + const [submitAccount, setSubmitAccount] = useState< + SandboxBackend.Circuit.CircuitAccountData | undefined + >(); + const [error, saveError] = useState<ErrorMessage | undefined>(); + return ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + + <AccountForm + template={undefined} + purpose="create" + onChange={(a) => setSubmitAccount(a)} + /> + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClose(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={!submitAccount} + type="submit" + value={i18n.str`Confirm`} + onClick={async (e) => { + e.preventDefault(); + + if (!submitAccount) return; + try { + const account: SandboxBackend.Circuit.CircuitAccountRequest = + { + cashout_address: submitAccount.cashout_address, + contact_data: submitAccount.contact_data, + internal_iban: submitAccount.iban, + name: submitAccount.name, + username: submitAccount.username, + password: randomPassword(), + }; + + await createAccount(account); + onCreateSuccess(account.password); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + +function ShowAccountDetails({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { updateAccount } = useAdminAccountAPI(); + const [update, setUpdate] = useState(false); + const [submitAccount, setSubmitAccount] = useState< + SandboxBackend.Circuit.CircuitAccountData | undefined + >(); + const [error, saveError] = useState<ErrorMessage | undefined>(); + + if (result.clientError) { + if (result.isNotfound) return <div>account not found</div>; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + return ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + <AccountForm + template={result.data} + purpose={update ? "update" : "show"} + onChange={(a) => setSubmitAccount(a)} + /> + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClear(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={update && !submitAccount} + type="submit" + value={update ? i18n.str`Confirm` : i18n.str`Update`} + onClick={async (e) => { + e.preventDefault(); + + if (!update) { + setUpdate(true); + } else { + if (!submitAccount) return; + try { + await updateAccount(account, { + cashout_address: submitAccount.cashout_address, + contact_data: submitAccount.contact_data, + }); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + +/** + * Create valid account object to update or create + * Take template as initial values for the form + * Purpose indicate if all field al read only (show), part of them (update) + * or none (create) + * @param param0 + * @returns + */ +function AccountForm({ + template, + purpose, + onChange, +}: { + template: SandboxBackend.Circuit.CircuitAccountData | undefined; + onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void; + purpose: "create" | "update" | "show"; +}): VNode { + const initial = initializeFromTemplate(template); + const [form, setForm] = useState(initial); + const [errors, setErrors] = useState<typeof initial | undefined>(undefined); + const { i18n } = useTranslationContext(); + + function updateForm(newForm: typeof initial): void { + const parsed = !newForm.cashout_address + ? undefined + : parsePaytoUri(newForm.cashout_address); + + const validationResult = undefinedIfEmpty<typeof initial>({ + cashout_address: !newForm.cashout_address + ? i18n.str`required` + : !parsed + ? i18n.str`does not follow the pattern` + : !parsed.isKnown || parsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !IBAN_REGEX.test(parsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : undefined, + contact_data: { + email: !newForm.contact_data.email + ? undefined + : !EMAIL_REGEX.test(newForm.contact_data.email) + ? i18n.str`it should be an email` + : undefined, + phone: !newForm.contact_data.phone + ? undefined + : !newForm.contact_data.phone.startsWith("+") + ? i18n.str`should start with +` + : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone) + ? i18n.str`phone number can't have other than numbers` + : undefined, + }, + iban: !newForm.iban + ? i18n.str`required` + : !IBAN_REGEX.test(newForm.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : undefined, + name: !newForm.name ? i18n.str`required` : undefined, + username: !newForm.username ? i18n.str`required` : undefined, + }); + + setErrors(validationResult); + setForm(newForm); + onChange(validationResult === undefined ? undefined : (newForm as any)); + } + + return ( + <form class="pure-form"> + <fieldset> + <label for="username">{i18n.str`Username`}</label> + <input + name="username" + type="text" + disabled={purpose !== "create"} + value={form.username} + onChange={(e) => { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={form.username !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Name`}</label> + <input + disabled={purpose !== "create"} + value={form.name ?? ""} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`IBAN`}</label> + <input + disabled={purpose !== "create"} + value={form.iban ?? ""} + onChange={(e) => { + form.iban = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.iban} + isDirty={form.iban !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Email`}</label> + <input + disabled={purpose === "show"} + value={form.contact_data.email ?? ""} + onChange={(e) => { + form.contact_data.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.contact_data.email} + isDirty={form.contact_data.email !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Phone`}</label> + <input + disabled={purpose === "show"} + value={form.contact_data.phone ?? ""} + onChange={(e) => { + form.contact_data.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.contact_data.phone} + isDirty={form.contact_data?.phone !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Cashout address`}</label> + <input + disabled={purpose === "show"} + value={form.cashout_address ?? ""} + onChange={(e) => { + form.cashout_address = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.cashout_address} + isDirty={form.cashout_address !== undefined} + /> + </fieldset> + </form> + ); +} + +function handleError( + error: unknown, + saveError: (e: ErrorMessage) => void, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): void { + if (error instanceof RequestError) { + const payload = error.info.error as SandboxBackend.SandboxError; + saveError({ + title: error.info.serverError + ? i18n.str`Server had an error` + : i18n.str`Server didn't accept the request`, + description: payload.error.description, + }); + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Could not update account`, + description: error.message, + }); + } else { + saveError({ + title: i18n.str`Error, please report`, + debug: JSON.stringify(error), + }); + } +} diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index e36629e2a..ed36daa21 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -19,7 +19,11 @@ import { ComponentChildren, Fragment, h, VNode } from "preact"; import talerLogo from "../assets/logo-white.svg"; import { LangSelectorLikePy as LangSelector } from "../components/LangSelector.js"; import { useBackendContext } from "../context/backend.js"; -import { PageStateType, usePageContext } from "../context/pageState.js"; +import { + ErrorMessage, + PageStateType, + usePageContext, +} from "../context/pageState.js"; import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { bankUiSettings } from "../settings.js"; @@ -42,7 +46,7 @@ export function BankFrame({ onClick={() => { pageStateSetter((prevState: PageStateType) => { const { talerWithdrawUri, withdrawalId, ...rest } = prevState; - backend.clear(); + backend.logOut(); return { ...rest, withdrawalInProgress: false, @@ -107,7 +111,14 @@ export function BankFrame({ </nav> </div> <section id="main" class="content"> - <ErrorBanner /> + {pageState.error && ( + <ErrorBanner + error={pageState.error} + onClear={() => { + pageStateSetter((prev) => ({ ...prev, error: undefined })); + }} + /> + )} <StatusBanner /> {backend.state.status === "loggedIn" ? logOut : null} {children} @@ -136,33 +147,34 @@ function maybeDemoContent(content: VNode): VNode { return <Fragment />; } -function ErrorBanner(): VNode | null { - const { pageState, pageStateSetter } = usePageContext(); - - if (!pageState.error) return null; - - const rval = ( +export function ErrorBanner({ + error, + onClear, +}: { + error: ErrorMessage; + onClear: () => void; +}): VNode | null { + return ( <div class="informational informational-fail" style={{ marginTop: 8 }}> <div style={{ display: "flex", justifyContent: "space-between" }}> <p> - <b>{pageState.error.title}</b> + <b>{error.title}</b> </p> <div> <input type="button" class="pure-button" value="Clear" - onClick={async () => { - pageStateSetter((prev) => ({ ...prev, error: undefined })); + onClick={(e) => { + e.preventDefault(); + onClear(); }} /> </div> </div> - <p>{pageState.error.description}</p> + <p>{error.description}</p> </div> ); - delete pageState.error; - return rval; } function StatusBanner(): VNode | null { diff --git a/packages/demobank-ui/src/pages/HomePage.tsx b/packages/demobank-ui/src/pages/HomePage.tsx new file mode 100644 index 000000000..e60732d42 --- /dev/null +++ b/packages/demobank-ui/src/pages/HomePage.tsx @@ -0,0 +1,149 @@ +/* + 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 { Logger } from "@gnu-taler/taler-util"; +import { + HttpResponsePaginated, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { Fragment, h, VNode } from "preact"; +import { Loading } from "../components/Loading.js"; +import { useBackendContext } from "../context/backend.js"; +import { PageStateType, usePageContext } from "../context/pageState.js"; +import { AccountPage } from "./AccountPage.js"; +import { AdminPage } from "./AdminPage.js"; +import { LoginForm } from "./LoginForm.js"; +import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; + +const logger = new Logger("AccountPage"); + +/** + * show content based on state: + * - LoginForm if the user is not logged in + * - qr code if withdrawal in progress + * - else account information + * Use the handler to catch error cases + * + * @param param0 + * @returns + */ +export function HomePage({ onRegister }: { onRegister: () => void }): VNode { + const backend = useBackendContext(); + const { pageState, pageStateSetter } = usePageContext(); + const { i18n } = useTranslationContext(); + + function saveError(error: PageStateType["error"]): void { + pageStateSetter((prev) => ({ ...prev, error })); + } + + function saveErrorAndLogout(error: PageStateType["error"]): void { + saveError(error); + backend.logOut(); + } + + function clearCurrentWithdrawal(): void { + pageStateSetter((prevState: PageStateType) => { + return { + ...prevState, + withdrawalId: undefined, + talerWithdrawUri: undefined, + withdrawalInProgress: false, + }; + }); + } + + if (backend.state.status === "loggedOut") { + return <LoginForm onRegister={onRegister} />; + } + + const { withdrawalId, talerWithdrawUri } = pageState; + + if (talerWithdrawUri && withdrawalId) { + return ( + <WithdrawalQRCode + account={backend.state.username} + withdrawalId={withdrawalId} + talerWithdrawUri={talerWithdrawUri} + onAbort={clearCurrentWithdrawal} + onLoadNotOk={handleNotOkResult( + backend.state.username, + saveError, + i18n, + onRegister, + )} + /> + ); + } + + if (backend.state.isUserAdministrator) { + return ( + <AdminPage + onLoadNotOk={handleNotOkResult( + backend.state.username, + saveErrorAndLogout, + i18n, + onRegister, + )} + /> + ); + } + + return ( + <AccountPage + account={backend.state.username} + onLoadNotOk={handleNotOkResult( + backend.state.username, + saveErrorAndLogout, + i18n, + onRegister, + )} + /> + ); +} + +function handleNotOkResult( + account: string, + onErrorHandler: (state: PageStateType["error"]) => void, + i18n: ReturnType<typeof useTranslationContext>["i18n"], + onRegister: () => void, +): <T, E>(result: HttpResponsePaginated<T, E>) => VNode { + return function handleNotOkResult2<T, E>( + result: HttpResponsePaginated<T, E>, + ): VNode { + if (result.clientError && result.isUnauthorized) { + onErrorHandler({ + title: i18n.str`Wrong credentials for "${account}"`, + }); + return <LoginForm onRegister={onRegister} />; + } + if (result.clientError && result.isNotfound) { + onErrorHandler({ + title: i18n.str`Username or account label "${account}" not found`, + }); + return <LoginForm onRegister={onRegister} />; + } + if (result.loading) return <Loading />; + if (!result.ok) { + onErrorHandler({ + title: i18n.str`The backend reported a problem: HTTP status #${result.status}`, + description: `Diagnostic from ${result.info?.url.href} is "${result.message}"`, + debug: JSON.stringify(result.error), + }); + return <LoginForm onRegister={onRegister} />; + } + return <div />; + }; +} diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index a5d8695dc..3d4279f99 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -14,21 +14,19 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { h, VNode } from "preact"; -import { route } from "preact-router"; +import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; +import { Fragment, h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; -import { BackendStateHandler } from "../hooks/backend.js"; import { bankUiSettings } from "../settings.js"; -import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { PASSWORD_REGEX, USERNAME_REGEX } from "./RegistrationPage.js"; import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; -import { USERNAME_REGEX, PASSWORD_REGEX } from "./RegistrationPage.js"; /** * Collect and submit login data. */ -export function LoginForm(): VNode { +export function LoginForm({ onRegister }: { onRegister: () => void }): VNode { const backend = useBackendContext(); const [username, setUsername] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); @@ -52,107 +50,93 @@ export function LoginForm(): VNode { }); return ( - <div class="login-div"> - <form - class="login-form" - noValidate - onSubmit={(e) => { - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" - > - <div class="pure-form"> - <h2>{i18n.str`Please login!`}</h2> - <p class="unameFieldLabel loginFieldLabel formFieldLabel"> - <label for="username">{i18n.str`Username:`}</label> - </p> - <input - ref={ref} - autoFocus - type="text" - name="username" - id="username" - value={username ?? ""} - placeholder="Username" - required - onInput={(e): void => { - setUsername(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.username} - isDirty={username !== undefined} - /> - <p class="passFieldLabel loginFieldLabel formFieldLabel"> - <label for="password">{i18n.str`Password:`}</label> - </p> - <input - type="password" - name="password" - id="password" - value={password ?? ""} - placeholder="Password" - required - onInput={(e): void => { - setPassword(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.password} - isDirty={password !== undefined} - /> - <br /> - <button - type="submit" - class="pure-button pure-button-primary" - disabled={!!errors} - onClick={(e) => { - e.preventDefault(); - if (!username || !password) return; - loginCall({ username, password }, backend); - setUsername(undefined); - setPassword(undefined); - }} - > - {i18n.str`Login`} - </button> + <Fragment> + <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> - {bankUiSettings.allowRegistrations ? ( + <div class="login-div"> + <form + class="login-form" + noValidate + onSubmit={(e) => { + e.preventDefault(); + }} + autoCapitalize="none" + autoCorrect="off" + > + <div class="pure-form"> + <h2>{i18n.str`Please login!`}</h2> + <p class="unameFieldLabel loginFieldLabel formFieldLabel"> + <label for="username">{i18n.str`Username:`}</label> + </p> + <input + ref={ref} + autoFocus + type="text" + name="username" + id="username" + value={username ?? ""} + placeholder="Username" + autocomplete="username" + required + onInput={(e): void => { + setUsername(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={username !== undefined} + /> + <p class="passFieldLabel loginFieldLabel formFieldLabel"> + <label for="password">{i18n.str`Password:`}</label> + </p> + <input + type="password" + name="password" + id="password" + autocomplete="current-password" + value={password ?? ""} + placeholder="Password" + required + onInput={(e): void => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + <br /> <button - class="pure-button pure-button-secondary btn-cancel" + type="submit" + class="pure-button pure-button-primary" + disabled={!!errors} onClick={(e) => { e.preventDefault(); - route("/register"); + if (!username || !password) return; + backend.logIn({ username, password }); + setUsername(undefined); + setPassword(undefined); }} > - {i18n.str`Register`} + {i18n.str`Login`} </button> - ) : ( - <div /> - )} - </div> - </form> - </div> - ); -} - -async function loginCall( - req: { username: string; password: string }, - /** - * FIXME: figure out if the two following - * functions can be retrieved from the state. - */ - backend: BackendStateHandler, -): Promise<void> { - /** - * Optimistically setting the state as 'logged in', and - * let the Account component request the balance to check - * whether the credentials are valid. */ - backend.save({ - url: getBankBackendBaseUrl(), - username: req.username, - password: req.password, - }); + {bankUiSettings.allowRegistrations ? ( + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={(e) => { + e.preventDefault(); + onRegister(); + }} + > + {i18n.str`Register`} + </button> + ) : ( + <div /> + )} + </div> + </form> + </div> + </Fragment> + ); } diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index ae876d556..dd04ed6e2 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -19,17 +19,22 @@ import { useState } from "preact/hooks"; import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; +import { PageStateType, usePageContext } from "../context/pageState.js"; /** * Let the user choose a payment option, * then specify the details trigger the action. */ -export function PaymentOptions({ currency }: { currency?: string }): VNode { +export function PaymentOptions({ currency }: { currency: string }): VNode { const { i18n } = useTranslationContext(); + const { pageStateSetter } = usePageContext(); const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">( "charge-wallet", ); + function saveError(error: PageStateType["error"]): void { + pageStateSetter((prev) => ({ ...prev, error })); + } return ( <article> @@ -55,13 +60,35 @@ export function PaymentOptions({ currency }: { currency?: string }): VNode { {tab === "charge-wallet" && ( <div id="charge-wallet" class="tabcontent active"> <h3>{i18n.str`Obtain digital cash`}</h3> - <WalletWithdrawForm focus currency={currency} /> + <WalletWithdrawForm + focus + currency={currency} + onSuccess={(data) => { + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + withdrawalInProgress: true, + talerWithdrawUri: data.taler_withdraw_uri, + withdrawalId: data.withdrawal_id, + })); + }} + onError={saveError} + /> </div> )} {tab === "wire-transfer" && ( <div id="wire-transfer" class="tabcontent active"> <h3>{i18n.str`Transfer to bank account`}</h3> - <PaytoWireTransferForm focus currency={currency} /> + <PaytoWireTransferForm + focus + currency={currency} + onSuccess={() => { + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + info: i18n.str`Wire transfer created!`, + })); + }} + onError={saveError} + /> </div> )} </div> diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index 46b006880..d859b1cc7 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -14,64 +14,81 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, Logger, parsePaytoUri } from "@gnu-taler/taler-util"; -import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser"; -import { h, VNode } from "preact"; -import { StateUpdater, useEffect, useRef, useState } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; -import { PageStateType, usePageContext } from "../context/pageState.js"; +import { + Amounts, + buildPayto, + Logger, + parsePaytoUri, + stringifyPaytoUri, +} from "@gnu-taler/taler-util"; import { InternationalizationAPI, + RequestError, useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; +import { h, VNode } from "preact"; +import { StateUpdater, useEffect, useRef, useState } from "preact/hooks"; +import { useBackendContext } from "../context/backend.js"; +import { PageStateType, usePageContext } from "../context/pageState.js"; +import { useAccessAPI } from "../hooks/access.js"; import { BackendState } from "../hooks/backend.js"; -import { prepareHeaders, undefinedIfEmpty } from "../utils.js"; +import { undefinedIfEmpty } from "../utils.js"; import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; const logger = new Logger("PaytoWireTransferForm"); export function PaytoWireTransferForm({ focus, + onError, + onSuccess, currency, }: { focus?: boolean; - currency?: string; + onError: (e: PageStateType["error"]) => void; + onSuccess: () => void; + currency: string; }): VNode { const backend = useBackendContext(); - const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button? + // const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button? - const [submitData, submitDataSetter] = useWireTransferRequestType(); + const [isRawPayto, setIsRawPayto] = useState(false); + // const [submitData, submitDataSetter] = useWireTransferRequestType(); + const [iban, setIban] = useState<string | undefined>(undefined); + const [subject, setSubject] = useState<string | undefined>(undefined); + const [amount, setAmount] = useState<string | undefined>(undefined); const [rawPaytoInput, rawPaytoInputSetter] = useState<string | undefined>( undefined, ); const { i18n } = useTranslationContext(); const ibanRegex = "^[A-Z][A-Z][0-9]+$"; - let transactionData: TransactionRequestType; const ref = useRef<HTMLInputElement>(null); useEffect(() => { if (focus) ref.current?.focus(); - }, [focus, pageState.isRawPayto]); + }, [focus, isRawPayto]); let parsedAmount = undefined; + const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; const errorsWire = undefinedIfEmpty({ - iban: !submitData?.iban + iban: !iban ? i18n.str`Missing IBAN` - : !/^[A-Z0-9]*$/.test(submitData.iban) + : !IBAN_REGEX.test(iban) ? i18n.str`IBAN should have just uppercased letters and numbers` : undefined, - subject: !submitData?.subject ? i18n.str`Missing subject` : undefined, - amount: !submitData?.amount + subject: !subject ? i18n.str`Missing subject` : undefined, + amount: !amount ? i18n.str`Missing amount` - : !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`)) + : !(parsedAmount = Amounts.parse(`${currency}:${amount}`)) ? i18n.str`Amount is not valid` : Amounts.isZero(parsedAmount) ? i18n.str`Should be greater than 0` : undefined, }); - if (!pageState.isRawPayto) + const { createTransaction } = useAccessAPI(); + + if (!isRawPayto) return ( <div> <form @@ -90,21 +107,18 @@ export function PaytoWireTransferForm({ type="text" id="iban" name="iban" - value={submitData?.iban ?? ""} + value={iban ?? ""} placeholder="CC0123456789" required pattern={ibanRegex} onInput={(e): void => { - submitDataSetter((submitData) => ({ - ...submitData, - iban: e.currentTarget.value, - })); + setIban(e.currentTarget.value); }} /> <br /> <ShowInputErrorLabel message={errorsWire?.iban} - isDirty={submitData?.iban !== undefined} + isDirty={iban !== undefined} /> <br /> <label for="subject">{i18n.str`Transfer subject:`}</label> @@ -113,19 +127,16 @@ export function PaytoWireTransferForm({ name="subject" id="subject" placeholder="subject" - value={submitData?.subject ?? ""} + value={subject ?? ""} required onInput={(e): void => { - submitDataSetter((submitData) => ({ - ...submitData, - subject: e.currentTarget.value, - })); + setSubject(e.currentTarget.value); }} /> <br /> <ShowInputErrorLabel message={errorsWire?.subject} - isDirty={submitData?.subject !== undefined} + isDirty={subject !== undefined} /> <br /> <label for="amount">{i18n.str`Amount:`}</label> @@ -146,18 +157,15 @@ export function PaytoWireTransferForm({ id="amount" placeholder="amount" required - value={submitData?.amount ?? ""} + value={amount ?? ""} onInput={(e): void => { - submitDataSetter((submitData) => ({ - ...submitData, - amount: e.currentTarget.value, - })); + setAmount(e.currentTarget.value); }} /> </div> <ShowInputErrorLabel message={errorsWire?.amount} - isDirty={submitData?.amount !== undefined} + isDirty={amount !== undefined} /> </p> @@ -169,43 +177,28 @@ export function PaytoWireTransferForm({ value="Send" onClick={async (e) => { e.preventDefault(); - if ( - typeof submitData === "undefined" || - typeof submitData.iban === "undefined" || - submitData.iban === "" || - typeof submitData.subject === "undefined" || - submitData.subject === "" || - typeof submitData.amount === "undefined" || - submitData.amount === "" - ) { - logger.error("Not all the fields were given."); - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, - - error: { - title: i18n.str`Field(s) missing.`, - }, - })); + if (!(iban && subject && amount)) { return; } - transactionData = { - paytoUri: `payto://iban/${ - submitData.iban - }?message=${encodeURIComponent(submitData.subject)}`, - amount: `${currency}:${submitData.amount}`, - }; - return await createTransactionCall( - transactionData, - backend.state, - pageStateSetter, - () => - submitDataSetter((p) => ({ - amount: undefined, - iban: undefined, - subject: undefined, - })), - i18n, - ); + const ibanPayto = buildPayto("iban", iban, undefined); + ibanPayto.params.message = encodeURIComponent(subject); + const paytoUri = stringifyPaytoUri(ibanPayto); + + await createTransaction({ + paytoUri, + amount: `${currency}:${amount}`, + }); + // return await createTransactionCall( + // transactionData, + // backend.state, + // pageStateSetter, + // () => { + // setAmount(undefined); + // setIban(undefined); + // setSubject(undefined); + // }, + // i18n, + // ); }} /> <input @@ -214,11 +207,9 @@ export function PaytoWireTransferForm({ value="Clear" onClick={async (e) => { e.preventDefault(); - submitDataSetter((p) => ({ - amount: undefined, - iban: undefined, - subject: undefined, - })); + setAmount(undefined); + setIban(undefined); + setSubject(undefined); }} /> </p> @@ -227,11 +218,7 @@ export function PaytoWireTransferForm({ <a href="/account" onClick={() => { - logger.trace("switch to raw payto form"); - pageStateSetter((prevState) => ({ - ...prevState, - isRawPayto: true, - })); + setIsRawPayto(true); }} > {i18n.str`Want to try the raw payto://-format?`} @@ -240,11 +227,23 @@ export function PaytoWireTransferForm({ </div> ); + const parsed = !rawPaytoInput ? undefined : parsePaytoUri(rawPaytoInput); + const errorsPayto = undefinedIfEmpty({ rawPaytoInput: !rawPaytoInput - ? i18n.str`Missing payto address` - : !parsePaytoUri(rawPaytoInput) - ? i18n.str`Payto does not follow the pattern` + ? i18n.str`required` + : !parsed + ? i18n.str`does not follow the pattern` + : !parsed.params.amount + ? i18n.str`use the "amount" parameter to specify the amount to be transferred` + : Amounts.parse(parsed.params.amount) === undefined + ? i18n.str`the amount is not valid` + : !parsed.params.message + ? i18n.str`use the "message" parameter to specify a reference text for the transfer` + : !parsed.isKnown || parsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !IBAN_REGEX.test(parsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` : undefined, }); @@ -296,25 +295,29 @@ export function PaytoWireTransferForm({ disabled={!!errorsPayto} value={i18n.str`Send`} onClick={async () => { - // empty string evaluates to false. if (!rawPaytoInput) { logger.error("Didn't get any raw Payto string!"); return; } - transactionData = { paytoUri: rawPaytoInput }; - if ( - typeof transactionData.paytoUri === "undefined" || - transactionData.paytoUri.length === 0 - ) - return; - return await createTransactionCall( - transactionData, - backend.state, - pageStateSetter, - () => rawPaytoInputSetter(undefined), - i18n, - ); + try { + await createTransaction({ + paytoUri: rawPaytoInput, + }); + onSuccess(); + rawPaytoInputSetter(undefined); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + + onError({ + title: i18n.str`Transfer creation gave response error`, + description: errorData.error.description, + debug: JSON.stringify(errorData), + }); + } + } }} /> </p> @@ -322,11 +325,7 @@ export function PaytoWireTransferForm({ <a href="/account" onClick={() => { - logger.trace("switch to wire-transfer-form"); - pageStateSetter((prevState) => ({ - ...prevState, - isRawPayto: false, - })); + setIsRawPayto(false); }} > {i18n.str`Use wire-transfer form?`} @@ -336,115 +335,3 @@ export function PaytoWireTransferForm({ </div> ); } - -/** - * Stores in the state a object representing a wire transfer, - * in order to avoid losing the handle of the data entered by - * the user in <input> fields. FIXME: name not matching the - * purpose, as this is not a HTTP request body but rather the - * state of the <input>-elements. - */ -type WireTransferRequestTypeOpt = WireTransferRequestType | undefined; -function useWireTransferRequestType( - state?: WireTransferRequestType, -): [WireTransferRequestTypeOpt, StateUpdater<WireTransferRequestTypeOpt>] { - const ret = useLocalStorage( - "wire-transfer-request-state", - JSON.stringify(state), - ); - const retObj: WireTransferRequestTypeOpt = ret[0] - ? JSON.parse(ret[0]) - : ret[0]; - const retSetter: StateUpdater<WireTransferRequestTypeOpt> = function (val) { - const newVal = - val instanceof Function - ? JSON.stringify(val(retObj)) - : JSON.stringify(val); - ret[1](newVal); - }; - return [retObj, retSetter]; -} - -/** - * This function creates a new transaction. It reads a Payto - * address entered by the user and POSTs it to the bank. No - * sanity-check of the input happens before the POST as this is - * already conducted by the backend. - */ -async function createTransactionCall( - req: TransactionRequestType, - backendState: BackendState, - pageStateSetter: StateUpdater<PageStateType>, - /** - * Optional since the raw payto form doesn't have - * a stateful management of the input data yet. - */ - cleanUpForm: () => void, - i18n: InternationalizationAPI, -): Promise<void> { - if (backendState.status === "loggedOut") { - logger.error("No credentials found."); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`No credentials found.`, - }, - })); - return; - } - let res: Response; - try { - const { username, password } = backendState; - const headers = prepareHeaders(username, password); - const url = new URL( - `access-api/accounts/${backendState.username}/transactions`, - backendState.url, - ); - res = await fetch(url.href, { - method: "POST", - headers, - body: JSON.stringify(req), - }); - } catch (error) { - logger.error("Could not POST transaction request to the bank", error); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`Could not create the wire transfer`, - description: (error as any).error.description, - debug: JSON.stringify(error), - }, - })); - return; - } - // POST happened, status not sure yet. - if (!res.ok) { - const response = await res.json(); - logger.error( - `Transfer creation gave response error: ${response} (${res.status})`, - ); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`Transfer creation gave response error`, - description: response.error.description, - debug: JSON.stringify(response), - }, - })); - return; - } - // status is 200 OK here, tell the user. - logger.trace("Wire transfer created!"); - pageStateSetter((prevState) => ({ - ...prevState, - - info: i18n.str`Wire transfer created!`, - })); - - // Only at this point the input data can - // be discarded. - cleanUpForm(); -} diff --git a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx index 7bf5c41c7..54a77b42a 100644 --- a/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx +++ b/packages/demobank-ui/src/pages/PublicHistoriesPage.tsx @@ -15,91 +15,42 @@ */ import { Logger } from "@gnu-taler/taler-util"; -import { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import { route } from "preact-router"; +import { + HttpResponsePaginated, + useLocalStorage, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { Fragment, h, VNode } from "preact"; import { StateUpdater } from "preact/hooks"; -import useSWR, { SWRConfig } from "swr"; -import { PageStateType, usePageContext } from "../context/pageState.js"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; -import { getBankBackendBaseUrl } from "../utils.js"; -import { BankFrame } from "./BankFrame.js"; import { Transactions } from "../components/Transactions/index.js"; +import { usePublicAccounts } from "../hooks/access.js"; const logger = new Logger("PublicHistoriesPage"); -export function PublicHistoriesPage(): VNode { - return ( - <SWRWithoutCredentials baseUrl={getBankBackendBaseUrl()}> - <BankFrame> - <PublicHistories /> - </BankFrame> - </SWRWithoutCredentials> - ); -} - -function SWRWithoutCredentials({ - baseUrl, - children, -}: { - children: ComponentChildren; - baseUrl: string; -}): VNode { - logger.trace("Base URL", baseUrl); - return ( - <SWRConfig - value={{ - fetcher: (url: string) => - fetch(baseUrl + url || "").then((r) => { - if (!r.ok) throw { status: r.status, json: r.json() }; +// export function PublicHistoriesPage2(): VNode { +// return ( +// <BankFrame> +// <PublicHistories /> +// </BankFrame> +// ); +// } - return r.json(); - }), - }} - > - {children as any} - </SWRConfig> - ); +interface Props { + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; } /** * Show histories of public accounts. */ -function PublicHistories(): VNode { - const { pageState, pageStateSetter } = usePageContext(); +export function PublicHistoriesPage({ onLoadNotOk }: Props): VNode { const [showAccount, setShowAccount] = useShowPublicAccount(); - const { data, error } = useSWR("access-api/public-accounts"); const { i18n } = useTranslationContext(); - if (typeof error !== "undefined") { - switch (error.status) { - case 404: - logger.error("public accounts: 404", error); - route("/account"); - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, + const result = usePublicAccounts(); + if (!result.ok) return onLoadNotOk(result); - error: { - title: i18n.str`List of public accounts was not found.`, - debug: JSON.stringify(error), - }, - })); - break; - default: - logger.error("public accounts: non-404 error", error); - route("/account"); - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, + const { data } = result; - error: { - title: i18n.str`List of public accounts could not be retrieved.`, - debug: JSON.stringify(error), - }, - })); - break; - } - } - if (!data) return <p>Waiting public accounts list...</p>; const txs: Record<string, h.JSX.Element> = {}; const accountsBar = []; @@ -133,9 +84,7 @@ function PublicHistories(): VNode { </a> </li>, ); - txs[account.accountLabel] = ( - <Transactions accountLabel={account.accountLabel} pageNumber={0} /> - ); + txs[account.accountLabel] = <Transactions account={account.accountLabel} />; } return ( diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx index e02c6efb1..708e28657 100644 --- a/packages/demobank-ui/src/pages/QrCodeSection.tsx +++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -21,10 +21,10 @@ import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; export function QrCodeSection({ talerWithdrawUri, - abortButton, + onAbort, }: { talerWithdrawUri: string; - abortButton: h.JSX.Element; + onAbort: () => void; }): VNode { const { i18n } = useTranslationContext(); useEffect(() => { @@ -62,7 +62,10 @@ export function QrCodeSection({ </i18n.Translate> </p> <br /> - {abortButton} + <a + class="pure-button btn-cancel" + onClick={onAbort} + >{i18n.str`Abort`}</a> </div> </article> </section> diff --git a/packages/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx index 29f1bf5ee..247ef8d80 100644 --- a/packages/demobank-ui/src/pages/RegistrationPage.tsx +++ b/packages/demobank-ui/src/pages/RegistrationPage.tsx @@ -13,38 +13,36 @@ 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 { Logger } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { route } from "preact-router"; -import { StateUpdater, useState } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; -import { PageStateType, usePageContext } from "../context/pageState.js"; +import { HttpStatusCode, Logger } from "@gnu-taler/taler-util"; import { - InternationalizationAPI, + RequestError, useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; -import { BackendStateHandler } from "../hooks/backend.js"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useBackendContext } from "../context/backend.js"; +import { PageStateType } from "../context/pageState.js"; +import { useTestingAPI } from "../hooks/access.js"; import { bankUiSettings } from "../settings.js"; -import { getBankBackendBaseUrl, undefinedIfEmpty } from "../utils.js"; -import { BankFrame } from "./BankFrame.js"; +import { undefinedIfEmpty } from "../utils.js"; import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; const logger = new Logger("RegistrationPage"); -export function RegistrationPage(): VNode { +export function RegistrationPage({ + onError, + onComplete, +}: { + onComplete: () => void; + onError: (e: PageStateType["error"]) => void; +}): VNode { const { i18n } = useTranslationContext(); if (!bankUiSettings.allowRegistrations) { return ( - <BankFrame> - <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> - </BankFrame> + <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> ); } - return ( - <BankFrame> - <RegistrationForm /> - </BankFrame> - ); + return <RegistrationForm onComplete={onComplete} onError={onError} />; } export const USERNAME_REGEX = /^[a-z][a-zA-Z0-9]*$/; @@ -53,13 +51,19 @@ export const PASSWORD_REGEX = /^[a-z0-9][a-zA-Z0-9]*$/; /** * Collect and submit registration data. */ -function RegistrationForm(): VNode { +function RegistrationForm({ + onComplete, + onError, +}: { + onComplete: () => void; + onError: (e: PageStateType["error"]) => void; +}): VNode { const backend = useBackendContext(); - const { pageState, pageStateSetter } = usePageContext(); const [username, setUsername] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); + const { register } = useTestingAPI(); const { i18n } = useTranslationContext(); const errors = undefinedIfEmpty({ @@ -104,6 +108,7 @@ function RegistrationForm(): VNode { name="register-un" type="text" placeholder="Username" + autocomplete="username" value={username ?? ""} onInput={(e): void => { setUsername(e.currentTarget.value); @@ -121,6 +126,7 @@ function RegistrationForm(): VNode { name="register-pw" id="register-pw" placeholder="Password" + autocomplete="new-password" value={password ?? ""} required onInput={(e): void => { @@ -139,6 +145,7 @@ function RegistrationForm(): VNode { style={{ marginBottom: 8 }} name="register-repeat" id="register-repeat" + autocomplete="new-password" placeholder="Same password" value={repeatPassword ?? ""} required @@ -155,19 +162,42 @@ function RegistrationForm(): VNode { class="pure-button pure-button-primary btn-register" type="submit" disabled={!!errors} - onClick={(e) => { + onClick={async (e) => { e.preventDefault(); - if (!username || !password) return; - registrationCall( - { username, password }, - backend, // will store BE URL, if OK. - pageStateSetter, - i18n, - ); - setUsername(undefined); - setPassword(undefined); - setRepeatPassword(undefined); + if (!username || !password) return; + try { + const credentials = { username, password }; + await register(credentials); + setUsername(undefined); + setPassword(undefined); + setRepeatPassword(undefined); + backend.logIn(credentials); + onComplete(); + } catch (error) { + if (error instanceof RequestError) { + const errorData: SandboxBackend.SandboxError = + error.info.error; + if (error.info.status === HttpStatusCode.Conflict) { + onError({ + title: i18n.str`That username is already taken`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } else { + onError({ + title: i18n.str`New registration gave response error`, + description: errorData.error.description, + debug: JSON.stringify(error.info), + }); + } + } else if (error instanceof Error) { + onError({ + title: i18n.str`Registration failed, please report`, + description: error.message, + }); + } + } }} > {i18n.str`Register`} @@ -180,7 +210,7 @@ function RegistrationForm(): VNode { setUsername(undefined); setPassword(undefined); setRepeatPassword(undefined); - route("/account"); + onComplete(); }} > {i18n.str`Cancel`} @@ -192,83 +222,3 @@ function RegistrationForm(): VNode { </Fragment> ); } - -/** - * This function requests /register. - * - * This function is responsible to change two states: - * the backend's (to store the login credentials) and - * the page's (to indicate a successful login or a problem). - */ -async function registrationCall( - req: { username: string; password: string }, - /** - * FIXME: figure out if the two following - * functions can be retrieved somewhat from - * the state. - */ - backend: BackendStateHandler, - pageStateSetter: StateUpdater<PageStateType>, - i18n: InternationalizationAPI, -): Promise<void> { - const url = getBankBackendBaseUrl(); - - const headers = new Headers(); - headers.append("Content-Type", "application/json"); - const registerEndpoint = new URL("access-api/testing/register", url); - let res: Response; - try { - res = await fetch(registerEndpoint.href, { - method: "POST", - body: JSON.stringify({ - username: req.username, - password: req.password, - }), - headers, - }); - } catch (error) { - logger.error( - `Could not POST new registration to the bank (${registerEndpoint.href})`, - error, - ); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`Registration failed, please report`, - debug: JSON.stringify(error), - }, - })); - return; - } - if (!res.ok) { - const response = await res.json(); - if (res.status === 409) { - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`That username is already taken`, - debug: JSON.stringify(response), - }, - })); - } else { - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`New registration gave response error`, - debug: JSON.stringify(response), - }, - })); - } - } else { - // registration was ok - backend.save({ - url, - username: req.username, - password: req.password, - }); - route("/account"); - } -} diff --git a/packages/demobank-ui/src/pages/Routing.tsx b/packages/demobank-ui/src/pages/Routing.tsx index 3c3aae0ce..a88af9b0b 100644 --- a/packages/demobank-ui/src/pages/Routing.tsx +++ b/packages/demobank-ui/src/pages/Routing.tsx @@ -14,21 +14,97 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { + HttpResponsePaginated, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; import { createHashHistory } from "history"; import { h, VNode } from "preact"; import Router, { route, Route } from "preact-router"; import { useEffect } from "preact/hooks"; -import { AccountPage } from "./AccountPage.js"; +import { Loading } from "../components/Loading.js"; +import { PageStateType, usePageContext } from "../context/pageState.js"; +import { HomePage } from "./HomePage.js"; +import { BankFrame } from "./BankFrame.js"; import { PublicHistoriesPage } from "./PublicHistoriesPage.js"; import { RegistrationPage } from "./RegistrationPage.js"; +function handleNotOkResult( + safe: string, + saveError: (state: PageStateType["error"]) => void, + i18n: ReturnType<typeof useTranslationContext>["i18n"], +): <T, E>(result: HttpResponsePaginated<T, E>) => VNode { + return function handleNotOkResult2<T, E>( + result: HttpResponsePaginated<T, E>, + ): VNode { + if (result.clientError && result.isUnauthorized) { + route(safe); + return <Loading />; + } + if (result.clientError && result.isNotfound) { + route(safe); + return ( + <div>Page not found, you are going to be redirected to {safe}</div> + ); + } + if (result.loading) return <Loading />; + if (!result.ok) { + saveError({ + title: i18n.str`The backend reported a problem: HTTP status #${result.status}`, + description: i18n.str`Diagnostic from ${result.info?.url} is "${result.message}"`, + debug: JSON.stringify(result.error), + }); + route(safe); + } + return <div />; + }; +} + export function Routing(): VNode { const history = createHashHistory(); + const { pageStateSetter } = usePageContext(); + + function saveError(error: PageStateType["error"]): void { + pageStateSetter((prev) => ({ ...prev, error })); + } + const { i18n } = useTranslationContext(); return ( <Router history={history}> - <Route path="/public-accounts" component={PublicHistoriesPage} /> - <Route path="/register" component={RegistrationPage} /> - <Route path="/account" component={AccountPage} /> + <Route + path="/public-accounts" + component={() => ( + <BankFrame> + <PublicHistoriesPage + onLoadNotOk={handleNotOkResult("/account", saveError, i18n)} + /> + </BankFrame> + )} + /> + <Route + path="/register" + component={() => ( + <BankFrame> + <RegistrationPage + onError={saveError} + onComplete={() => { + route("/account"); + }} + /> + </BankFrame> + )} + /> + <Route + path="/account" + component={() => ( + <BankFrame> + <HomePage + onRegister={() => { + route("/register"); + }} + /> + </BankFrame> + )} + /> <Route default component={Redirect} to="/account" /> </Router> ); diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx index a1b616657..2b2df3baa 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -14,36 +14,54 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Logger } from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { StateUpdater, useEffect, useRef } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; -import { PageStateType, usePageContext } from "../context/pageState.js"; +import { Amounts, Logger } from "@gnu-taler/taler-util"; import { - InternationalizationAPI, + RequestError, useTranslationContext, } from "@gnu-taler/web-util/lib/index.browser"; -import { BackendState } from "../hooks/backend.js"; -import { prepareHeaders, validateAmount } from "../utils.js"; +import { h, VNode } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { PageStateType, usePageContext } from "../context/pageState.js"; +import { useAccessAPI } from "../hooks/access.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; const logger = new Logger("WalletWithdrawForm"); export function WalletWithdrawForm({ focus, currency, + onError, + onSuccess, }: { - currency?: string; + currency: string; focus?: boolean; + onError: (e: PageStateType["error"]) => void; + onSuccess: ( + data: SandboxBackend.Access.BankAccountCreateWithdrawalResponse, + ) => void; }): VNode { - const backend = useBackendContext(); - const { pageState, pageStateSetter } = usePageContext(); + // const backend = useBackendContext(); + // const { pageState, pageStateSetter } = usePageContext(); const { i18n } = useTranslationContext(); - let submitAmount: string | undefined = "5.00"; + const { createWithdrawal } = useAccessAPI(); + const [amount, setAmount] = useState<string | undefined>("5.00"); const ref = useRef<HTMLInputElement>(null); useEffect(() => { if (focus) ref.current?.focus(); }, [focus]); + + const amountFloat = amount ? parseFloat(amount) : undefined; + const errors = undefinedIfEmpty({ + amount: !amountFloat + ? i18n.str`required` + : Number.isNaN(amountFloat) + ? i18n.str`should be a number` + : amountFloat < 0 + ? i18n.str`should be positive` + : undefined, + }); return ( <form id="reserve-form" @@ -63,8 +81,8 @@ export function WalletWithdrawForm({ type="text" readonly class="currency-indicator" - size={currency?.length ?? 5} - maxLength={currency?.length} + size={currency.length} + maxLength={currency.length} tabIndex={-1} value={currency} /> @@ -74,14 +92,15 @@ export function WalletWithdrawForm({ ref={ref} id="withdraw-amount" name="withdraw-amount" - value={submitAmount} + value={amount ?? ""} onChange={(e): void => { - // FIXME: validate using 'parseAmount()', - // deactivate submit button as long as - // amount is not valid - submitAmount = e.currentTarget.value; + setAmount(e.currentTarget.value); }} /> + <ShowInputErrorLabel + message={errors?.amount} + isDirty={amount !== undefined} + /> </div> </p> <p> @@ -90,22 +109,34 @@ export function WalletWithdrawForm({ id="select-exchange" class="pure-button pure-button-primary" type="submit" + disabled={!!errors} value={i18n.str`Withdraw`} - onClick={(e) => { + onClick={async (e) => { e.preventDefault(); - submitAmount = validateAmount(submitAmount); - /** - * By invalid amounts, the validator prints error messages - * on the console, and the browser colourizes the amount input - * box to indicate a error. - */ - if (!submitAmount && currency) return; - createWithdrawalCall( - `${currency}:${submitAmount}`, - backend.state, - pageStateSetter, - i18n, - ); + if (!amountFloat) return; + try { + const result = await createWithdrawal({ + amount: Amounts.stringify( + Amounts.fromFloat(amountFloat, currency), + ), + }); + + onSuccess(result.data); + } catch (error) { + if (error instanceof RequestError) { + onError({ + title: i18n.str`Could not create withdrawal operation`, + description: (error as any).error.description, + debug: JSON.stringify(error), + }); + } + if (error instanceof Error) { + onError({ + title: i18n.str`Something when wrong trying to start the withdrawal`, + description: error.message, + }); + } + } }} /> </div> @@ -114,84 +145,84 @@ export function WalletWithdrawForm({ ); } -/** - * This function creates a withdrawal operation via the Access API. - * - * After having successfully created the withdrawal operation, the - * user should receive a QR code of the "taler://withdraw/" type and - * supposed to scan it with their phone. - * - * TODO: (1) after the scan, the page should refresh itself and inform - * the user about the operation's outcome. (2) use POST helper. */ -async function createWithdrawalCall( - amount: string, - backendState: BackendState, - pageStateSetter: StateUpdater<PageStateType>, - i18n: InternationalizationAPI, -): Promise<void> { - if (backendState?.status === "loggedOut") { - logger.error("Page has a problem: no credentials found in the state."); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`No credentials given.`, - }, - })); - return; - } - - let res: Response; - try { - const { username, password } = backendState; - const headers = prepareHeaders(username, password); - - // Let bank generate withdraw URI: - const url = new URL( - `access-api/accounts/${backendState.username}/withdrawals`, - backendState.url, - ); - res = await fetch(url.href, { - method: "POST", - headers, - body: JSON.stringify({ amount }), - }); - } catch (error) { - logger.trace("Could not POST withdrawal request to the bank", error); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`Could not create withdrawal operation`, - description: (error as any).error.description, - debug: JSON.stringify(error), - }, - })); - return; - } - if (!res.ok) { - const response = await res.json(); - logger.error( - `Withdrawal creation gave response error: ${response} (${res.status})`, - ); - pageStateSetter((prevState) => ({ - ...prevState, - - error: { - title: i18n.str`Withdrawal creation gave response error`, - description: response.error.description, - debug: JSON.stringify(response), - }, - })); - return; - } - - logger.trace("Withdrawal operation created!"); - const resp = await res.json(); - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, - withdrawalInProgress: true, - talerWithdrawUri: resp.taler_withdraw_uri, - withdrawalId: resp.withdrawal_id, - })); -} +// /** +// * This function creates a withdrawal operation via the Access API. +// * +// * After having successfully created the withdrawal operation, the +// * user should receive a QR code of the "taler://withdraw/" type and +// * supposed to scan it with their phone. +// * +// * TODO: (1) after the scan, the page should refresh itself and inform +// * the user about the operation's outcome. (2) use POST helper. */ +// async function createWithdrawalCall( +// amount: string, +// backendState: BackendState, +// pageStateSetter: StateUpdater<PageStateType>, +// i18n: InternationalizationAPI, +// ): Promise<void> { +// if (backendState?.status === "loggedOut") { +// logger.error("Page has a problem: no credentials found in the state."); +// pageStateSetter((prevState) => ({ +// ...prevState, + +// error: { +// title: i18n.str`No credentials given.`, +// }, +// })); +// return; +// } + +// let res: Response; +// try { +// const { username, password } = backendState; +// const headers = prepareHeaders(username, password); + +// // Let bank generate withdraw URI: +// const url = new URL( +// `access-api/accounts/${backendState.username}/withdrawals`, +// backendState.url, +// ); +// res = await fetch(url.href, { +// method: "POST", +// headers, +// body: JSON.stringify({ amount }), +// }); +// } catch (error) { +// logger.trace("Could not POST withdrawal request to the bank", error); +// pageStateSetter((prevState) => ({ +// ...prevState, + +// error: { +// title: i18n.str`Could not create withdrawal operation`, +// description: (error as any).error.description, +// debug: JSON.stringify(error), +// }, +// })); +// return; +// } +// if (!res.ok) { +// const response = await res.json(); +// logger.error( +// `Withdrawal creation gave response error: ${response} (${res.status})`, +// ); +// pageStateSetter((prevState) => ({ +// ...prevState, + +// error: { +// title: i18n.str`Withdrawal creation gave response error`, +// description: response.error.description, +// debug: JSON.stringify(response), +// }, +// })); +// return; +// } + +// logger.trace("Withdrawal operation created!"); +// const resp = await res.json(); +// pageStateSetter((prevState: PageStateType) => ({ +// ...prevState, +// withdrawalInProgress: true, +// talerWithdrawUri: resp.taler_withdraw_uri, +// withdrawalId: resp.withdrawal_id, +// })); +// } diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index b87b77c83..4e5c621e2 100644 --- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -15,24 +15,29 @@ */ import { Logger } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { Fragment, h, VNode } from "preact"; -import { StateUpdater, useMemo, useState } from "preact/hooks"; +import { useMemo, useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; -import { PageStateType, usePageContext } from "../context/pageState.js"; -import { - InternationalizationAPI, - useTranslationContext, -} from "@gnu-taler/web-util/lib/index.browser"; -import { BackendState } from "../hooks/backend.js"; -import { prepareHeaders } from "../utils.js"; +import { usePageContext } from "../context/pageState.js"; +import { useAccessAPI } from "../hooks/access.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; const logger = new Logger("WithdrawalConfirmationQuestion"); +interface Props { + account: string; + withdrawalId: string; +} /** * Additional authentication required to complete the operation. * Not providing a back button, only abort. */ -export function WithdrawalConfirmationQuestion(): VNode { +export function WithdrawalConfirmationQuestion({ + account, + withdrawalId, +}: Props): VNode { const { pageState, pageStateSetter } = usePageContext(); const backend = useBackendContext(); const { i18n } = useTranslationContext(); @@ -42,10 +47,20 @@ export function WithdrawalConfirmationQuestion(): VNode { a: Math.floor(Math.random() * 10), b: Math.floor(Math.random() * 10), }; - }, [pageState.withdrawalId]); + }, []); + const { confirmWithdrawal, abortWithdrawal } = useAccessAPI(); const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>(); - + const answer = parseInt(captchaAnswer ?? "", 10); + const errors = undefinedIfEmpty({ + answer: !captchaAnswer + ? i18n.str`Answer the question before continue` + : Number.isNaN(answer) + ? i18n.str`The answer should be a number` + : answer !== captchaNumbers.a + captchaNumbers.b + ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.` + : undefined, + }); return ( <Fragment> <h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1> @@ -82,33 +97,49 @@ export function WithdrawalConfirmationQuestion(): VNode { setCaptchaAnswer(e.currentTarget.value); }} /> + <ShowInputErrorLabel + message={errors?.answer} + isDirty={captchaAnswer !== undefined} + /> </p> <p> <button type="submit" class="pure-button pure-button-primary btn-confirm" + disabled={!!errors} onClick={async (e) => { e.preventDefault(); - if ( - captchaAnswer == - (captchaNumbers.a + captchaNumbers.b).toString() - ) { - await confirmWithdrawalCall( - backend.state, - pageState.withdrawalId, - pageStateSetter, - i18n, - ); - return; + try { + await confirmWithdrawal(withdrawalId); + pageStateSetter((prevState) => { + const { talerWithdrawUri, ...rest } = prevState; + return { + ...rest, + info: i18n.str`Withdrawal confirmed!`, + }; + }); + } catch (error) { + pageStateSetter((prevState) => ({ + ...prevState, + error: { + title: i18n.str`Could not confirm the withdrawal`, + description: (error as any).error.description, + debug: JSON.stringify(error), + }, + })); } - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, - - error: { - title: i18n.str`The answer "${captchaAnswer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.`, - }, - })); - setCaptchaAnswer(undefined); + // if ( + // captchaAnswer == + // (captchaNumbers.a + captchaNumbers.b).toString() + // ) { + // await confirmWithdrawalCall( + // backend.state, + // pageState.withdrawalId, + // pageStateSetter, + // i18n, + // ); + // return; + // } }} > {i18n.str`Confirm`} @@ -118,12 +149,31 @@ export function WithdrawalConfirmationQuestion(): VNode { class="pure-button pure-button-secondary btn-cancel" onClick={async (e) => { e.preventDefault(); - await abortWithdrawalCall( - backend.state, - pageState.withdrawalId, - pageStateSetter, - i18n, - ); + try { + await abortWithdrawal(withdrawalId); + pageStateSetter((prevState) => { + const { talerWithdrawUri, ...rest } = prevState; + return { + ...rest, + info: i18n.str`Withdrawal confirmed!`, + }; + }); + } catch (error) { + pageStateSetter((prevState) => ({ + ...prevState, + error: { + title: i18n.str`Could not confirm the withdrawal`, + description: (error as any).error.description, + debug: JSON.stringify(error), + }, + })); + } + // await abortWithdrawalCall( + // backend.state, + // pageState.withdrawalId, + // pageStateSetter, + // i18n, + // ); }} > {i18n.str`Cancel`} @@ -156,188 +206,188 @@ export function WithdrawalConfirmationQuestion(): VNode { * This function will set the confirmation status in the * 'page state' and let the related components refresh. */ -async function confirmWithdrawalCall( - backendState: BackendState, - withdrawalId: string | undefined, - pageStateSetter: StateUpdater<PageStateType>, - i18n: InternationalizationAPI, -): Promise<void> { - if (backendState.status === "loggedOut") { - logger.error("No credentials found."); - pageStateSetter((prevState) => ({ - ...prevState, +// async function confirmWithdrawalCall( +// backendState: BackendState, +// withdrawalId: string | undefined, +// pageStateSetter: StateUpdater<PageStateType>, +// i18n: InternationalizationAPI, +// ): Promise<void> { +// if (backendState.status === "loggedOut") { +// logger.error("No credentials found."); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`No credentials found.`, - }, - })); - return; - } - if (typeof withdrawalId === "undefined") { - logger.error("No withdrawal ID found."); - pageStateSetter((prevState) => ({ - ...prevState, +// error: { +// title: i18n.str`No credentials found.`, +// }, +// })); +// return; +// } +// if (typeof withdrawalId === "undefined") { +// logger.error("No withdrawal ID found."); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`No withdrawal ID found.`, - }, - })); - return; - } - let res: Response; - try { - const { username, password } = backendState; - const headers = prepareHeaders(username, password); - /** - * NOTE: tests show that when a same object is being - * POSTed, caching might prevent same requests from being - * made. Hence, trying to POST twice the same amount might - * get silently ignored. - * - * headers.append("cache-control", "no-store"); - * headers.append("cache-control", "no-cache"); - * headers.append("pragma", "no-cache"); - * */ +// error: { +// title: i18n.str`No withdrawal ID found.`, +// }, +// })); +// return; +// } +// let res: Response; +// try { +// const { username, password } = backendState; +// const headers = prepareHeaders(username, password); +// /** +// * NOTE: tests show that when a same object is being +// * POSTed, caching might prevent same requests from being +// * made. Hence, trying to POST twice the same amount might +// * get silently ignored. +// * +// * headers.append("cache-control", "no-store"); +// * headers.append("cache-control", "no-cache"); +// * headers.append("pragma", "no-cache"); +// * */ - // Backend URL must have been stored _with_ a final slash. - const url = new URL( - `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`, - backendState.url, - ); - res = await fetch(url.href, { - method: "POST", - headers, - }); - } catch (error) { - logger.error("Could not POST withdrawal confirmation to the bank", error); - pageStateSetter((prevState) => ({ - ...prevState, +// // Backend URL must have been stored _with_ a final slash. +// const url = new URL( +// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/confirm`, +// backendState.url, +// ); +// res = await fetch(url.href, { +// method: "POST", +// headers, +// }); +// } catch (error) { +// logger.error("Could not POST withdrawal confirmation to the bank", error); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`Could not confirm the withdrawal`, - description: (error as any).error.description, - debug: JSON.stringify(error), - }, - })); - return; - } - if (!res || !res.ok) { - const response = await res.json(); - // assume not ok if res is null - logger.error( - `Withdrawal confirmation gave response error (${res.status})`, - res.statusText, - ); - pageStateSetter((prevState) => ({ - ...prevState, +// error: { +// title: i18n.str`Could not confirm the withdrawal`, +// description: (error as any).error.description, +// debug: JSON.stringify(error), +// }, +// })); +// return; +// } +// if (!res || !res.ok) { +// const response = await res.json(); +// // assume not ok if res is null +// logger.error( +// `Withdrawal confirmation gave response error (${res.status})`, +// res.statusText, +// ); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`Withdrawal confirmation gave response error`, - debug: JSON.stringify(response), - }, - })); - return; - } - logger.trace("Withdrawal operation confirmed!"); - pageStateSetter((prevState) => { - const { talerWithdrawUri, ...rest } = prevState; - return { - ...rest, +// error: { +// title: i18n.str`Withdrawal confirmation gave response error`, +// debug: JSON.stringify(response), +// }, +// })); +// return; +// } +// logger.trace("Withdrawal operation confirmed!"); +// pageStateSetter((prevState) => { +// const { talerWithdrawUri, ...rest } = prevState; +// return { +// ...rest, - info: i18n.str`Withdrawal confirmed!`, - }; - }); -} +// info: i18n.str`Withdrawal confirmed!`, +// }; +// }); +// } -/** - * Abort a withdrawal operation via the Access API's /abort. - */ -async function abortWithdrawalCall( - backendState: BackendState, - withdrawalId: string | undefined, - pageStateSetter: StateUpdater<PageStateType>, - i18n: InternationalizationAPI, -): Promise<void> { - if (backendState.status === "loggedOut") { - logger.error("No credentials found."); - pageStateSetter((prevState) => ({ - ...prevState, +// /** +// * Abort a withdrawal operation via the Access API's /abort. +// */ +// async function abortWithdrawalCall( +// backendState: BackendState, +// withdrawalId: string | undefined, +// pageStateSetter: StateUpdater<PageStateType>, +// i18n: InternationalizationAPI, +// ): Promise<void> { +// if (backendState.status === "loggedOut") { +// logger.error("No credentials found."); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`No credentials found.`, - }, - })); - return; - } - if (typeof withdrawalId === "undefined") { - logger.error("No withdrawal ID found."); - pageStateSetter((prevState) => ({ - ...prevState, +// error: { +// title: i18n.str`No credentials found.`, +// }, +// })); +// return; +// } +// if (typeof withdrawalId === "undefined") { +// logger.error("No withdrawal ID found."); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`No withdrawal ID found.`, - }, - })); - return; - } - let res: Response; - try { - const { username, password } = backendState; - const headers = prepareHeaders(username, password); - /** - * NOTE: tests show that when a same object is being - * POSTed, caching might prevent same requests from being - * made. Hence, trying to POST twice the same amount might - * get silently ignored. Needs more observation! - * - * headers.append("cache-control", "no-store"); - * headers.append("cache-control", "no-cache"); - * headers.append("pragma", "no-cache"); - * */ +// error: { +// title: i18n.str`No withdrawal ID found.`, +// }, +// })); +// return; +// } +// let res: Response; +// try { +// const { username, password } = backendState; +// const headers = prepareHeaders(username, password); +// /** +// * NOTE: tests show that when a same object is being +// * POSTed, caching might prevent same requests from being +// * made. Hence, trying to POST twice the same amount might +// * get silently ignored. Needs more observation! +// * +// * headers.append("cache-control", "no-store"); +// * headers.append("cache-control", "no-cache"); +// * headers.append("pragma", "no-cache"); +// * */ - // Backend URL must have been stored _with_ a final slash. - const url = new URL( - `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`, - backendState.url, - ); - res = await fetch(url.href, { method: "POST", headers }); - } catch (error) { - logger.error("Could not abort the withdrawal", error); - pageStateSetter((prevState) => ({ - ...prevState, +// // Backend URL must have been stored _with_ a final slash. +// const url = new URL( +// `access-api/accounts/${backendState.username}/withdrawals/${withdrawalId}/abort`, +// backendState.url, +// ); +// res = await fetch(url.href, { method: "POST", headers }); +// } catch (error) { +// logger.error("Could not abort the withdrawal", error); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`Could not abort the withdrawal.`, - description: (error as any).error.description, - debug: JSON.stringify(error), - }, - })); - return; - } - if (!res.ok) { - const response = await res.json(); - logger.error( - `Withdrawal abort gave response error (${res.status})`, - res.statusText, - ); - pageStateSetter((prevState) => ({ - ...prevState, +// error: { +// title: i18n.str`Could not abort the withdrawal.`, +// description: (error as any).error.description, +// debug: JSON.stringify(error), +// }, +// })); +// return; +// } +// if (!res.ok) { +// const response = await res.json(); +// logger.error( +// `Withdrawal abort gave response error (${res.status})`, +// res.statusText, +// ); +// pageStateSetter((prevState) => ({ +// ...prevState, - error: { - title: i18n.str`Withdrawal abortion failed.`, - description: response.error.description, - debug: JSON.stringify(response), - }, - })); - return; - } - logger.trace("Withdrawal operation aborted!"); - pageStateSetter((prevState) => { - const { ...rest } = prevState; - return { - ...rest, +// error: { +// title: i18n.str`Withdrawal abortion failed.`, +// description: response.error.description, +// debug: JSON.stringify(response), +// }, +// })); +// return; +// } +// logger.trace("Withdrawal operation aborted!"); +// pageStateSetter((prevState) => { +// const { ...rest } = prevState; +// return { +// ...rest, - info: i18n.str`Withdrawal aborted!`, - }; - }); -} +// info: i18n.str`Withdrawal aborted!`, +// }; +// }); +// } diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index 174c19288..fd91c0e1a 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -15,106 +15,67 @@ */ import { Logger } from "@gnu-taler/taler-util"; +import { + HttpResponsePaginated, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; import { Fragment, h, VNode } from "preact"; -import useSWR from "swr"; -import { PageStateType, usePageContext } from "../context/pageState.js"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; +import { Loading } from "../components/Loading.js"; +import { usePageContext } from "../context/pageState.js"; +import { useWithdrawalDetails } from "../hooks/access.js"; import { QrCodeSection } from "./QrCodeSection.js"; import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; const logger = new Logger("WithdrawalQRCode"); + +interface Props { + account: string; + withdrawalId: string; + talerWithdrawUri: string; + onAbort: () => void; + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; +} /** * Offer the QR code (and a clickable taler://-link) to * permit the passing of exchange and reserve details to * the bank. Poll the backend until such operation is done. */ export function WithdrawalQRCode({ + account, withdrawalId, talerWithdrawUri, -}: { - withdrawalId: string; - talerWithdrawUri: string; -}): VNode { - // turns true when the wallet POSTed the reserve details: - const { pageState, pageStateSetter } = usePageContext(); - const { i18n } = useTranslationContext(); - const abortButton = ( - <a - class="pure-button btn-cancel" - onClick={() => { - pageStateSetter((prevState: PageStateType) => { - return { - ...prevState, - withdrawalId: undefined, - talerWithdrawUri: undefined, - withdrawalInProgress: false, - }; - }); - }} - >{i18n.str`Abort`}</a> - ); - + onAbort, + onLoadNotOk, +}: Props): VNode { logger.trace(`Showing withdraw URI: ${talerWithdrawUri}`); - // waiting for the wallet: - - const { data, error } = useSWR( - `integration-api/withdrawal-operation/${withdrawalId}`, - { refreshInterval: 1000 }, - ); - if (typeof error !== "undefined") { - logger.error( - `withdrawal (${withdrawalId}) was never (correctly) created at the bank...`, - error, - ); - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, - - error: { - title: i18n.str`withdrawal (${withdrawalId}) was never (correctly) created at the bank...`, - }, - })); - return ( - <Fragment> - <br /> - <br /> - {abortButton} - </Fragment> - ); + const result = useWithdrawalDetails(account, withdrawalId); + if (!result.ok) { + return onLoadNotOk(result); } + const { data } = result; - // data didn't arrive yet and wallet didn't communicate: - if (typeof data === "undefined") - return <p>{i18n.str`Waiting the bank to create the operation...`}</p>; - - /** - * Wallet didn't communicate withdrawal details yet: - */ logger.trace("withdrawal status", data); - if (data.aborted) - pageStateSetter((prevState: PageStateType) => { - const { withdrawalId, talerWithdrawUri, ...rest } = prevState; - return { - ...rest, - withdrawalInProgress: false, - - error: { - title: i18n.str`This withdrawal was aborted!`, - }, - }; - }); + if (data.aborted) { + //signal that this withdrawal is aborted + //will redirect to account info + onAbort(); + return <Loading />; + } if (!data.selection_done) { return ( - <QrCodeSection - talerWithdrawUri={talerWithdrawUri} - abortButton={abortButton} - /> + <QrCodeSection talerWithdrawUri={talerWithdrawUri} onAbort={onAbort} /> ); } /** * Wallet POSTed the withdrawal details! Ask the * user to authorize the operation (here CAPTCHA). */ - return <WithdrawalConfirmationQuestion />; + return ( + <WithdrawalConfirmationQuestion + account={account} + withdrawalId={talerWithdrawUri} + /> + ); } |