taler-typescript-core

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

commit 5fc8f95a5d4ce8dea03b2dbec7eb5c37e7ff3f15
parent 6b6f80466ee07f591203c28a724ce4e7128af85d
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri,  9 Dec 2022 12:15:15 -0300

simplify directories

Diffstat:
Apackages/demobank-ui/src/components/LangSelector.tsx | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/demobank-ui/src/components/menu/LangSelector.tsx | 103-------------------------------------------------------------------------------
Apackages/demobank-ui/src/pages/AccountPage.tsx | 266+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/BankFrame.tsx | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/LoginForm.tsx | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpackages/demobank-ui/src/pages/home/PaymentOptions.tsx -> packages/demobank-ui/src/pages/PaymentOptions.tsx | 0
Apackages/demobank-ui/src/pages/PaytoWireTransferForm.tsx | 432+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/PublicHistoriesPage.tsx | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpackages/demobank-ui/src/pages/home/QrCodeSection.stories.tsx -> packages/demobank-ui/src/pages/QrCodeSection.stories.tsx | 0
Apackages/demobank-ui/src/pages/QrCodeSection.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/RegistrationPage.tsx | 262+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/demobank-ui/src/pages/Routing.tsx | 6+++---
Rpackages/demobank-ui/src/pages/home/ShowInputErrorLabel.tsx -> packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx | 0
Rpackages/demobank-ui/src/pages/home/Transactions.tsx -> packages/demobank-ui/src/pages/Transactions.tsx | 0
Apackages/demobank-ui/src/pages/WalletWithdrawForm.tsx | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/demobank-ui/src/pages/WithdrawalQRCode.tsx | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/demobank-ui/src/pages/home/AccountPage.tsx | 266-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/BankFrame.tsx | 192-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/LoginForm.tsx | 139-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx | 432-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/PublicHistoriesPage.tsx | 182-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/QrCodeSection.tsx | 59-----------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/RegistrationPage.tsx | 262-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/WalletWithdrawForm.tsx | 187-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/WithdrawalConfirmationQuestion.tsx | 327-------------------------------------------------------------------------------
Dpackages/demobank-ui/src/pages/home/WithdrawalQRCode.tsx | 120-------------------------------------------------------------------------------
Rpackages/demobank-ui/src/pages/home/index.stories.tsx -> packages/demobank-ui/src/pages/index.stories.tsx | 0
Mpackages/demobank-ui/src/stories.tsx | 2+-
29 files changed, 2273 insertions(+), 2273 deletions(-)

diff --git a/packages/demobank-ui/src/components/LangSelector.tsx b/packages/demobank-ui/src/components/LangSelector.tsx @@ -0,0 +1,103 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; +import { strings as messages } from "../i18n/strings.js"; + +type LangsNames = { + [P in keyof typeof messages]: string; +}; + +const names: LangsNames = { + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; + +function getLangName(s: keyof LangsNames | string): string { + if (names[s]) return names[s]; + return String(s); +} + +// FIXME: explain "like py". +export function LangSelectorLikePy(): VNode { + const [updatingLang, setUpdatingLang] = useState(false); + const { lang, changeLanguage } = useTranslationContext(); + const [hidden, setHidden] = useState(true); + useEffect(() => { + function bodyKeyPress(event: KeyboardEvent) { + if (event.code === "Escape") setHidden(true); + } + function bodyOnClick(event: Event) { + setHidden(true); + } + document.body.addEventListener("click", bodyOnClick); + document.body.addEventListener("keydown", bodyKeyPress as any); + return () => { + document.body.removeEventListener("keydown", bodyKeyPress as any); + document.body.removeEventListener("click", bodyOnClick); + }; + }, []); + return ( + <Fragment> + <button + name="language" + onClick={(ev) => { + setHidden((h) => !h); + ev.stopPropagation(); + }} + > + {getLangName(lang)} + </button> + <div id="lang" class={hidden ? "hide" : ""}> + <div style="position: relative; overflow: visible;"> + <div + class="nav" + style="position: absolute; max-height: 60vh; overflow-y: scroll" + > + {Object.keys(messages) + .filter((l) => l !== lang) + .map((l) => ( + <a + key={l} + href="#" + class="navbtn langbtn" + value={l} + onClick={() => { + changeLanguage(l); + setUpdatingLang(false); + }} + > + {getLangName(l)} + </a> + ))} + <br /> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/demobank-ui/src/components/menu/LangSelector.tsx b/packages/demobank-ui/src/components/menu/LangSelector.tsx @@ -1,103 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; -import { strings as messages } from "../../i18n/strings.js"; - -type LangsNames = { - [P in keyof typeof messages]: string; -}; - -const names: LangsNames = { - es: "Español [es]", - en: "English [en]", - fr: "Français [fr]", - de: "Deutsch [de]", - sv: "Svenska [sv]", - it: "Italiano [it]", -}; - -function getLangName(s: keyof LangsNames | string): string { - if (names[s]) return names[s]; - return String(s); -} - -// FIXME: explain "like py". -export function LangSelectorLikePy(): VNode { - const [updatingLang, setUpdatingLang] = useState(false); - const { lang, changeLanguage } = useTranslationContext(); - const [hidden, setHidden] = useState(true); - useEffect(() => { - function bodyKeyPress(event: KeyboardEvent) { - if (event.code === "Escape") setHidden(true); - } - function bodyOnClick(event: Event) { - setHidden(true); - } - document.body.addEventListener("click", bodyOnClick); - document.body.addEventListener("keydown", bodyKeyPress as any); - return () => { - document.body.removeEventListener("keydown", bodyKeyPress as any); - document.body.removeEventListener("click", bodyOnClick); - }; - }, []); - return ( - <Fragment> - <button - name="language" - onClick={(ev) => { - setHidden((h) => !h); - ev.stopPropagation(); - }} - > - {getLangName(lang)} - </button> - <div id="lang" class={hidden ? "hide" : ""}> - <div style="position: relative; overflow: visible;"> - <div - class="nav" - style="position: absolute; max-height: 60vh; overflow-y: scroll" - > - {Object.keys(messages) - .filter((l) => l !== lang) - .map((l) => ( - <a - key={l} - href="#" - class="navbtn langbtn" - value={l} - onClick={() => { - changeLanguage(l); - setUpdatingLang(false); - }} - > - {getLangName(l)} - </a> - ))} - <br /> - </div> - </div> - </div> - </Fragment> - ); -} diff --git a/packages/demobank-ui/src/pages/AccountPage.tsx b/packages/demobank-ui/src/pages/AccountPage.tsx @@ -0,0 +1,266 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Amounts, HttpStatusCode, 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 { Transactions } from "./Transactions.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() }; + + return r.json(); + }); + }, + }} + > + {children as any} + </SWRConfig> + ); +} + +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. + */ +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; + 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>; + } + } + } + const balance = !data ? undefined : Amounts.parseOrThrow(data.balance.amount); + 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"); + return ( + <BankFrame> + <WithdrawalQRCode + withdrawalId={withdrawalId} + talerWithdrawUri={talerWithdrawUri} + /> + </BankFrame> + ); + } + const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance); + + return ( + <BankFrame> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate> + Welcome, + {accountNumber + ? `${accountLabel} (${accountNumber})` + : accountLabel} + ! + </i18n.Translate> + </h1> + </div> + <section id="assets"> + <div class="asset-summary"> + <h2>{i18n.str`Bank account balance`}</h2> + {!balance ? ( + <div class="large-amount" style={{ color: "gray" }}> + Waiting server response... + </div> + ) : ( + <div class="large-amount amount"> + {balanceIsDebit ? <b>-</b> : null} + <span class="value">{`${balanceValue}`}</span>&nbsp; + <span class="currency">{`${balance.currency}`}</span> + </div> + )} + </div> + </section> + <section id="payments"> + <div class="payments"> + <h2>{i18n.str`Payments`}</h2> + <PaymentOptions currency={balance?.currency} /> + </div> + </section> + <section id="main"> + <article> + <h2>{i18n.str`Latest transactions:`}</h2> + <Transactions + balanceValue={balanceValue} + pageNumber={0} + accountLabel={accountLabel} + /> + </article> + </section> + </BankFrame> + ); +} + +// 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]; +// } diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -0,0 +1,192 @@ +/* + 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 { 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 { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; +import { bankUiSettings } from "../settings.js"; + +const logger = new Logger("BankFrame"); + +export function BankFrame({ + children, +}: { + children: ComponentChildren; +}): VNode { + const { i18n } = useTranslationContext(); + const backend = useBackendContext(); + const { pageState, pageStateSetter } = usePageContext(); + logger.trace("state", pageState); + const logOut = ( + <div class="logout"> + <a + href="#" + class="pure-button logout-button" + onClick={() => { + pageStateSetter((prevState: PageStateType) => { + const { talerWithdrawUri, withdrawalId, ...rest } = prevState; + backend.clear(); + return { + ...rest, + withdrawalInProgress: false, + error: undefined, + info: undefined, + isRawPayto: false, + }; + }); + }} + >{i18n.str`Logout`}</a> + </div> + ); + + const demo_sites = []; + for (const i in bankUiSettings.demoSites) + demo_sites.push( + <a href={bankUiSettings.demoSites[i][1]}> + {bankUiSettings.demoSites[i][0]} + </a>, + ); + + return ( + <Fragment> + <header + class="demobar" + style="display: flex; flex-direction: row; justify-content: space-between;" + > + <a href="#main" class="skip">{i18n.str`Skip to main content`}</a> + <div style="max-width: 50em; margin-left: 2em;"> + <h1> + <span class="it"> + <a href="/">{bankUiSettings.bankName}</a> + </span> + </h1> + {maybeDemoContent( + <p> + <i18n.Translate> + This part of the demo shows how a bank that supports Taler + directly would work. In addition to using your own bank account, + you can also see the transaction history of some{" "} + <a href="/public-accounts">Public Accounts</a>. + </i18n.Translate> + </p>, + )} + </div> + <a href="https://taler.net/"> + <img + src={talerLogo} + alt={i18n.str`Taler logo`} + height="100" + width="224" + style="margin: 2em 2em" + /> + </a> + </header> + <div style="display:flex; flex-direction: column;" class="navcontainer"> + <nav class="demolist"> + {maybeDemoContent(<Fragment>{demo_sites}</Fragment>)} + <div class="right"> + <LangSelector /> + </div> + </nav> + </div> + <section id="main" class="content"> + <ErrorBanner /> + <StatusBanner /> + {backend.state.status === "loggedIn" ? logOut : null} + {children} + </section> + <section id="footer" class="footer"> + <div class="footer"> + <hr /> + <div> + <p> + You can learn more about GNU Taler on our{" "} + <a href="https://taler.net">main website</a>. + </p> + </div> + <div style="flex-grow:1" /> + <p>Copyright &copy; 2014&mdash;2022 Taler Systems SA</p> + </div> + </section> + </Fragment> + ); +} + +function maybeDemoContent(content: VNode): VNode { + if (bankUiSettings.showDemoNav) { + return content; + } + return <Fragment />; +} + +function ErrorBanner(): VNode | null { + const { pageState, pageStateSetter } = usePageContext(); + + if (!pageState.error) return null; + + const rval = ( + <div class="informational informational-fail" style={{ marginTop: 8 }}> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <p> + <b>{pageState.error.title}</b> + </p> + <div> + <input + type="button" + class="pure-button" + value="Clear" + onClick={async () => { + pageStateSetter((prev) => ({ ...prev, error: undefined })); + }} + /> + </div> + </div> + <p>{pageState.error.description}</p> + </div> + ); + delete pageState.error; + return rval; +} + +function StatusBanner(): VNode | null { + const { pageState, pageStateSetter } = usePageContext(); + if (!pageState.info) return null; + + const rval = ( + <div class="informational informational-ok" style={{ marginTop: 8 }}> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <p> + <b>{pageState.info}</b> + </p> + <div> + <input + type="button" + class="pure-button" + value="Clear" + onClick={async () => { + pageStateSetter((prev) => ({ ...prev, info: undefined })); + }} + /> + </div> + </div> + </div> + ); + return rval; +} diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -0,0 +1,139 @@ +/* + 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 { h, VNode } from "preact"; +import { route } from "preact-router"; +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 { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; + +/** + * Collect and submit login data. + */ +export function LoginForm(): VNode { + const backend = useBackendContext(); + const [username, setUsername] = useState<string | undefined>(); + const [password, setPassword] = useState<string | undefined>(); + const { i18n } = useTranslationContext(); + const ref = useRef<HTMLInputElement>(null); + useEffect(() => { + ref.current?.focus(); + }, []); + + const errors = undefinedIfEmpty({ + username: !username ? i18n.str`Missing username` : undefined, + password: !password ? i18n.str`Missing password` : undefined, + }); + + return ( + <div class="login-div"> + <form action="javascript:void(0);" class="login-form" noValidate> + <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={() => { + if (!username || !password) return; + loginCall({ username, password }, backend); + setUsername(undefined); + setPassword(undefined); + }} + > + {i18n.str`Login`} + </button> + + {bankUiSettings.allowRegistrations ? ( + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={() => { + route("/register"); + }} + > + {i18n.str`Register`} + </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, + }); +} diff --git a/packages/demobank-ui/src/pages/home/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -0,0 +1,432 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Amounts, 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 { + InternationalizationAPI, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { BackendState } from "../hooks/backend.js"; +import { prepareHeaders, undefinedIfEmpty } from "../utils.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; + +const logger = new Logger("PaytoWireTransferForm"); + +export function PaytoWireTransferForm({ + focus, + currency, +}: { + focus?: boolean; + currency?: string; +}): VNode { + const backend = useBackendContext(); + const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button? + + const [submitData, submitDataSetter] = useWireTransferRequestType(); + + 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]); + + let parsedAmount = undefined; + + const errorsWire = { + iban: !submitData?.iban + ? i18n.str`Missing IBAN` + : !/^[A-Z0-9]*$/.test(submitData.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : undefined, + subject: !submitData?.subject ? i18n.str`Missing subject` : undefined, + amount: !submitData?.amount + ? i18n.str`Missing amount` + : !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`)) + ? i18n.str`Amount is not valid` + : Amounts.isZero(parsedAmount) + ? i18n.str`Should be greater than 0` + : undefined, + }; + + if (!pageState.isRawPayto) + return ( + <div> + <form class="pure-form" name="wire-transfer-form"> + <p> + <label for="iban">{i18n.str`Receiver IBAN:`}</label>&nbsp; + <input + ref={ref} + type="text" + id="iban" + name="iban" + value={submitData?.iban ?? ""} + placeholder="CC0123456789" + required + pattern={ibanRegex} + onInput={(e): void => { + submitDataSetter((submitData) => ({ + ...submitData, + iban: e.currentTarget.value, + })); + }} + /> + <br /> + <ShowInputErrorLabel + message={errorsWire?.iban} + isDirty={submitData?.iban !== undefined} + /> + <br /> + <label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp; + <input + type="text" + name="subject" + id="subject" + placeholder="subject" + value={submitData?.subject ?? ""} + required + onInput={(e): void => { + submitDataSetter((submitData) => ({ + ...submitData, + subject: e.currentTarget.value, + })); + }} + /> + <br /> + <ShowInputErrorLabel + message={errorsWire?.subject} + isDirty={submitData?.subject !== undefined} + /> + <br /> + <label for="amount">{i18n.str`Amount:`}</label>&nbsp; + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={currency?.length} + maxLength={currency?.length} + tabIndex={-1} + value={currency} + /> + &nbsp; + <input + type="number" + name="amount" + id="amount" + placeholder="amount" + required + value={submitData?.amount ?? ""} + onInput={(e): void => { + submitDataSetter((submitData) => ({ + ...submitData, + amount: e.currentTarget.value, + })); + }} + /> + </div> + <ShowInputErrorLabel + message={errorsWire?.amount} + isDirty={submitData?.amount !== undefined} + /> + </p> + + <p style={{ display: "flex", justifyContent: "space-between" }}> + <input + type="submit" + class="pure-button pure-button-primary" + disabled={!!errorsWire} + value="Send" + onClick={async () => { + 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.`, + }, + })); + 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, + ); + }} + /> + <input + type="button" + class="pure-button" + value="Clear" + onClick={async () => { + submitDataSetter((p) => ({ + amount: undefined, + iban: undefined, + subject: undefined, + })); + }} + /> + </p> + </form> + <p> + <a + href="/account" + onClick={() => { + logger.trace("switch to raw payto form"); + pageStateSetter((prevState) => ({ + ...prevState, + isRawPayto: true, + })); + }} + > + {i18n.str`Want to try the raw payto://-format?`} + </a> + </p> + </div> + ); + + const errorsPayto = undefinedIfEmpty({ + rawPaytoInput: !rawPaytoInput + ? i18n.str`Missing payto address` + : !parsePaytoUri(rawPaytoInput) + ? i18n.str`Payto does not follow the pattern` + : undefined, + }); + + return ( + <div> + <p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p> + <div class="pure-form" name="payto-form"> + <p> + <label for="address">{i18n.str`payto URI:`}</label>&nbsp; + <input + name="address" + type="text" + size={50} + ref={ref} + id="address" + value={rawPaytoInput ?? ""} + required + placeholder={i18n.str`payto address`} + // pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`} + onInput={(e): void => { + rawPaytoInputSetter(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errorsPayto?.rawPaytoInput} + isDirty={rawPaytoInput !== undefined} + /> + <br /> + <div class="hint"> + Hint: + <code> + payto://iban/[receiver-iban]?message=[subject]&amount=[{currency} + :X.Y] + </code> + </div> + </p> + <p> + <input + class="pure-button pure-button-primary" + type="submit" + 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, + ); + }} + /> + </p> + <p> + <a + href="/account" + onClick={() => { + logger.trace("switch to wire-transfer-form"); + pageStateSetter((prevState) => ({ + ...prevState, + isRawPayto: false, + })); + }} + > + {i18n.str`Use wire-transfer form?`} + </a> + </p> + </div> + </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 @@ -0,0 +1,182 @@ +/* + 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 { useLocalStorage } from "@gnu-taler/web-util/lib/index.browser"; +import { ComponentChildren, Fragment, h, VNode } from "preact"; +import { route } from "preact-router"; +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 "./Transactions.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() }; + + return r.json(); + }), + }} + > + {children as any} + </SWRConfig> + ); +} + +/** + * Show histories of public accounts. + */ +function PublicHistories(): VNode { + const { pageState, pageStateSetter } = usePageContext(); + 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, + + 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, + + 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 = []; + + /** + * Show the account specified in the props, or just one + * from the list if that's not given. + */ + if (typeof showAccount === "undefined" && data.publicAccounts.length > 0) { + setShowAccount(data.publicAccounts[1].accountLabel); + } + logger.trace(`Public history tab: ${showAccount}`); + + // Ask story of all the public accounts. + for (const account of data.publicAccounts) { + logger.trace("Asking transactions for", account.accountLabel); + const isSelected = account.accountLabel == showAccount; + accountsBar.push( + <li + class={ + isSelected + ? "pure-menu-selected pure-menu-item" + : "pure-menu-item pure-menu" + } + > + <a + href="#" + class="pure-menu-link" + onClick={() => setShowAccount(account.accountLabel)} + > + {account.accountLabel} + </a> + </li>, + ); + txs[account.accountLabel] = ( + <Transactions accountLabel={account.accountLabel} pageNumber={0} /> + ); + } + + return ( + <Fragment> + <h1 class="nav">{i18n.str`History of public accounts`}</h1> + <section id="main"> + <article> + <div class="pure-menu pure-menu-horizontal" name="accountMenu"> + <ul class="pure-menu-list">{accountsBar}</ul> + {typeof showAccount !== "undefined" ? ( + txs[showAccount] + ) : ( + <p>No public transactions found.</p> + )} + <br /> + <a href="/account" class="pure-button"> + Go back + </a> + </div> + </article> + </section> + </Fragment> + ); +} + +/** + * Stores in the state a object containing a 'username' + * and 'password' field, in order to avoid losing the + * handle of the data entered by the user in <input> fields. + */ +function useShowPublicAccount( + state?: string, +): [string | undefined, StateUpdater<string | undefined>] { + const ret = useLocalStorage("show-public-account", JSON.stringify(state)); + const retObj: string | undefined = ret[0] ? JSON.parse(ret[0]) : ret[0]; + const retSetter: StateUpdater<string | undefined> = function (val) { + const newVal = + val instanceof Function + ? JSON.stringify(val(retObj)) + : JSON.stringify(val); + ret[1](newVal); + }; + return [retObj, retSetter]; +} diff --git a/packages/demobank-ui/src/pages/home/QrCodeSection.stories.tsx b/packages/demobank-ui/src/pages/QrCodeSection.stories.tsx diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -0,0 +1,59 @@ +/* + 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 { h, VNode } from "preact"; +import { useEffect } from "preact/hooks"; +import { QR } from "../components/QR.js"; +import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; + +export function QrCodeSection({ + talerWithdrawUri, + abortButton, +}: { + talerWithdrawUri: string; + abortButton: h.JSX.Element; +}): VNode { + const { i18n } = useTranslationContext(); + useEffect(() => { + //Taler Wallet WebExtension is listening to headers response and tab updates. + //In the SPA there is no header response with the Taler URI so + //this hack manually triggers the tab update after the QR is in the DOM. + // WebExtension will be using + // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated + document.title = `${document.title} ${talerWithdrawUri}`; + }, []); + + return ( + <section id="main" class="content"> + <h1 class="nav">{i18n.str`Transfer to Taler Wallet`}</h1> + <article> + <div class="qr-div"> + <p>{i18n.str`Use this QR code to withdraw to your mobile wallet:`}</p> + <QR text={talerWithdrawUri} /> + <p> + <i18n.Translate> + Click{" "} + <a id="linkqr" href={talerWithdrawUri}>{i18n.str`this link`}</a>{" "} + to open your Taler wallet! + </i18n.Translate> + </p> + <br /> + {abortButton} + </div> + </article> + </section> + ); +} diff --git a/packages/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx @@ -0,0 +1,262 @@ +/* + 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 { 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 { + InternationalizationAPI, + 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 { BankFrame } from "./BankFrame.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; + +const logger = new Logger("RegistrationPage"); + +export function RegistrationPage(): VNode { + const { i18n } = useTranslationContext(); + if (!bankUiSettings.allowRegistrations) { + return ( + <BankFrame> + <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> + </BankFrame> + ); + } + return ( + <BankFrame> + <RegistrationForm /> + </BankFrame> + ); +} + +const usernameRegex = /^[a-z][a-zA-Z0-9]+$/; + +/** + * Collect and submit registration data. + */ +function RegistrationForm(): 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 { i18n } = useTranslationContext(); + + const errors = undefinedIfEmpty({ + username: !username + ? i18n.str`Missing username` + : !usernameRegex.test(username) + ? i18n.str`Use only letter and numbers starting with a lower case letter` + : undefined, + password: !password + ? i18n.str`Missing password` + : !usernameRegex.test(password) + ? i18n.str`Use only letter and numbers starting with a lower case letter` + : undefined, + repeatPassword: !repeatPassword + ? i18n.str`Missing password` + : repeatPassword !== password + ? i18n.str`Password don't match` + : undefined, + }); + + return ( + <Fragment> + <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> + <article> + <div class="register-div"> + <form action="javascript:void(0);" class="register-form" noValidate> + <div class="pure-form"> + <h2>{i18n.str`Please register!`}</h2> + <p class="unameFieldLabel registerFieldLabel formFieldLabel"> + <label for="register-un">{i18n.str`Username:`}</label> + </p> + <input + id="register-un" + name="register-un" + type="text" + placeholder="Username" + value={username ?? ""} + onInput={(e): void => { + setUsername(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={username !== undefined} + /> + <p class="unameFieldLabel registerFieldLabel formFieldLabel"> + <label for="register-pw">{i18n.str`Password:`}</label> + </p> + <input + type="password" + name="register-pw" + id="register-pw" + placeholder="Password" + value={password ?? ""} + required + onInput={(e): void => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + <p class="unameFieldLabel registerFieldLabel formFieldLabel"> + <label for="register-repeat">{i18n.str`Repeat Password:`}</label> + </p> + <input + type="password" + style={{ marginBottom: 8 }} + name="register-repeat" + id="register-repeat" + placeholder="Same password" + value={repeatPassword ?? ""} + required + onInput={(e): void => { + setRepeatPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.repeatPassword} + isDirty={repeatPassword !== undefined} + /> + <br /> + <button + class="pure-button pure-button-primary btn-register" + disabled={!!errors} + onClick={() => { + if (!username || !password) return; + registrationCall( + { username, password }, + backend, // will store BE URL, if OK. + pageStateSetter, + i18n, + ); + + setUsername(undefined); + setPassword(undefined); + setRepeatPassword(undefined); + }} + > + {i18n.str`Register`} + </button> + {/* FIXME: should use a different color */} + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={() => { + setUsername(undefined); + setPassword(undefined); + setRepeatPassword(undefined); + route("/account"); + }} + > + {i18n.str`Cancel`} + </button> + </div> + </form> + </div> + </article> + </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 @@ -18,9 +18,9 @@ import { createHashHistory } from "history"; import { h, VNode } from "preact"; import Router, { route, Route } from "preact-router"; import { useEffect } from "preact/hooks"; -import { AccountPage } from "./home/AccountPage.js"; -import { PublicHistoriesPage } from "./home/PublicHistoriesPage.js"; -import { RegistrationPage } from "./home/RegistrationPage.js"; +import { AccountPage } from "./AccountPage.js"; +import { PublicHistoriesPage } from "./PublicHistoriesPage.js"; +import { RegistrationPage } from "./RegistrationPage.js"; export function Routing(): VNode { const history = createHashHistory(); diff --git a/packages/demobank-ui/src/pages/home/ShowInputErrorLabel.tsx b/packages/demobank-ui/src/pages/ShowInputErrorLabel.tsx diff --git a/packages/demobank-ui/src/pages/home/Transactions.tsx b/packages/demobank-ui/src/pages/Transactions.tsx diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -0,0 +1,187 @@ +/* + 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 { 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 { + InternationalizationAPI, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { BackendState } from "../hooks/backend.js"; +import { prepareHeaders, validateAmount } from "../utils.js"; + +const logger = new Logger("WalletWithdrawForm"); + +export function WalletWithdrawForm({ + focus, + currency, +}: { + currency?: string; + focus?: boolean; +}): VNode { + const backend = useBackendContext(); + const { pageState, pageStateSetter } = usePageContext(); + const { i18n } = useTranslationContext(); + let submitAmount: string | undefined = "5.00"; + + const ref = useRef<HTMLInputElement>(null); + useEffect(() => { + if (focus) ref.current?.focus(); + }, [focus]); + return ( + <form id="reserve-form" class="pure-form" name="tform"> + <p> + <label for="withdraw-amount">{i18n.str`Amount to withdraw:`}</label> + &nbsp; + <div style={{ width: "max-content" }}> + <input + type="text" + readonly + class="currency-indicator" + size={currency?.length ?? 5} + maxLength={currency?.length} + tabIndex={-1} + value={currency} + /> + &nbsp; + <input + type="number" + ref={ref} + id="withdraw-amount" + name="withdraw-amount" + value={submitAmount} + onChange={(e): void => { + // FIXME: validate using 'parseAmount()', + // deactivate submit button as long as + // amount is not valid + submitAmount = e.currentTarget.value; + }} + /> + </div> + </p> + <p> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary" + type="submit" + value={i18n.str`Withdraw`} + onClick={() => { + 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, + ); + }} + /> + </div> + </p> + </form> + ); +} + +/** + * 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 @@ -0,0 +1,327 @@ +/* + 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 { Fragment, h, VNode } from "preact"; +import { StateUpdater } 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"; + +const logger = new Logger("WithdrawalConfirmationQuestion"); + +/** + * Additional authentication required to complete the operation. + * Not providing a back button, only abort. + */ +export function WithdrawalConfirmationQuestion(): VNode { + const { pageState, pageStateSetter } = usePageContext(); + const backend = useBackendContext(); + const { i18n } = useTranslationContext(); + const captchaNumbers = { + a: Math.floor(Math.random() * 10), + b: Math.floor(Math.random() * 10), + }; + let captchaAnswer = ""; + + return ( + <Fragment> + <h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1> + <article> + <div class="challenge-div"> + <form class="challenge-form" noValidate> + <div class="pure-form" id="captcha" name="capcha-form"> + <h2>{i18n.str`Authorize withdrawal by solving challenge`}</h2> + <p> + <label for="answer"> + {i18n.str`What is`}&nbsp; + <em> + {captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b} + </em> + ?&nbsp; + </label> + &nbsp; + <input + name="answer" + id="answer" + type="text" + autoFocus + required + onInput={(e): void => { + captchaAnswer = e.currentTarget.value; + }} + /> + </p> + <p> + <button + class="pure-button pure-button-primary btn-confirm" + onClick={(e) => { + e.preventDefault(); + if ( + captchaAnswer == + (captchaNumbers.a + captchaNumbers.b).toString() + ) { + confirmWithdrawalCall( + backend.state, + pageState.withdrawalId, + pageStateSetter, + i18n, + ); + return; + } + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + + error: { + title: i18n.str`Answer is wrong.`, + }, + })); + }} + > + {i18n.str`Confirm`} + </button> + &nbsp; + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={async () => + await abortWithdrawalCall( + backend.state, + pageState.withdrawalId, + pageStateSetter, + i18n, + ) + } + > + {i18n.str`Cancel`} + </button> + </p> + </div> + </form> + <div class="hint"> + <p> + <i18n.Translate> + A this point, a <b>real</b> bank would ask for an additional + authentication proof (PIN/TAN, one time password, ..), instead + of a simple calculation. + </i18n.Translate> + </p> + </div> + </div> + </article> + </Fragment> + ); +} + +/** + * This function confirms a withdrawal operation AFTER + * the wallet has given the exchange's payment details + * to the bank (via the Integration API). Such details + * can be given by scanning a QR code or by passing the + * raw taler://withdraw-URI to the CLI wallet. + * + * 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, + + 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"); + * */ + + // 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`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!`, + }; + }); +} + +/** + * 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 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, + + 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, + + info: i18n.str`Withdrawal aborted!`, + }; + }); +} diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -0,0 +1,120 @@ +/* + 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 { 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 { QrCodeSection } from "./QrCodeSection.js"; +import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; + +const logger = new Logger("WithdrawalQRCode"); +/** + * 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({ + 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> + ); + + 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> + ); + } + + // 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.selection_done) { + return ( + <QrCodeSection + talerWithdrawUri={talerWithdrawUri} + abortButton={abortButton} + /> + ); + } + /** + * Wallet POSTed the withdrawal details! Ask the + * user to authorize the operation (here CAPTCHA). + */ + return <WithdrawalConfirmationQuestion />; +} diff --git a/packages/demobank-ui/src/pages/home/AccountPage.tsx b/packages/demobank-ui/src/pages/home/AccountPage.tsx @@ -1,266 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Amounts, HttpStatusCode, 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 { Transactions } from "./Transactions.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() }; - - return r.json(); - }); - }, - }} - > - {children as any} - </SWRConfig> - ); -} - -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. - */ -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; - 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>; - } - } - } - const balance = !data ? undefined : Amounts.parseOrThrow(data.balance.amount); - 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"); - return ( - <BankFrame> - <WithdrawalQRCode - withdrawalId={withdrawalId} - talerWithdrawUri={talerWithdrawUri} - /> - </BankFrame> - ); - } - const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance); - - return ( - <BankFrame> - <div> - <h1 class="nav welcome-text"> - <i18n.Translate> - Welcome, - {accountNumber - ? `${accountLabel} (${accountNumber})` - : accountLabel} - ! - </i18n.Translate> - </h1> - </div> - <section id="assets"> - <div class="asset-summary"> - <h2>{i18n.str`Bank account balance`}</h2> - {!balance ? ( - <div class="large-amount" style={{ color: "gray" }}> - Waiting server response... - </div> - ) : ( - <div class="large-amount amount"> - {balanceIsDebit ? <b>-</b> : null} - <span class="value">{`${balanceValue}`}</span>&nbsp; - <span class="currency">{`${balance.currency}`}</span> - </div> - )} - </div> - </section> - <section id="payments"> - <div class="payments"> - <h2>{i18n.str`Payments`}</h2> - <PaymentOptions currency={balance?.currency} /> - </div> - </section> - <section id="main"> - <article> - <h2>{i18n.str`Latest transactions:`}</h2> - <Transactions - balanceValue={balanceValue} - pageNumber={0} - accountLabel={accountLabel} - /> - </article> - </section> - </BankFrame> - ); -} - -// 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]; -// } diff --git a/packages/demobank-ui/src/pages/home/BankFrame.tsx b/packages/demobank-ui/src/pages/home/BankFrame.tsx @@ -1,192 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Logger } from "@gnu-taler/taler-util"; -import { ComponentChildren, Fragment, h, VNode } from "preact"; -import talerLogo from "../../assets/logo-white.svg"; -import { LangSelectorLikePy as LangSelector } from "../../components/menu/LangSelector.js"; -import { useBackendContext } from "../../context/backend.js"; -import { PageStateType, usePageContext } from "../../context/pageState.js"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; -import { bankUiSettings } from "../../settings.js"; - -const logger = new Logger("BankFrame"); - -export function BankFrame({ - children, -}: { - children: ComponentChildren; -}): VNode { - const { i18n } = useTranslationContext(); - const backend = useBackendContext(); - const { pageState, pageStateSetter } = usePageContext(); - logger.trace("state", pageState); - const logOut = ( - <div class="logout"> - <a - href="#" - class="pure-button logout-button" - onClick={() => { - pageStateSetter((prevState: PageStateType) => { - const { talerWithdrawUri, withdrawalId, ...rest } = prevState; - backend.clear(); - return { - ...rest, - withdrawalInProgress: false, - error: undefined, - info: undefined, - isRawPayto: false, - }; - }); - }} - >{i18n.str`Logout`}</a> - </div> - ); - - const demo_sites = []; - for (const i in bankUiSettings.demoSites) - demo_sites.push( - <a href={bankUiSettings.demoSites[i][1]}> - {bankUiSettings.demoSites[i][0]} - </a>, - ); - - return ( - <Fragment> - <header - class="demobar" - style="display: flex; flex-direction: row; justify-content: space-between;" - > - <a href="#main" class="skip">{i18n.str`Skip to main content`}</a> - <div style="max-width: 50em; margin-left: 2em;"> - <h1> - <span class="it"> - <a href="/">{bankUiSettings.bankName}</a> - </span> - </h1> - {maybeDemoContent( - <p> - <i18n.Translate> - This part of the demo shows how a bank that supports Taler - directly would work. In addition to using your own bank account, - you can also see the transaction history of some{" "} - <a href="/public-accounts">Public Accounts</a>. - </i18n.Translate> - </p>, - )} - </div> - <a href="https://taler.net/"> - <img - src={talerLogo} - alt={i18n.str`Taler logo`} - height="100" - width="224" - style="margin: 2em 2em" - /> - </a> - </header> - <div style="display:flex; flex-direction: column;" class="navcontainer"> - <nav class="demolist"> - {maybeDemoContent(<Fragment>{demo_sites}</Fragment>)} - <div class="right"> - <LangSelector /> - </div> - </nav> - </div> - <section id="main" class="content"> - <ErrorBanner /> - <StatusBanner /> - {backend.state.status === "loggedIn" ? logOut : null} - {children} - </section> - <section id="footer" class="footer"> - <div class="footer"> - <hr /> - <div> - <p> - You can learn more about GNU Taler on our{" "} - <a href="https://taler.net">main website</a>. - </p> - </div> - <div style="flex-grow:1" /> - <p>Copyright &copy; 2014&mdash;2022 Taler Systems SA</p> - </div> - </section> - </Fragment> - ); -} - -function maybeDemoContent(content: VNode): VNode { - if (bankUiSettings.showDemoNav) { - return content; - } - return <Fragment />; -} - -function ErrorBanner(): VNode | null { - const { pageState, pageStateSetter } = usePageContext(); - - if (!pageState.error) return null; - - const rval = ( - <div class="informational informational-fail" style={{ marginTop: 8 }}> - <div style={{ display: "flex", justifyContent: "space-between" }}> - <p> - <b>{pageState.error.title}</b> - </p> - <div> - <input - type="button" - class="pure-button" - value="Clear" - onClick={async () => { - pageStateSetter((prev) => ({ ...prev, error: undefined })); - }} - /> - </div> - </div> - <p>{pageState.error.description}</p> - </div> - ); - delete pageState.error; - return rval; -} - -function StatusBanner(): VNode | null { - const { pageState, pageStateSetter } = usePageContext(); - if (!pageState.info) return null; - - const rval = ( - <div class="informational informational-ok" style={{ marginTop: 8 }}> - <div style={{ display: "flex", justifyContent: "space-between" }}> - <p> - <b>{pageState.info}</b> - </p> - <div> - <input - type="button" - class="pure-button" - value="Clear" - onClick={async () => { - pageStateSetter((prev) => ({ ...prev, info: undefined })); - }} - /> - </div> - </div> - </div> - ); - return rval; -} diff --git a/packages/demobank-ui/src/pages/home/LoginForm.tsx b/packages/demobank-ui/src/pages/home/LoginForm.tsx @@ -1,139 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { h, VNode } from "preact"; -import { route } from "preact-router"; -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 { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; - -/** - * Collect and submit login data. - */ -export function LoginForm(): VNode { - const backend = useBackendContext(); - const [username, setUsername] = useState<string | undefined>(); - const [password, setPassword] = useState<string | undefined>(); - const { i18n } = useTranslationContext(); - const ref = useRef<HTMLInputElement>(null); - useEffect(() => { - ref.current?.focus(); - }, []); - - const errors = undefinedIfEmpty({ - username: !username ? i18n.str`Missing username` : undefined, - password: !password ? i18n.str`Missing password` : undefined, - }); - - return ( - <div class="login-div"> - <form action="javascript:void(0);" class="login-form" noValidate> - <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={() => { - if (!username || !password) return; - loginCall({ username, password }, backend); - setUsername(undefined); - setPassword(undefined); - }} - > - {i18n.str`Login`} - </button> - - {bankUiSettings.allowRegistrations ? ( - <button - class="pure-button pure-button-secondary btn-cancel" - onClick={() => { - route("/register"); - }} - > - {i18n.str`Register`} - </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, - }); -} diff --git a/packages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx @@ -1,432 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Amounts, 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 { - InternationalizationAPI, - useTranslationContext, -} from "@gnu-taler/web-util/lib/index.browser"; -import { BackendState } from "../../hooks/backend.js"; -import { prepareHeaders, undefinedIfEmpty } from "../../utils.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; - -const logger = new Logger("PaytoWireTransferForm"); - -export function PaytoWireTransferForm({ - focus, - currency, -}: { - focus?: boolean; - currency?: string; -}): VNode { - const backend = useBackendContext(); - const { pageState, pageStateSetter } = usePageContext(); // NOTE: used for go-back button? - - const [submitData, submitDataSetter] = useWireTransferRequestType(); - - 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]); - - let parsedAmount = undefined; - - const errorsWire = { - iban: !submitData?.iban - ? i18n.str`Missing IBAN` - : !/^[A-Z0-9]*$/.test(submitData.iban) - ? i18n.str`IBAN should have just uppercased letters and numbers` - : undefined, - subject: !submitData?.subject ? i18n.str`Missing subject` : undefined, - amount: !submitData?.amount - ? i18n.str`Missing amount` - : !(parsedAmount = Amounts.parse(`${currency}:${submitData.amount}`)) - ? i18n.str`Amount is not valid` - : Amounts.isZero(parsedAmount) - ? i18n.str`Should be greater than 0` - : undefined, - }; - - if (!pageState.isRawPayto) - return ( - <div> - <form class="pure-form" name="wire-transfer-form"> - <p> - <label for="iban">{i18n.str`Receiver IBAN:`}</label>&nbsp; - <input - ref={ref} - type="text" - id="iban" - name="iban" - value={submitData?.iban ?? ""} - placeholder="CC0123456789" - required - pattern={ibanRegex} - onInput={(e): void => { - submitDataSetter((submitData) => ({ - ...submitData, - iban: e.currentTarget.value, - })); - }} - /> - <br /> - <ShowInputErrorLabel - message={errorsWire?.iban} - isDirty={submitData?.iban !== undefined} - /> - <br /> - <label for="subject">{i18n.str`Transfer subject:`}</label>&nbsp; - <input - type="text" - name="subject" - id="subject" - placeholder="subject" - value={submitData?.subject ?? ""} - required - onInput={(e): void => { - submitDataSetter((submitData) => ({ - ...submitData, - subject: e.currentTarget.value, - })); - }} - /> - <br /> - <ShowInputErrorLabel - message={errorsWire?.subject} - isDirty={submitData?.subject !== undefined} - /> - <br /> - <label for="amount">{i18n.str`Amount:`}</label>&nbsp; - <div style={{ width: "max-content" }}> - <input - type="text" - readonly - class="currency-indicator" - size={currency?.length} - maxLength={currency?.length} - tabIndex={-1} - value={currency} - /> - &nbsp; - <input - type="number" - name="amount" - id="amount" - placeholder="amount" - required - value={submitData?.amount ?? ""} - onInput={(e): void => { - submitDataSetter((submitData) => ({ - ...submitData, - amount: e.currentTarget.value, - })); - }} - /> - </div> - <ShowInputErrorLabel - message={errorsWire?.amount} - isDirty={submitData?.amount !== undefined} - /> - </p> - - <p style={{ display: "flex", justifyContent: "space-between" }}> - <input - type="submit" - class="pure-button pure-button-primary" - disabled={!!errorsWire} - value="Send" - onClick={async () => { - 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.`, - }, - })); - 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, - ); - }} - /> - <input - type="button" - class="pure-button" - value="Clear" - onClick={async () => { - submitDataSetter((p) => ({ - amount: undefined, - iban: undefined, - subject: undefined, - })); - }} - /> - </p> - </form> - <p> - <a - href="/account" - onClick={() => { - logger.trace("switch to raw payto form"); - pageStateSetter((prevState) => ({ - ...prevState, - isRawPayto: true, - })); - }} - > - {i18n.str`Want to try the raw payto://-format?`} - </a> - </p> - </div> - ); - - const errorsPayto = undefinedIfEmpty({ - rawPaytoInput: !rawPaytoInput - ? i18n.str`Missing payto address` - : !parsePaytoUri(rawPaytoInput) - ? i18n.str`Payto does not follow the pattern` - : undefined, - }); - - return ( - <div> - <p>{i18n.str`Transfer money to account identified by payto:// URI:`}</p> - <div class="pure-form" name="payto-form"> - <p> - <label for="address">{i18n.str`payto URI:`}</label>&nbsp; - <input - name="address" - type="text" - size={50} - ref={ref} - id="address" - value={rawPaytoInput ?? ""} - required - placeholder={i18n.str`payto address`} - // pattern={`payto://iban/[A-Z][A-Z][0-9]+?message=[a-zA-Z0-9 ]+&amount=${currency}:[0-9]+(.[0-9]+)?`} - onInput={(e): void => { - rawPaytoInputSetter(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errorsPayto?.rawPaytoInput} - isDirty={rawPaytoInput !== undefined} - /> - <br /> - <div class="hint"> - Hint: - <code> - payto://iban/[receiver-iban]?message=[subject]&amount=[{currency} - :X.Y] - </code> - </div> - </p> - <p> - <input - class="pure-button pure-button-primary" - type="submit" - 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, - ); - }} - /> - </p> - <p> - <a - href="/account" - onClick={() => { - logger.trace("switch to wire-transfer-form"); - pageStateSetter((prevState) => ({ - ...prevState, - isRawPayto: false, - })); - }} - > - {i18n.str`Use wire-transfer form?`} - </a> - </p> - </div> - </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/home/PublicHistoriesPage.tsx b/packages/demobank-ui/src/pages/home/PublicHistoriesPage.tsx @@ -1,182 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { 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 { 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 "./Transactions.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() }; - - return r.json(); - }), - }} - > - {children as any} - </SWRConfig> - ); -} - -/** - * Show histories of public accounts. - */ -function PublicHistories(): VNode { - const { pageState, pageStateSetter } = usePageContext(); - 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, - - 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, - - 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 = []; - - /** - * Show the account specified in the props, or just one - * from the list if that's not given. - */ - if (typeof showAccount === "undefined" && data.publicAccounts.length > 0) { - setShowAccount(data.publicAccounts[1].accountLabel); - } - logger.trace(`Public history tab: ${showAccount}`); - - // Ask story of all the public accounts. - for (const account of data.publicAccounts) { - logger.trace("Asking transactions for", account.accountLabel); - const isSelected = account.accountLabel == showAccount; - accountsBar.push( - <li - class={ - isSelected - ? "pure-menu-selected pure-menu-item" - : "pure-menu-item pure-menu" - } - > - <a - href="#" - class="pure-menu-link" - onClick={() => setShowAccount(account.accountLabel)} - > - {account.accountLabel} - </a> - </li>, - ); - txs[account.accountLabel] = ( - <Transactions accountLabel={account.accountLabel} pageNumber={0} /> - ); - } - - return ( - <Fragment> - <h1 class="nav">{i18n.str`History of public accounts`}</h1> - <section id="main"> - <article> - <div class="pure-menu pure-menu-horizontal" name="accountMenu"> - <ul class="pure-menu-list">{accountsBar}</ul> - {typeof showAccount !== "undefined" ? ( - txs[showAccount] - ) : ( - <p>No public transactions found.</p> - )} - <br /> - <a href="/account" class="pure-button"> - Go back - </a> - </div> - </article> - </section> - </Fragment> - ); -} - -/** - * Stores in the state a object containing a 'username' - * and 'password' field, in order to avoid losing the - * handle of the data entered by the user in <input> fields. - */ -function useShowPublicAccount( - state?: string, -): [string | undefined, StateUpdater<string | undefined>] { - const ret = useLocalStorage("show-public-account", JSON.stringify(state)); - const retObj: string | undefined = ret[0] ? JSON.parse(ret[0]) : ret[0]; - const retSetter: StateUpdater<string | undefined> = function (val) { - const newVal = - val instanceof Function - ? JSON.stringify(val(retObj)) - : JSON.stringify(val); - ret[1](newVal); - }; - return [retObj, retSetter]; -} diff --git a/packages/demobank-ui/src/pages/home/QrCodeSection.tsx b/packages/demobank-ui/src/pages/home/QrCodeSection.tsx @@ -1,59 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { h, VNode } from "preact"; -import { useEffect } from "preact/hooks"; -import { QR } from "../../components/QR.js"; -import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; - -export function QrCodeSection({ - talerWithdrawUri, - abortButton, -}: { - talerWithdrawUri: string; - abortButton: h.JSX.Element; -}): VNode { - const { i18n } = useTranslationContext(); - useEffect(() => { - //Taler Wallet WebExtension is listening to headers response and tab updates. - //In the SPA there is no header response with the Taler URI so - //this hack manually triggers the tab update after the QR is in the DOM. - // WebExtension will be using - // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated - document.title = `${document.title} ${talerWithdrawUri}`; - }, []); - - return ( - <section id="main" class="content"> - <h1 class="nav">{i18n.str`Transfer to Taler Wallet`}</h1> - <article> - <div class="qr-div"> - <p>{i18n.str`Use this QR code to withdraw to your mobile wallet:`}</p> - <QR text={talerWithdrawUri} /> - <p> - <i18n.Translate> - Click{" "} - <a id="linkqr" href={talerWithdrawUri}>{i18n.str`this link`}</a>{" "} - to open your Taler wallet! - </i18n.Translate> - </p> - <br /> - {abortButton} - </div> - </article> - </section> - ); -} diff --git a/packages/demobank-ui/src/pages/home/RegistrationPage.tsx b/packages/demobank-ui/src/pages/home/RegistrationPage.tsx @@ -1,262 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ -import { 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 { - InternationalizationAPI, - 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 { BankFrame } from "./BankFrame.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; - -const logger = new Logger("RegistrationPage"); - -export function RegistrationPage(): VNode { - const { i18n } = useTranslationContext(); - if (!bankUiSettings.allowRegistrations) { - return ( - <BankFrame> - <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> - </BankFrame> - ); - } - return ( - <BankFrame> - <RegistrationForm /> - </BankFrame> - ); -} - -const usernameRegex = /^[a-z][a-zA-Z0-9]+$/; - -/** - * Collect and submit registration data. - */ -function RegistrationForm(): 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 { i18n } = useTranslationContext(); - - const errors = undefinedIfEmpty({ - username: !username - ? i18n.str`Missing username` - : !usernameRegex.test(username) - ? i18n.str`Use only letter and numbers starting with a lower case letter` - : undefined, - password: !password - ? i18n.str`Missing password` - : !usernameRegex.test(password) - ? i18n.str`Use only letter and numbers starting with a lower case letter` - : undefined, - repeatPassword: !repeatPassword - ? i18n.str`Missing password` - : repeatPassword !== password - ? i18n.str`Password don't match` - : undefined, - }); - - return ( - <Fragment> - <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> - <article> - <div class="register-div"> - <form action="javascript:void(0);" class="register-form" noValidate> - <div class="pure-form"> - <h2>{i18n.str`Please register!`}</h2> - <p class="unameFieldLabel registerFieldLabel formFieldLabel"> - <label for="register-un">{i18n.str`Username:`}</label> - </p> - <input - id="register-un" - name="register-un" - type="text" - placeholder="Username" - value={username ?? ""} - onInput={(e): void => { - setUsername(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.username} - isDirty={username !== undefined} - /> - <p class="unameFieldLabel registerFieldLabel formFieldLabel"> - <label for="register-pw">{i18n.str`Password:`}</label> - </p> - <input - type="password" - name="register-pw" - id="register-pw" - placeholder="Password" - value={password ?? ""} - required - onInput={(e): void => { - setPassword(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.password} - isDirty={password !== undefined} - /> - <p class="unameFieldLabel registerFieldLabel formFieldLabel"> - <label for="register-repeat">{i18n.str`Repeat Password:`}</label> - </p> - <input - type="password" - style={{ marginBottom: 8 }} - name="register-repeat" - id="register-repeat" - placeholder="Same password" - value={repeatPassword ?? ""} - required - onInput={(e): void => { - setRepeatPassword(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.repeatPassword} - isDirty={repeatPassword !== undefined} - /> - <br /> - <button - class="pure-button pure-button-primary btn-register" - disabled={!!errors} - onClick={() => { - if (!username || !password) return; - registrationCall( - { username, password }, - backend, // will store BE URL, if OK. - pageStateSetter, - i18n, - ); - - setUsername(undefined); - setPassword(undefined); - setRepeatPassword(undefined); - }} - > - {i18n.str`Register`} - </button> - {/* FIXME: should use a different color */} - <button - class="pure-button pure-button-secondary btn-cancel" - onClick={() => { - setUsername(undefined); - setPassword(undefined); - setRepeatPassword(undefined); - route("/account"); - }} - > - {i18n.str`Cancel`} - </button> - </div> - </form> - </div> - </article> - </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/home/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/home/WalletWithdrawForm.tsx @@ -1,187 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { 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 { - InternationalizationAPI, - useTranslationContext, -} from "@gnu-taler/web-util/lib/index.browser"; -import { BackendState } from "../../hooks/backend.js"; -import { prepareHeaders, validateAmount } from "../../utils.js"; - -const logger = new Logger("WalletWithdrawForm"); - -export function WalletWithdrawForm({ - focus, - currency, -}: { - currency?: string; - focus?: boolean; -}): VNode { - const backend = useBackendContext(); - const { pageState, pageStateSetter } = usePageContext(); - const { i18n } = useTranslationContext(); - let submitAmount: string | undefined = "5.00"; - - const ref = useRef<HTMLInputElement>(null); - useEffect(() => { - if (focus) ref.current?.focus(); - }, [focus]); - return ( - <form id="reserve-form" class="pure-form" name="tform"> - <p> - <label for="withdraw-amount">{i18n.str`Amount to withdraw:`}</label> - &nbsp; - <div style={{ width: "max-content" }}> - <input - type="text" - readonly - class="currency-indicator" - size={currency?.length ?? 5} - maxLength={currency?.length} - tabIndex={-1} - value={currency} - /> - &nbsp; - <input - type="number" - ref={ref} - id="withdraw-amount" - name="withdraw-amount" - value={submitAmount} - onChange={(e): void => { - // FIXME: validate using 'parseAmount()', - // deactivate submit button as long as - // amount is not valid - submitAmount = e.currentTarget.value; - }} - /> - </div> - </p> - <p> - <div> - <input - id="select-exchange" - class="pure-button pure-button-primary" - type="submit" - value={i18n.str`Withdraw`} - onClick={() => { - 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, - ); - }} - /> - </div> - </p> - </form> - ); -} - -/** - * 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/home/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/home/WithdrawalConfirmationQuestion.tsx @@ -1,327 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Logger } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { StateUpdater } 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"; - -const logger = new Logger("WithdrawalConfirmationQuestion"); - -/** - * Additional authentication required to complete the operation. - * Not providing a back button, only abort. - */ -export function WithdrawalConfirmationQuestion(): VNode { - const { pageState, pageStateSetter } = usePageContext(); - const backend = useBackendContext(); - const { i18n } = useTranslationContext(); - const captchaNumbers = { - a: Math.floor(Math.random() * 10), - b: Math.floor(Math.random() * 10), - }; - let captchaAnswer = ""; - - return ( - <Fragment> - <h1 class="nav">{i18n.str`Confirm Withdrawal`}</h1> - <article> - <div class="challenge-div"> - <form class="challenge-form" noValidate> - <div class="pure-form" id="captcha" name="capcha-form"> - <h2>{i18n.str`Authorize withdrawal by solving challenge`}</h2> - <p> - <label for="answer"> - {i18n.str`What is`}&nbsp; - <em> - {captchaNumbers.a}&nbsp;+&nbsp;{captchaNumbers.b} - </em> - ?&nbsp; - </label> - &nbsp; - <input - name="answer" - id="answer" - type="text" - autoFocus - required - onInput={(e): void => { - captchaAnswer = e.currentTarget.value; - }} - /> - </p> - <p> - <button - class="pure-button pure-button-primary btn-confirm" - onClick={(e) => { - e.preventDefault(); - if ( - captchaAnswer == - (captchaNumbers.a + captchaNumbers.b).toString() - ) { - confirmWithdrawalCall( - backend.state, - pageState.withdrawalId, - pageStateSetter, - i18n, - ); - return; - } - pageStateSetter((prevState: PageStateType) => ({ - ...prevState, - - error: { - title: i18n.str`Answer is wrong.`, - }, - })); - }} - > - {i18n.str`Confirm`} - </button> - &nbsp; - <button - class="pure-button pure-button-secondary btn-cancel" - onClick={async () => - await abortWithdrawalCall( - backend.state, - pageState.withdrawalId, - pageStateSetter, - i18n, - ) - } - > - {i18n.str`Cancel`} - </button> - </p> - </div> - </form> - <div class="hint"> - <p> - <i18n.Translate> - A this point, a <b>real</b> bank would ask for an additional - authentication proof (PIN/TAN, one time password, ..), instead - of a simple calculation. - </i18n.Translate> - </p> - </div> - </div> - </article> - </Fragment> - ); -} - -/** - * This function confirms a withdrawal operation AFTER - * the wallet has given the exchange's payment details - * to the bank (via the Integration API). Such details - * can be given by scanning a QR code or by passing the - * raw taler://withdraw-URI to the CLI wallet. - * - * 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, - - 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"); - * */ - - // 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`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!`, - }; - }); -} - -/** - * 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 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, - - 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, - - info: i18n.str`Withdrawal aborted!`, - }; - }); -} diff --git a/packages/demobank-ui/src/pages/home/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/home/WithdrawalQRCode.tsx @@ -1,120 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { Logger } from "@gnu-taler/taler-util"; -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 { QrCodeSection } from "./QrCodeSection.js"; -import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; - -const logger = new Logger("WithdrawalQRCode"); -/** - * 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({ - 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> - ); - - 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> - ); - } - - // 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.selection_done) { - return ( - <QrCodeSection - talerWithdrawUri={talerWithdrawUri} - abortButton={abortButton} - /> - ); - } - /** - * Wallet POSTed the withdrawal details! Ask the - * user to authorize the operation (here CAPTCHA). - */ - return <WithdrawalConfirmationQuestion />; -} diff --git a/packages/demobank-ui/src/pages/home/index.stories.tsx b/packages/demobank-ui/src/pages/index.stories.tsx diff --git a/packages/demobank-ui/src/stories.tsx b/packages/demobank-ui/src/stories.tsx @@ -20,7 +20,7 @@ */ import { strings } from "./i18n/strings.js"; -import * as pages from "./pages/home/index.stories.js"; +import * as pages from "./pages/index.stories.js"; import { renderStories } from "@gnu-taler/web-util/lib/index.browser";