diff options
Diffstat (limited to 'packages/demobank-ui')
| -rw-r--r-- | packages/demobank-ui/src/components/app.tsx | 17 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/Routing.tsx | 2 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/AccountPage.tsx | 289 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/LoginForm.tsx | 149 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/PaymentOptions.tsx | 54 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx | 442 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/TalerWithdrawalConfirmationQuestion.tsx | 300 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/TalerWithdrawalQRCode.tsx | 97 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/WalletWithdrawForm.tsx | 176 | ||||
| -rw-r--r-- | packages/demobank-ui/src/pages/home/index.tsx | 1502 | ||||
| -rw-r--r-- | packages/demobank-ui/src/utils.ts | 15 | 
11 files changed, 1540 insertions, 1503 deletions
| diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index 35681a58c..f3bc3f571 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -3,6 +3,23 @@ import { PageStateProvider } from "../context/pageState.js";  import { TranslationProvider } from "../context/translation.js";  import { Routing } from "../pages/Routing.js"; +/** + * FIXME: + * + * - INPUT elements have their 'required' attribute ignored. + * + * - the page needs a "home" button that either redirects to + *   the profile page (when the user is logged in), or to + *   the very initial home page. + * + * - histories 'pages' are grouped in UL elements that cause + *   the rendering to visually separate each UL.  History elements + *   should instead line up without any separation caused by + *   a implementation detail. + * + * - Many strings need to be i18n-wrapped. + */ +  const App: FunctionalComponent = () => {    return (      <TranslationProvider> diff --git a/packages/demobank-ui/src/pages/Routing.tsx b/packages/demobank-ui/src/pages/Routing.tsx index 1ef042297..7f079a7de 100644 --- a/packages/demobank-ui/src/pages/Routing.tsx +++ b/packages/demobank-ui/src/pages/Routing.tsx @@ -18,7 +18,7 @@ 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/index.js"; +import { AccountPage } from "./home/AccountPage.js";  import { PublicHistoriesPage } from "./home/PublicHistoriesPage.js";  import { RegistrationPage } from "./home/RegistrationPage.js"; diff --git a/packages/demobank-ui/src/pages/home/AccountPage.tsx b/packages/demobank-ui/src/pages/home/AccountPage.tsx new file mode 100644 index 000000000..2bc05c332 --- /dev/null +++ b/packages/demobank-ui/src/pages/home/AccountPage.tsx @@ -0,0 +1,289 @@ +/* + 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 } from "@gnu-taler/taler-util"; +import { hooks } from "@gnu-taler/web-util/lib/index.browser"; +import { h, Fragment, VNode } from "preact"; +import { StateUpdater, useEffect, useState } from "preact/hooks"; +import useSWR, { SWRConfig, useSWRConfig } from "swr"; +import { PageStateType, usePageContext } from "../../context/pageState.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { useBackendState } from "../../hooks/backend.js"; +import { bankUiSettings } from "../../settings.js"; +import { getIbanFromPayto } from "../../utils.js"; +import { BankFrame } from "./BankFrame.js"; +import { LoginForm } from "./LoginForm.js"; +import { PaymentOptions } from "./PaymentOptions.js"; +import { TalerWithdrawalQRCode } from "./TalerWithdrawalQRCode.js"; +import { Transactions } from "./Transactions.js"; + +export function AccountPage(): VNode { +  const [backendState, backendStateSetter] = useBackendState(); +  const { i18n } = useTranslationContext(); +  const { pageState, pageStateSetter } = usePageContext(); + +  if (!pageState.isLoggedIn) { +    return ( +      <BankFrame> +        <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> +        <LoginForm /> +      </BankFrame> +    ); +  } + +  if (typeof backendState === "undefined") { +    pageStateSetter((prevState) => ({ +      ...prevState, + +      isLoggedIn: false, +      error: { +        title: i18n.str`Page has a problem: logged in but backend state is lost.`, +      }, +    })); +    return <p>Error: waiting for details...</p>; +  } +  console.log("Showing the profile page.."); +  return ( +    <SWRWithCredentials +      username={backendState.username} +      password={backendState.password} +      backendUrl={backendState.url} +    > +      <Account +        accountLabel={backendState.username} +        backendState={backendState} +      /> +    </SWRWithCredentials> +  ); +} + +/** + * Factor out login credentials. + */ +function SWRWithCredentials(props: any): VNode { +  const { username, password, backendUrl } = props; +  const headers = new Headers(); +  headers.append("Authorization", `Basic ${btoa(`${username}:${password}`)}`); +  console.log("Likely backend base URL", backendUrl); +  return ( +    <SWRConfig +      value={{ +        fetcher: (url: string) => { +          return fetch(backendUrl + url || "", { headers }).then((r) => { +            if (!r.ok) throw { status: r.status, json: r.json() }; + +            return r.json(); +          }); +        }, +      }} +    > +      {props.children} +    </SWRConfig> +  ); +} + +/** + * 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(Props: any): VNode { +  const { cache } = useSWRConfig(); +  const { accountLabel, backendState } = Props; +  // 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 { pageState, pageStateSetter: setPageState } = usePageContext(); +  const { +    withdrawalInProgress, +    withdrawalId, +    isLoggedIn, +    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") { +    console.log("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: { +        setPageState((prevState: PageStateType) => ({ +          ...prevState, + +          isLoggedIn: false, +          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: { +        setPageState((prevState: PageStateType) => ({ +          ...prevState, + +          isLoggedIn: false, +          error: { +            title: i18n.str`Wrong credentials given.`, +          }, +        })); +        return <p>Wrong credentials...</p>; +      } +      default: { +        setPageState((prevState: PageStateType) => ({ +          ...prevState, + +          isLoggedIn: false, +          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. +   */ +  console.log(`maybe new withdrawal ${talerWithdrawUri}`); +  if (talerWithdrawUri) { +    console.log("Bank created a new Taler withdrawal"); +    return ( +      <BankFrame> +        <TalerWithdrawalQRCode +          accountLabel={accountLabel} +          backendState={backendState} +          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>  +              <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 = hooks.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/LoginForm.tsx b/packages/demobank-ui/src/pages/home/LoginForm.tsx new file mode 100644 index 000000000..f60c9f600 --- /dev/null +++ b/packages/demobank-ui/src/pages/home/LoginForm.tsx @@ -0,0 +1,149 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE.  See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING.  If not, see <http://www.gnu.org/licenses/> + */ + +import { h, VNode } from "preact"; +import { route } from "preact-router"; +import { StateUpdater, useEffect, useRef, useState } from "preact/hooks"; +import { PageStateType, usePageContext } from "../../context/pageState.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { BackendStateType, useBackendState } 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 [backendState, backendStateSetter] = useBackendState(); +  const { pageState, pageStateSetter } = usePageContext(); +  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 }, +                backendStateSetter, +                pageStateSetter, +              ); +              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. +   */ +  backendStateSetter: StateUpdater<BackendStateType | undefined>, +  pageStateSetter: StateUpdater<PageStateType>, +): Promise<void> { +  /** +   * Optimistically setting the state as 'logged in', and +   * let the Account component request the balance to check +   * whether the credentials are valid.  */ +  pageStateSetter((prevState) => ({ ...prevState, isLoggedIn: true })); +  let baseUrl = getBankBackendBaseUrl(); +  if (!baseUrl.endsWith("/")) baseUrl += "/"; + +  backendStateSetter((prevState) => ({ +    ...prevState, +    url: baseUrl, +    username: req.username, +    password: req.password, +  })); +} diff --git a/packages/demobank-ui/src/pages/home/PaymentOptions.tsx b/packages/demobank-ui/src/pages/home/PaymentOptions.tsx new file mode 100644 index 000000000..69c8d383e --- /dev/null +++ b/packages/demobank-ui/src/pages/home/PaymentOptions.tsx @@ -0,0 +1,54 @@ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useTranslationContext } from "../../context/translation.js"; +import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; +import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; + +/** + * Let the user choose a payment option, + * then specify the details trigger the action. + */ +export function PaymentOptions({ currency }: { currency?: string }): VNode { +  const { i18n } = useTranslationContext(); + +  const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">( +    "charge-wallet", +  ); + +  return ( +    <article> +      <div class="payments"> +        <div class="tab"> +          <button +            class={tab === "charge-wallet" ? "tablinks active" : "tablinks"} +            onClick={(): void => { +              setTab("charge-wallet"); +            }} +          > +            {i18n.str`Obtain digital cash`} +          </button> +          <button +            class={tab === "wire-transfer" ? "tablinks active" : "tablinks"} +            onClick={(): void => { +              setTab("wire-transfer"); +            }} +          > +            {i18n.str`Transfer to bank account`} +          </button> +        </div> +        {tab === "charge-wallet" && ( +          <div id="charge-wallet" class="tabcontent active"> +            <h3>{i18n.str`Obtain digital cash`}</h3> +            <WalletWithdrawForm focus currency={currency} /> +          </div> +        )} +        {tab === "wire-transfer" && ( +          <div id="wire-transfer" class="tabcontent active"> +            <h3>{i18n.str`Transfer to bank account`}</h3> +            <PaytoWireTransferForm focus currency={currency} /> +          </div> +        )} +      </div> +    </article> +  ); +} diff --git a/packages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx new file mode 100644 index 000000000..45e7cf5ca --- /dev/null +++ b/packages/demobank-ui/src/pages/home/PaytoWireTransferForm.tsx @@ -0,0 +1,442 @@ +/* + 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, parsePaytoUri } from "@gnu-taler/taler-util"; +import { hooks } from "@gnu-taler/web-util/lib/index.browser"; +import { h, VNode } from "preact"; +import { StateUpdater, useEffect, useRef, useState } from "preact/hooks"; +import { PageStateType, usePageContext } from "../../context/pageState.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { BackendStateType, useBackendState } from "../../hooks/backend.js"; +import { prepareHeaders, undefinedIfEmpty } from "../../utils.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; + +export function PaytoWireTransferForm({ +  focus, +  currency, +}: { +  focus?: boolean; +  currency?: string; +}): VNode { +  const [backendState, backendStateSetter] = useBackendState(); +  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>  +            <input +              ref={ref} +              type="text" +              id="iban" +              name="iban" +              value={submitData?.iban ?? ""} +              placeholder="CC0123456789" +              required +              pattern={ibanRegex} +              onInput={(e): void => { +                submitDataSetter((submitData: any) => ({ +                  ...submitData, +                  iban: e.currentTarget.value, +                })); +              }} +            /> +            <br /> +            <ShowInputErrorLabel +              message={errorsWire?.iban} +              isDirty={submitData?.iban !== undefined} +            /> +            <br /> +            <label for="subject">{i18n.str`Transfer subject:`}</label>  +            <input +              type="text" +              name="subject" +              id="subject" +              placeholder="subject" +              value={submitData?.subject ?? ""} +              required +              onInput={(e): void => { +                submitDataSetter((submitData: any) => ({ +                  ...submitData, +                  subject: e.currentTarget.value, +                })); +              }} +            /> +            <br /> +            <ShowInputErrorLabel +              message={errorsWire?.subject} +              isDirty={submitData?.subject !== undefined} +            /> +            <br /> +            <label for="amount">{i18n.str`Amount:`}</label>  +            <input +              type="text" +              readonly +              class="currency-indicator" +              size={currency?.length} +              maxLength={currency?.length} +              tabIndex={-1} +              value={currency} +            /> +              +            <input +              type="number" +              name="amount" +              id="amount" +              placeholder="amount" +              required +              value={submitData?.amount ?? ""} +              onInput={(e): void => { +                submitDataSetter((submitData: any) => ({ +                  ...submitData, +                  amount: e.currentTarget.value, +                })); +              }} +            /> +            <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 === "" +                ) { +                  console.log("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, +                  backendState, +                  pageStateSetter, +                  () => +                    submitDataSetter((p) => ({ +                      amount: undefined, +                      iban: undefined, +                      subject: undefined, +                    })), +                ); +              }} +            /> +            <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={() => { +              console.log("switch to raw payto form"); +              pageStateSetter((prevState: any) => ({ +                ...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>  +          <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) { +                console.log("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, +                backendState, +                pageStateSetter, +                () => rawPaytoInputSetter(undefined), +              ); +            }} +          /> +        </p> +        <p> +          <a +            href="/account" +            onClick={() => { +              console.log("switch to wire-transfer-form"); +              pageStateSetter((prevState: any) => ({ +                ...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 = hooks.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: BackendStateType | undefined, +  pageStateSetter: StateUpdater<PageStateType>, +  /** +   * Optional since the raw payto form doesn't have +   * a stateful management of the input data yet. +   */ +  cleanUpForm: () => void, +): Promise<void> { +  let res: any; +  try { +    res = await postToBackend( +      `access-api/accounts/${getUsername(backendState)}/transactions`, +      backendState, +      JSON.stringify(req), +    ); +  } catch (error) { +    console.log("Could not POST transaction request to the bank", error); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `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(); +    console.log( +      `Transfer creation gave response error: ${response} (${res.status})`, +    ); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `Transfer creation gave response error`, +        description: response.error.description, +        debug: JSON.stringify(response), +      }, +    })); +    return; +  } +  // status is 200 OK here, tell the user. +  console.log("Wire transfer created!"); +  pageStateSetter((prevState) => ({ +    ...prevState, + +    info: "Wire transfer created!", +  })); + +  // Only at this point the input data can +  // be discarded. +  cleanUpForm(); +} + +/** + * Get username from the backend state, and throw + * exception if not found. + */ +function getUsername(backendState: BackendStateType | undefined): string { +  if (typeof backendState === "undefined") +    throw Error("Username can't be found in a undefined backend state."); + +  if (!backendState.username) { +    throw Error("No username, must login first."); +  } +  return backendState.username; +} + +/** + * Helps extracting the credentials from the state + * and wraps the actual call to 'fetch'.  Should be + * enclosed in a try-catch block by the caller. + */ +async function postToBackend( +  uri: string, +  backendState: BackendStateType | undefined, +  body: string, +): Promise<any> { +  if (typeof backendState === "undefined") +    throw Error("Credentials can't be found in a undefined backend state."); + +  const { username, password } = backendState; +  const headers = prepareHeaders(username, password); +  // Backend URL must have been stored _with_ a final slash. +  const url = new URL(uri, backendState.url); +  return await fetch(url.href, { +    method: "POST", +    headers, +    body, +  }); +} diff --git a/packages/demobank-ui/src/pages/home/TalerWithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/home/TalerWithdrawalConfirmationQuestion.tsx new file mode 100644 index 000000000..e3d8957b8 --- /dev/null +++ b/packages/demobank-ui/src/pages/home/TalerWithdrawalConfirmationQuestion.tsx @@ -0,0 +1,300 @@ +import { Fragment, h, VNode } from "preact"; +import { StateUpdater } from "preact/hooks"; +import { PageStateType, usePageContext } from "../../context/pageState.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { BackendStateType } from "../../hooks/backend.js"; +import { prepareHeaders } from "../../utils.js"; + +/** + * Additional authentication required to complete the operation. + * Not providing a back button, only abort. + */ +export function TalerWithdrawalConfirmationQuestion(Props: any): VNode { +  const { pageState, pageStateSetter } = usePageContext(); +  const { backendState } = Props; +  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`}  +                  <em> +                    {captchaNumbers.a} + {captchaNumbers.b} +                  </em> +                  ?  +                </label> +                  +                <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( +                        backendState, +                        pageState.withdrawalId, +                        pageStateSetter, +                      ); +                      return; +                    } +                    pageStateSetter((prevState: PageStateType) => ({ +                      ...prevState, + +                      error: { +                        title: i18n.str`Answer is wrong.`, +                      }, +                    })); +                  }} +                > +                  {i18n.str`Confirm`} +                </button> +                  +                <button +                  class="pure-button pure-button-secondary btn-cancel" +                  onClick={async () => +                    await abortWithdrawalCall( +                      backendState, +                      pageState.withdrawalId, +                      pageStateSetter, +                    ) +                  } +                > +                  {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: BackendStateType | undefined, +  withdrawalId: string | undefined, +  pageStateSetter: StateUpdater<PageStateType>, +): Promise<void> { +  if (typeof backendState === "undefined") { +    console.log("No credentials found."); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: "No credentials found.", +      }, +    })); +    return; +  } +  if (typeof withdrawalId === "undefined") { +    console.log("No withdrawal ID found."); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: "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) { +    console.log("Could not POST withdrawal confirmation to the bank", error); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `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 +    console.log( +      `Withdrawal confirmation gave response error (${res.status})`, +      res.statusText, +    ); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `Withdrawal confirmation gave response error`, +        debug: JSON.stringify(response), +      }, +    })); +    return; +  } +  console.log("Withdrawal operation confirmed!"); +  pageStateSetter((prevState) => { +    const { talerWithdrawUri, ...rest } = prevState; +    return { +      ...rest, + +      info: "Withdrawal confirmed!", +    }; +  }); +} + +/** + * Abort a withdrawal operation via the Access API's /abort. + */ +async function abortWithdrawalCall( +  backendState: BackendStateType | undefined, +  withdrawalId: string | undefined, +  pageStateSetter: StateUpdater<PageStateType>, +): Promise<void> { +  if (typeof backendState === "undefined") { +    console.log("No credentials found."); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `No credentials found.`, +      }, +    })); +    return; +  } +  if (typeof withdrawalId === "undefined") { +    console.log("No withdrawal ID found."); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `No withdrawal ID found.`, +      }, +    })); +    return; +  } +  let res: any; +  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) { +    console.log("Could not abort the withdrawal", error); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `Could not abort the withdrawal.`, +        description: (error as any).error.description, +        debug: JSON.stringify(error), +      }, +    })); +    return; +  } +  if (!res.ok) { +    const response = await res.json(); +    console.log( +      `Withdrawal abort gave response error (${res.status})`, +      res.statusText, +    ); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `Withdrawal abortion failed.`, +        description: response.error.description, +        debug: JSON.stringify(response), +      }, +    })); +    return; +  } +  console.log("Withdrawal operation aborted!"); +  pageStateSetter((prevState) => { +    const { ...rest } = prevState; +    return { +      ...rest, + +      info: "Withdrawal aborted!", +    }; +  }); +} diff --git a/packages/demobank-ui/src/pages/home/TalerWithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/home/TalerWithdrawalQRCode.tsx new file mode 100644 index 000000000..da4ccc45e --- /dev/null +++ b/packages/demobank-ui/src/pages/home/TalerWithdrawalQRCode.tsx @@ -0,0 +1,97 @@ +import { Fragment, h, VNode } from "preact"; +import useSWR from "swr"; +import { PageStateType, usePageContext } from "../../context/pageState.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { QrCodeSection } from "./QrCodeSection.js"; +import { TalerWithdrawalConfirmationQuestion } from "./TalerWithdrawalConfirmationQuestion.js"; + +/** + * 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 TalerWithdrawalQRCode(Props: any): VNode { +  // turns true when the wallet POSTed the reserve details: +  const { pageState, pageStateSetter } = usePageContext(); +  const { withdrawalId, talerWithdrawUri, backendState } = Props; +  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> +  ); + +  console.log(`Showing withdraw URI: ${talerWithdrawUri}`); +  // waiting for the wallet: + +  const { data, error } = useSWR( +    `integration-api/withdrawal-operation/${withdrawalId}`, +    { refreshInterval: 1000 }, +  ); + +  if (typeof error !== "undefined") { +    console.log( +      `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: +   */ +  console.log("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 <TalerWithdrawalConfirmationQuestion backendState={backendState} />; +} diff --git a/packages/demobank-ui/src/pages/home/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/home/WalletWithdrawForm.tsx new file mode 100644 index 000000000..842f14a5f --- /dev/null +++ b/packages/demobank-ui/src/pages/home/WalletWithdrawForm.tsx @@ -0,0 +1,176 @@ +/* + 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 { StateUpdater, useEffect, useRef } from "preact/hooks"; +import { PageStateType, usePageContext } from "../../context/pageState.js"; +import { useTranslationContext } from "../../context/translation.js"; +import { BackendStateType, useBackendState } from "../../hooks/backend.js"; +import { prepareHeaders, validateAmount } from "../../utils.js"; + +export function WalletWithdrawForm({ +  focus, +  currency, +}: { +  currency?: string; +  focus?: boolean; +}): VNode { +  const [backendState, backendStateSetter] = useBackendState(); +  const { pageState, pageStateSetter } = usePageContext(); +  const { i18n } = useTranslationContext(); +  let submitAmount = "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> +          +        <input +          type="text" +          readonly +          class="currency-indicator" +          size={currency?.length ?? 5} +          maxLength={currency?.length} +          tabIndex={-1} +          value={currency} +        /> +          +        <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; +          }} +        /> +      </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}`, +                backendState, +                pageStateSetter, +              ); +            }} +          /> +        </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: BackendStateType | undefined, +  pageStateSetter: StateUpdater<PageStateType>, +): Promise<void> { +  if (typeof backendState === "undefined") { +    console.log("Page has a problem: no credentials found in the state."); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: "No credentials given.", +      }, +    })); +    return; +  } + +  let res: any; +  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) { +    console.log("Could not POST withdrawal request to the bank", error); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `Could not create withdrawal operation`, +        description: (error as any).error.description, +        debug: JSON.stringify(error), +      }, +    })); +    return; +  } +  if (!res.ok) { +    const response = await res.json(); +    console.log( +      `Withdrawal creation gave response error: ${response} (${res.status})`, +    ); +    pageStateSetter((prevState) => ({ +      ...prevState, + +      error: { +        title: `Withdrawal creation gave response error`, +        description: response.error.description, +        debug: JSON.stringify(response), +      }, +    })); +    return; +  } + +  console.log("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/index.tsx b/packages/demobank-ui/src/pages/home/index.tsx deleted file mode 100644 index ca5cae571..000000000 --- a/packages/demobank-ui/src/pages/home/index.tsx +++ /dev/null @@ -1,1502 +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/> - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { h, Fragment, VNode } from "preact"; -import useSWR, { SWRConfig, useSWRConfig } from "swr"; - -import { Amounts, HttpStatusCode, parsePaytoUri } from "@gnu-taler/taler-util"; -import { hooks } from "@gnu-taler/web-util/lib/index.browser"; -import { route } from "preact-router"; -import { StateUpdater, useEffect, useRef, useState } from "preact/hooks"; -import { PageStateType, usePageContext } from "../../context/pageState.js"; -import { useTranslationContext } from "../../context/translation.js"; -import { BackendStateType, useBackendState } from "../../hooks/backend.js"; -import { bankUiSettings } from "../../settings.js"; -import { QrCodeSection } from "./QrCodeSection.js"; -import { -  getBankBackendBaseUrl, -  getIbanFromPayto, -  undefinedIfEmpty, -  validateAmount, -} from "../../utils.js"; -import { BankFrame } from "./BankFrame.js"; -import { Transactions } from "./Transactions.js"; -import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; - -/** - * FIXME: - * - * - INPUT elements have their 'required' attribute ignored. - * - * - the page needs a "home" button that either redirects to - *   the profile page (when the user is logged in), or to - *   the very initial home page. - * - * - histories 'pages' are grouped in UL elements that cause - *   the rendering to visually separate each UL.  History elements - *   should instead line up without any separation caused by - *   a implementation detail. - * - * - Many strings need to be i18n-wrapped. - */ - -/************ - * Helpers. * - ***********/ - -/** - * Get username from the backend state, and throw - * exception if not found. - */ -function getUsername(backendState: BackendStateType | undefined): string { -  if (typeof backendState === "undefined") -    throw Error("Username can't be found in a undefined backend state."); - -  if (!backendState.username) { -    throw Error("No username, must login first."); -  } -  return backendState.username; -} - -/** - * Helps extracting the credentials from the state - * and wraps the actual call to 'fetch'.  Should be - * enclosed in a try-catch block by the caller. - */ -async function postToBackend( -  uri: string, -  backendState: BackendStateType | undefined, -  body: string, -): Promise<any> { -  if (typeof backendState === "undefined") -    throw Error("Credentials can't be found in a undefined backend state."); - -  const { username, password } = backendState; -  const headers = prepareHeaders(username, password); -  // Backend URL must have been stored _with_ a final slash. -  const url = new URL(uri, backendState.url); -  return await fetch(url.href, { -    method: "POST", -    headers, -    body, -  }); -} - -function useTransactionPageNumber(): [number, StateUpdater<number>] { -  const ret = hooks.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]; -} - -/** - * Craft headers with Authorization and Content-Type. - */ -function prepareHeaders(username?: string, password?: string): Headers { -  const headers = new Headers(); -  if (username && password) { -    headers.append( -      "Authorization", -      `Basic ${window.btoa(`${username}:${password}`)}`, -    ); -  } -  headers.append("Content-Type", "application/json"); -  return headers; -} - -/******************* - * State managers. * - ******************/ - -/** - * Stores the raw Payto value entered by the user in the state. - */ -type RawPaytoInputType = string; -type RawPaytoInputTypeOpt = RawPaytoInputType | undefined; -function useRawPaytoInputType( -  state?: RawPaytoInputType, -): [RawPaytoInputTypeOpt, StateUpdater<RawPaytoInputTypeOpt>] { -  const ret = hooks.useLocalStorage("raw-payto-input-state", state); -  const retObj: RawPaytoInputTypeOpt = ret[0]; -  const retSetter: StateUpdater<RawPaytoInputTypeOpt> = function (val) { -    const newVal = val instanceof Function ? val(retObj) : val; -    ret[1](newVal); -  }; -  return [retObj, retSetter]; -} - -/** - * 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 = hooks.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]; -} - -/** - * Request preparators. - * - * These functions aim at sanitizing the input received - * from users - for example via a HTML form - and create - * a HTTP request object out of that. - */ - -/****************** - * HTTP wrappers. * - *****************/ - -/** - * A 'wrapper' is typically a function that prepares one - * particular API call and updates the state accordingly.  */ - -/** - * Abort a withdrawal operation via the Access API's /abort. - */ -async function abortWithdrawalCall( -  backendState: BackendStateType | undefined, -  withdrawalId: string | undefined, -  pageStateSetter: StateUpdater<PageStateType>, -): Promise<void> { -  if (typeof backendState === "undefined") { -    console.log("No credentials found."); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `No credentials found.`, -      }, -    })); -    return; -  } -  if (typeof withdrawalId === "undefined") { -    console.log("No withdrawal ID found."); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `No withdrawal ID found.`, -      }, -    })); -    return; -  } -  let res: any; -  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) { -    console.log("Could not abort the withdrawal", error); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `Could not abort the withdrawal.`, -        description: (error as any).error.description, -        debug: JSON.stringify(error), -      }, -    })); -    return; -  } -  if (!res.ok) { -    const response = await res.json(); -    console.log( -      `Withdrawal abort gave response error (${res.status})`, -      res.statusText, -    ); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `Withdrawal abortion failed.`, -        description: response.error.description, -        debug: JSON.stringify(response), -      }, -    })); -    return; -  } -  console.log("Withdrawal operation aborted!"); -  pageStateSetter((prevState) => { -    const { ...rest } = prevState; -    return { -      ...rest, - -      info: "Withdrawal aborted!", -    }; -  }); -} - -/** - * 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: BackendStateType | undefined, -  withdrawalId: string | undefined, -  pageStateSetter: StateUpdater<PageStateType>, -): Promise<void> { -  if (typeof backendState === "undefined") { -    console.log("No credentials found."); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: "No credentials found.", -      }, -    })); -    return; -  } -  if (typeof withdrawalId === "undefined") { -    console.log("No withdrawal ID found."); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: "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) { -    console.log("Could not POST withdrawal confirmation to the bank", error); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `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 -    console.log( -      `Withdrawal confirmation gave response error (${res.status})`, -      res.statusText, -    ); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `Withdrawal confirmation gave response error`, -        debug: JSON.stringify(response), -      }, -    })); -    return; -  } -  console.log("Withdrawal operation confirmed!"); -  pageStateSetter((prevState) => { -    const { talerWithdrawUri, ...rest } = prevState; -    return { -      ...rest, - -      info: "Withdrawal confirmed!", -    }; -  }); -} - -/** - * 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: BackendStateType | undefined, -  pageStateSetter: StateUpdater<PageStateType>, -  /** -   * Optional since the raw payto form doesn't have -   * a stateful management of the input data yet. -   */ -  cleanUpForm: () => void, -): Promise<void> { -  let res: any; -  try { -    res = await postToBackend( -      `access-api/accounts/${getUsername(backendState)}/transactions`, -      backendState, -      JSON.stringify(req), -    ); -  } catch (error) { -    console.log("Could not POST transaction request to the bank", error); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `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(); -    console.log( -      `Transfer creation gave response error: ${response} (${res.status})`, -    ); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `Transfer creation gave response error`, -        description: response.error.description, -        debug: JSON.stringify(response), -      }, -    })); -    return; -  } -  // status is 200 OK here, tell the user. -  console.log("Wire transfer created!"); -  pageStateSetter((prevState) => ({ -    ...prevState, - -    info: "Wire transfer created!", -  })); - -  // Only at this point the input data can -  // be discarded. -  cleanUpForm(); -} - -/** - * 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: BackendStateType | undefined, -  pageStateSetter: StateUpdater<PageStateType>, -): Promise<void> { -  if (typeof backendState === "undefined") { -    console.log("Page has a problem: no credentials found in the state."); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: "No credentials given.", -      }, -    })); -    return; -  } - -  let res: any; -  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) { -    console.log("Could not POST withdrawal request to the bank", error); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `Could not create withdrawal operation`, -        description: (error as any).error.description, -        debug: JSON.stringify(error), -      }, -    })); -    return; -  } -  if (!res.ok) { -    const response = await res.json(); -    console.log( -      `Withdrawal creation gave response error: ${response} (${res.status})`, -    ); -    pageStateSetter((prevState) => ({ -      ...prevState, - -      error: { -        title: `Withdrawal creation gave response error`, -        description: response.error.description, -        debug: JSON.stringify(response), -      }, -    })); -    return; -  } - -  console.log("Withdrawal operation created!"); -  const resp = await res.json(); -  pageStateSetter((prevState: PageStateType) => ({ -    ...prevState, -    withdrawalInProgress: true, -    talerWithdrawUri: resp.taler_withdraw_uri, -    withdrawalId: resp.withdrawal_id, -  })); -} - -async function loginCall( -  req: { username: string; password: string }, -  /** -   * FIXME: figure out if the two following -   * functions can be retrieved from the state. -   */ -  backendStateSetter: StateUpdater<BackendStateType | undefined>, -  pageStateSetter: StateUpdater<PageStateType>, -): Promise<void> { -  /** -   * Optimistically setting the state as 'logged in', and -   * let the Account component request the balance to check -   * whether the credentials are valid.  */ -  pageStateSetter((prevState) => ({ ...prevState, isLoggedIn: true })); -  let baseUrl = getBankBackendBaseUrl(); -  if (!baseUrl.endsWith("/")) baseUrl += "/"; - -  backendStateSetter((prevState) => ({ -    ...prevState, -    url: baseUrl, -    username: req.username, -    password: req.password, -  })); -} - -/************************** - * Functional components. * - *************************/ - -function PaytoWireTransfer({ -  focus, -  currency, -}: { -  focus?: boolean; -  currency?: string; -}): VNode { -  const [backendState, backendStateSetter] = useBackendState(); -  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>  -            <input -              ref={ref} -              type="text" -              id="iban" -              name="iban" -              value={submitData?.iban ?? ""} -              placeholder="CC0123456789" -              required -              pattern={ibanRegex} -              onInput={(e): void => { -                submitDataSetter((submitData: any) => ({ -                  ...submitData, -                  iban: e.currentTarget.value, -                })); -              }} -            /> -            <br /> -            <ShowInputErrorLabel -              message={errorsWire?.iban} -              isDirty={submitData?.iban !== undefined} -            /> -            <br /> -            <label for="subject">{i18n.str`Transfer subject:`}</label>  -            <input -              type="text" -              name="subject" -              id="subject" -              placeholder="subject" -              value={submitData?.subject ?? ""} -              required -              onInput={(e): void => { -                submitDataSetter((submitData: any) => ({ -                  ...submitData, -                  subject: e.currentTarget.value, -                })); -              }} -            /> -            <br /> -            <ShowInputErrorLabel -              message={errorsWire?.subject} -              isDirty={submitData?.subject !== undefined} -            /> -            <br /> -            <label for="amount">{i18n.str`Amount:`}</label>  -            <input -              type="text" -              readonly -              class="currency-indicator" -              size={currency?.length} -              maxLength={currency?.length} -              tabIndex={-1} -              value={currency} -            /> -              -            <input -              type="number" -              name="amount" -              id="amount" -              placeholder="amount" -              required -              value={submitData?.amount ?? ""} -              onInput={(e): void => { -                submitDataSetter((submitData: any) => ({ -                  ...submitData, -                  amount: e.currentTarget.value, -                })); -              }} -            /> -            <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 === "" -                ) { -                  console.log("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, -                  backendState, -                  pageStateSetter, -                  () => -                    submitDataSetter((p) => ({ -                      amount: undefined, -                      iban: undefined, -                      subject: undefined, -                    })), -                ); -              }} -            /> -            <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={() => { -              console.log("switch to raw payto form"); -              pageStateSetter((prevState: any) => ({ -                ...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>  -          <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) { -                console.log("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, -                backendState, -                pageStateSetter, -                () => rawPaytoInputSetter(undefined), -              ); -            }} -          /> -        </p> -        <p> -          <a -            href="/account" -            onClick={() => { -              console.log("switch to wire-transfer-form"); -              pageStateSetter((prevState: any) => ({ -                ...prevState, -                isRawPayto: false, -              })); -            }} -          > -            {i18n.str`Use wire-transfer form?`} -          </a> -        </p> -      </div> -    </div> -  ); -} - -/** - * Additional authentication required to complete the operation. - * Not providing a back button, only abort. - */ -function TalerWithdrawalConfirmationQuestion(Props: any): VNode { -  const { pageState, pageStateSetter } = usePageContext(); -  const { backendState } = Props; -  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`}  -                  <em> -                    {captchaNumbers.a} + {captchaNumbers.b} -                  </em> -                  ?  -                </label> -                  -                <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( -                        backendState, -                        pageState.withdrawalId, -                        pageStateSetter, -                      ); -                      return; -                    } -                    pageStateSetter((prevState: PageStateType) => ({ -                      ...prevState, - -                      error: { -                        title: i18n.str`Answer is wrong.`, -                      }, -                    })); -                  }} -                > -                  {i18n.str`Confirm`} -                </button> -                  -                <button -                  class="pure-button pure-button-secondary btn-cancel" -                  onClick={async () => -                    await abortWithdrawalCall( -                      backendState, -                      pageState.withdrawalId, -                      pageStateSetter, -                    ) -                  } -                > -                  {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> -  ); -} - -/** - * 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. - */ -function TalerWithdrawalQRCode(Props: any): VNode { -  // turns true when the wallet POSTed the reserve details: -  const { pageState, pageStateSetter } = usePageContext(); -  const { withdrawalId, talerWithdrawUri, backendState } = Props; -  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> -  ); - -  console.log(`Showing withdraw URI: ${talerWithdrawUri}`); -  // waiting for the wallet: - -  const { data, error } = useSWR( -    `integration-api/withdrawal-operation/${withdrawalId}`, -    { refreshInterval: 1000 }, -  ); - -  if (typeof error !== "undefined") { -    console.log( -      `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: -   */ -  console.log("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 <TalerWithdrawalConfirmationQuestion backendState={backendState} />; -} - -function WalletWithdraw({ -  focus, -  currency, -}: { -  currency?: string; -  focus?: boolean; -}): VNode { -  const [backendState, backendStateSetter] = useBackendState(); -  const { pageState, pageStateSetter } = usePageContext(); -  const { i18n } = useTranslationContext(); -  let submitAmount = "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> -          -        <input -          type="text" -          readonly -          class="currency-indicator" -          size={currency?.length ?? 5} -          maxLength={currency?.length} -          tabIndex={-1} -          value={currency} -        /> -          -        <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; -          }} -        /> -      </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}`, -                backendState, -                pageStateSetter, -              ); -            }} -          /> -        </div> -      </p> -    </form> -  ); -} - -/** - * Let the user choose a payment option, - * then specify the details trigger the action. - */ -function PaymentOptions({ currency }: { currency?: string }): VNode { -  const { i18n } = useTranslationContext(); - -  const [tab, setTab] = useState<"charge-wallet" | "wire-transfer">( -    "charge-wallet", -  ); - -  return ( -    <article> -      <div class="payments"> -        <div class="tab"> -          <button -            class={tab === "charge-wallet" ? "tablinks active" : "tablinks"} -            onClick={(): void => { -              setTab("charge-wallet"); -            }} -          > -            {i18n.str`Obtain digital cash`} -          </button> -          <button -            class={tab === "wire-transfer" ? "tablinks active" : "tablinks"} -            onClick={(): void => { -              setTab("wire-transfer"); -            }} -          > -            {i18n.str`Transfer to bank account`} -          </button> -        </div> -        {tab === "charge-wallet" && ( -          <div id="charge-wallet" class="tabcontent active"> -            <h3>{i18n.str`Obtain digital cash`}</h3> -            <WalletWithdraw focus currency={currency} /> -          </div> -        )} -        {tab === "wire-transfer" && ( -          <div id="wire-transfer" class="tabcontent active"> -            <h3>{i18n.str`Transfer to bank account`}</h3> -            <PaytoWireTransfer focus currency={currency} /> -          </div> -        )} -      </div> -    </article> -  ); -} - -/** - * Collect and submit login data. - */ -function LoginForm(): VNode { -  const [backendState, backendStateSetter] = useBackendState(); -  const { pageState, pageStateSetter } = usePageContext(); -  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 }, -                backendStateSetter, -                pageStateSetter, -              ); -              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> -  ); -} - -/** - * 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(Props: any): VNode { -  const { cache } = useSWRConfig(); -  const { accountLabel, backendState } = Props; -  // 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 { pageState, pageStateSetter: setPageState } = usePageContext(); -  const { -    withdrawalInProgress, -    withdrawalId, -    isLoggedIn, -    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") { -    console.log("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: { -        setPageState((prevState: PageStateType) => ({ -          ...prevState, - -          isLoggedIn: false, -          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: { -        setPageState((prevState: PageStateType) => ({ -          ...prevState, - -          isLoggedIn: false, -          error: { -            title: i18n.str`Wrong credentials given.`, -          }, -        })); -        return <p>Wrong credentials...</p>; -      } -      default: { -        setPageState((prevState: PageStateType) => ({ -          ...prevState, - -          isLoggedIn: false, -          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. -   */ -  console.log(`maybe new withdrawal ${talerWithdrawUri}`); -  if (talerWithdrawUri) { -    console.log("Bank created a new Taler withdrawal"); -    return ( -      <BankFrame> -        <TalerWithdrawalQRCode -          accountLabel={accountLabel} -          backendState={backendState} -          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>  -              <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> -  ); -} - -/** - * Factor out login credentials. - */ -function SWRWithCredentials(props: any): VNode { -  const { username, password, backendUrl } = props; -  const headers = new Headers(); -  headers.append("Authorization", `Basic ${btoa(`${username}:${password}`)}`); -  console.log("Likely backend base URL", backendUrl); -  return ( -    <SWRConfig -      value={{ -        fetcher: (url: string) => { -          return fetch(backendUrl + url || "", { headers }).then((r) => { -            if (!r.ok) throw { status: r.status, json: r.json() }; - -            return r.json(); -          }); -        }, -      }} -    > -      {props.children} -    </SWRConfig> -  ); -} - -export function AccountPage(): VNode { -  const [backendState, backendStateSetter] = useBackendState(); -  const { i18n } = useTranslationContext(); -  const { pageState, pageStateSetter } = usePageContext(); - -  if (!pageState.isLoggedIn) { -    return ( -      <BankFrame> -        <h1 class="nav">{i18n.str`Welcome to ${bankUiSettings.bankName}!`}</h1> -        <LoginForm /> -      </BankFrame> -    ); -  } - -  if (typeof backendState === "undefined") { -    pageStateSetter((prevState) => ({ -      ...prevState, - -      isLoggedIn: false, -      error: { -        title: i18n.str`Page has a problem: logged in but backend state is lost.`, -      }, -    })); -    return <p>Error: waiting for details...</p>; -  } -  console.log("Showing the profile page.."); -  return ( -    <SWRWithCredentials -      username={backendState.username} -      password={backendState.password} -      backendUrl={backendState.url} -    > -      <Account -        accountLabel={backendState.username} -        backendState={backendState} -      /> -    </SWRWithCredentials> -  ); -} diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts index b8e0a2acb..23cade0e8 100644 --- a/packages/demobank-ui/src/utils.ts +++ b/packages/demobank-ui/src/utils.ts @@ -52,3 +52,18 @@ export function undefinedIfEmpty<T extends object>(obj: T): T | undefined {      ? obj      : undefined;  } + +/** + * Craft headers with Authorization and Content-Type. + */ +export function prepareHeaders(username?: string, password?: string): Headers { +  const headers = new Headers(); +  if (username && password) { +    headers.append( +      "Authorization", +      `Basic ${window.btoa(`${username}:${password}`)}`, +    ); +  } +  headers.append("Content-Type", "application/json"); +  return headers; +} | 
