/* 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 */ /* 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, validateAmount, } from "../../utils.js"; import { BankFrame } from "./BankFrame.js"; import { Transactions } from "./Transactions.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 { 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] { const ret = hooks.useNotNullLocalStorage("transaction-page", "0"); const retObj = JSON.parse(ret[0]); const retSetter: StateUpdater = 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] { const ret = hooks.useLocalStorage("raw-payto-input-state", state); const retObj: RawPaytoInputTypeOpt = ret[0]; const retSetter: StateUpdater = 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 fields. FIXME: name not matching the * purpose, as this is not a HTTP request body but rather the * state of the -elements. */ type WireTransferRequestTypeOpt = WireTransferRequestType | undefined; function useWireTransferRequestType( state?: WireTransferRequestType, ): [WireTransferRequestTypeOpt, StateUpdater] { 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 = 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, ): Promise { 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, ): Promise { 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, /** * Optional since the raw payto form doesn't have * a stateful management of the input data yet. */ cleanUpForm: () => void, ): Promise { 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, ): Promise { 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, pageStateSetter: StateUpdater, ): Promise { /** * 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, })); } /** * This function requests /register. * * This function is responsible to change two states: * the backend's (to store the login credentials) and * the page's (to indicate a successful login or a problem). */ async function registrationCall( req: { username: string; password: string }, /** * FIXME: figure out if the two following * functions can be retrieved somewhat from * the state. */ backendStateSetter: StateUpdater, pageStateSetter: StateUpdater, ): Promise { let baseUrl = getBankBackendBaseUrl(); /** * If the base URL doesn't end with slash and the path * is not empty, then the concatenation made by URL() * drops the last path element. */ if (!baseUrl.endsWith("/")) baseUrl += "/"; const headers = new Headers(); headers.append("Content-Type", "application/json"); const url = new URL("access-api/testing/register", baseUrl); let res: Response; try { res = await fetch(url.href, { method: "POST", body: JSON.stringify({ username: req.username, password: req.password, }), headers, }); } catch (error) { console.log( `Could not POST new registration to the bank (${url.href})`, error, ); pageStateSetter((prevState) => ({ ...prevState, error: { title: `Registration failed, please report`, debug: JSON.stringify(error), }, })); return; } if (!res.ok) { const response = await res.json(); if (res.status === 409) { pageStateSetter((prevState) => ({ ...prevState, error: { title: `That username is already taken`, debug: JSON.stringify(response), }, })); } else { pageStateSetter((prevState) => ({ ...prevState, error: { title: `New registration gave response error`, debug: JSON.stringify(response), }, })); } } else { // registration was ok pageStateSetter((prevState) => ({ ...prevState, isLoggedIn: true, })); backendStateSetter((prevState) => ({ ...prevState, url: baseUrl, username: req.username, password: req.password, })); route("/account"); } } /************************** * Functional components. * *************************/ function ShowInputErrorLabel({ isDirty, message, }: { message: string | undefined; isDirty: boolean; }): VNode { if (message && isDirty) return
{message}
; return ; } 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( undefined, ); const { i18n } = useTranslationContext(); const ibanRegex = "^[A-Z][A-Z][0-9]+$"; let transactionData: TransactionRequestType; const ref = useRef(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 (

  { submitDataSetter((submitData: any) => ({ ...submitData, iban: e.currentTarget.value, })); }} />

  { submitDataSetter((submitData: any) => ({ ...submitData, subject: e.currentTarget.value, })); }} />

    { submitDataSetter((submitData: any) => ({ ...submitData, amount: e.currentTarget.value, })); }} />

{ 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, })), ); }} /> { submitDataSetter((p) => ({ amount: undefined, iban: undefined, subject: undefined, })); }} />

{ console.log("switch to raw payto form"); pageStateSetter((prevState: any) => ({ ...prevState, isRawPayto: true, })); }} > {i18n.str`Want to try the raw payto://-format?`}

); const errorsPayto = undefinedIfEmpty({ rawPaytoInput: !rawPaytoInput ? i18n.str`Missing payto address` : !parsePaytoUri(rawPaytoInput) ? i18n.str`Payto does not follow the pattern` : undefined, }); return (

{i18n.str`Transfer money to account identified by payto:// URI:`}

  { rawPaytoInputSetter(e.currentTarget.value); }} />

Hint: payto://iban/[receiver-iban]?message=[subject]&amount=[{currency} :X.Y]

{ // 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), ); }} />

{ console.log("switch to wire-transfer-form"); pageStateSetter((prevState: any) => ({ ...prevState, isRawPayto: false, })); }} > {i18n.str`Use wire-transfer form?`}

); } /** * 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 (

{i18n.str`Confirm Withdrawal`}

{i18n.str`Authorize withdrawal by solving challenge`}

  { captchaAnswer = e.currentTarget.value; }} />

 

A this point, a real bank would ask for an additional authentication proof (PIN/TAN, one time password, ..), instead of a simple calculation.

); } /** * 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 = ( { pageStateSetter((prevState: PageStateType) => { return { ...prevState, withdrawalId: undefined, talerWithdrawUri: undefined, withdrawalInProgress: false, }; }); }} >{i18n.str`Abort`} ); 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 (

{abortButton}
); } // data didn't arrive yet and wallet didn't communicate: if (typeof data === "undefined") return

{i18n.str`Waiting the bank to create the operation...`}

; /** * 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 ( ); } /** * Wallet POSTed the withdrawal details! Ask the * user to authorize the operation (here CAPTCHA). */ return ; } 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(null); useEffect(() => { if (focus) ref.current?.focus(); }, [focus]); return (

    { // FIXME: validate using 'parseAmount()', // deactivate submit button as long as // amount is not valid submitAmount = e.currentTarget.value; }} />

{ 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, ); }} />

); } /** * 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 (
{tab === "charge-wallet" && (

{i18n.str`Obtain digital cash`}

)} {tab === "wire-transfer" && (

{i18n.str`Transfer to bank account`}

)}
); } function undefinedIfEmpty(obj: T): T | undefined { return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) ? obj : undefined; } /** * Collect and submit login data. */ function LoginForm(): VNode { const [backendState, backendStateSetter] = useBackendState(); const { pageState, pageStateSetter } = usePageContext(); const [username, setUsername] = useState(); const [password, setPassword] = useState(); const { i18n } = useTranslationContext(); const ref = useRef(null); useEffect(() => { ref.current?.focus(); }, []); const errors = undefinedIfEmpty({ username: !username ? i18n.str`Missing username` : undefined, password: !password ? i18n.str`Missing password` : undefined, }); return (