From 3e060b80428943c6562250a6ff77eff10a0259b7 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Mon, 24 Oct 2022 10:46:14 +0200 Subject: repo: integrate packages from former merchant-backoffice.git --- packages/demobank-ui/src/pages/home/index.tsx | 2018 +++++++++++++++++++++++++ 1 file changed, 2018 insertions(+) create mode 100644 packages/demobank-ui/src/pages/home/index.tsx (limited to 'packages/demobank-ui/src/pages/home/index.tsx') diff --git a/packages/demobank-ui/src/pages/home/index.tsx b/packages/demobank-ui/src/pages/home/index.tsx new file mode 100644 index 000000000..bf9764b78 --- /dev/null +++ b/packages/demobank-ui/src/pages/home/index.tsx @@ -0,0 +1,2018 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import useSWR, { SWRConfig as _SWRConfig, useSWRConfig } from 'swr'; +import { h, Fragment, VNode, createContext } from 'preact'; +import { useRef, useState, useEffect, StateUpdater, useContext } from 'preact/hooks'; +import { Buffer } from 'buffer'; +import { useTranslator, Translate } from '../../i18n'; +import { QR } from '../../components/QR'; +import { useNotNullLocalStorage, useLocalStorage } from '../../hooks'; +import '../../scss/main.scss'; +import talerLogo from '../../assets/logo-white.svg'; +import { LangSelectorLikePy as LangSelector } from '../../components/menu/LangSelector'; + +// FIXME: Fix usages of SWRConfig, doing this isn't the best practice (but hey, it works for now) +const SWRConfig = _SWRConfig as any; + +const UI_ALLOW_REGISTRATIONS = ('__LIBEUFIN_UI_ALLOW_REGISTRATIONS__') ?? 1; +const UI_IS_DEMO = ('__LIBEUFIN_UI_IS_DEMO__') ?? 0; +const UI_BANK_NAME = ('__LIBEUFIN_UI_BANK_NAME__') ?? 'Taler Bank'; + +/** + * 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. + */ + +/*********** + * Globals * + **********/ + +/************ + * Contexts * + ***********/ +const CurrencyContext = createContext(null); +const PageContext = createContext(null); + +/********************************************** + * Type definitions for states and API calls. * + *********************************************/ + +/** + * Has the information to reach and + * authenticate at the bank's backend. + */ +interface BackendStateType { + url: string; + username: string; + password: string; +} + +/** + * Request body of POST /transactions. + * + * If the amount appears twice: both as a Payto parameter and + * in the JSON dedicate field, the one on the Payto URI takes + * precedence. + */ +interface TransactionRequestType { + paytoUri: string; + amount?: string; // with currency. +} + +/** + * Request body of /register. + */ +interface CredentialsRequestType { + username: string; + password: string; +} + +/** + * Request body of /register. + */ +interface LoginRequestType { + username: string; + password: string; +} + +interface WireTransferRequestType { + iban: string; + subject: string; + amount: string; +} + +interface Amount { + value: string; + currency: string; +} + +/** + * Track page state. + */ +interface PageStateType { + isLoggedIn: boolean; + isRawPayto: boolean; + tryRegister: boolean; + showPublicHistories: boolean; + hasError: boolean; + hasInfo: boolean; + withdrawalInProgress: boolean; + error?: string; + info?: string; + talerWithdrawUri?: string; + /** + * Not strictly a presentational value, could + * be moved in a future "withdrawal state" object. + */ + withdrawalId?: string; +} + +/** + * Bank account specific information. + */ +interface AccountStateType { + balance: string; + /* FIXME: Need history here. */ +} + +/************ + * Helpers. * + ***********/ + +function maybeDemoContent(content: VNode) { + if (UI_IS_DEMO) return content; +} + +async function fetcher(url: string) { + return fetch(url).then((r) => (r.json())); +} + +function genCaptchaNumbers(): string { + return `${Math.floor(Math.random() * 10)} + ${Math.floor(Math.random() * 10)}`; +} +/** + * Bring the state to show the public accounts page. + */ +function goPublicAccounts(pageStateSetter: StateUpdater) { + return () => pageStateSetter((prevState) => ({ ...prevState, showPublicHistories: true })) +} + +/** + * Validate (the number part of) an amount. If needed, + * replace comma with a dot. Returns 'false' whenever + * the input is invalid, the valid amount otherwise. + */ +function validateAmount(maybeAmount: string): any { + const amountRegex = '^[0-9]+(\.[0-9]+)?$'; + if (!maybeAmount) { + console.log(`Entered amount (${maybeAmount}) mismatched pattern.`); + return; + } + if (typeof maybeAmount !== 'undefined' || maybeAmount !== '') { + console.log(`Maybe valid amount: ${maybeAmount}`); + // tolerating comma instead of point. + const re = RegExp(amountRegex) + if (!re.test(maybeAmount)) { + console.log(`Not using invalid amount '${maybeAmount}'.`); + return false; + } + } + return maybeAmount; +} + +/** + * Extract IBAN from a Payto URI. + */ +function getIbanFromPayto(url: string): string { + const pathSplit = new URL(url).pathname.split('/'); + let lastIndex = pathSplit.length - 1; + // Happens if the path ends with "/". + if (pathSplit[lastIndex] === '') lastIndex--; + const iban = pathSplit[lastIndex]; + return iban; +} + +/** + * Extract value and currency from a $currency:x.y amount. + */ +function parseAmount(val: string): Amount { + const format = /^[A-Z]+:[0-9]+(\.[0-9]+)?$/; + if (!format.test(val)) + throw Error(`Backend gave invalid amount: ${val}.`) + const amountSplit = val.split(':'); + return { value: amountSplit[1], currency: amountSplit[0] } +} + +/** + * Get username from the backend state, and throw + * exception if not found. + */ +function getUsername(backendState: BackendStateTypeOpt): string { + if (typeof backendState === 'undefined') + throw Error('Username can\'t be found in a undefined backend state.') + + 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: BackendStateTypeOpt, + 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 = 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) { + const headers = new Headers(); + headers.append( + 'Authorization', + `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + ); + headers.append( + 'Content-Type', + 'application/json' + ) + return headers; +} + +// Window can be mocked this way: +// https://gist.github.com/theKashey/07090691c0a4680ed773375d8dbeebc1#file-webpack-conf-js +// That allows the app to be pointed to a arbitrary +// euFin backend when launched via "pnpm dev". +const getRootPath = () => { + const maybeRootPath = typeof window !== undefined + ? window.location.origin + window.location.pathname + : '/'; + if (!maybeRootPath.endsWith('/')) return `${maybeRootPath}/`; + return maybeRootPath; +}; + +/******************* + * State managers. * + ******************/ + +/** + * Stores in the state a object containing a 'username' + * and 'password' field, in order to avoid losing the + * handle of the data entered by the user in fields. + */ +function useShowPublicAccount( + state?: string +): [string | undefined, StateUpdater] { + + const ret = useLocalStorage('show-public-account', JSON.stringify(state)); + const retObj: string | undefined = 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] +} + +/** + * 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 = 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 = 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] +} + +/** + * Stores in the state a object containing a 'username' + * and 'password' field, in order to avoid losing the + * handle of the data entered by the user in fields. + */ +type CredentialsRequestTypeOpt = CredentialsRequestType | undefined; +function useCredentialsRequestType( + state?: CredentialsRequestType +): [CredentialsRequestTypeOpt, StateUpdater] { + + const ret = useLocalStorage('credentials-request-state', JSON.stringify(state)); + const retObj: CredentialsRequestTypeOpt = 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] +} + +/** + * Return getters and setters for + * login credentials and backend's + * base URL. + */ +type BackendStateTypeOpt = BackendStateType | undefined; +function useBackendState( + state?: BackendStateType +): [BackendStateTypeOpt, StateUpdater] { + + const ret = useLocalStorage('backend-state', JSON.stringify(state)); + const retObj: BackendStateTypeOpt = 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] +} + +/** + * Keep mere business information, like account balance or + * transactions history. + */ +type AccountStateTypeOpt = AccountStateType | undefined; +function useAccountState( + state?: AccountStateType +): [AccountStateTypeOpt, StateUpdater] { + + const ret = useLocalStorage('account-state', JSON.stringify(state)); + const retObj: AccountStateTypeOpt = 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] +} + +/** + * Wrapper providing defaults. + */ +function usePageState( + state: PageStateType = { + isLoggedIn: false, + isRawPayto: false, + tryRegister: false, + showPublicHistories: false, + hasError: false, + hasInfo: false, + withdrawalInProgress: false, + } +): [PageStateType, StateUpdater] { + const ret = useNotNullLocalStorage('page-state', JSON.stringify(state)); + const retObj: PageStateType = JSON.parse(ret[0]); + console.log('Current page state', retObj); + const retSetter: StateUpdater = function (val) { + const newVal = val instanceof Function ? JSON.stringify(val(retObj)) : JSON.stringify(val) + console.log('Setting new page state', newVal) + 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: BackendStateTypeOpt, + withdrawalId: string | undefined, + pageStateSetter: StateUpdater +) { + if (typeof backendState === 'undefined') { + console.log('No credentials found.'); + pageStateSetter((prevState) => ({ ...prevState, hasError: true, error: 'No credentials found.' })) + return; + } + if (typeof withdrawalId === 'undefined') { + console.log('No withdrawal ID found.'); + pageStateSetter((prevState) => ({ ...prevState, hasError: true, error: '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, + hasError: true, + error: `Could not abort the withdrawal: ${error}` + })) + return; + } + if (!res.ok) { + console.log(`Withdrawal abort gave response error (${res.status})`, res.statusText); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: `Withdrawal abortion gave response error (${res.status})` + })) + 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: BackendStateTypeOpt, + withdrawalId: string | undefined, + pageStateSetter: StateUpdater +) { + + if (typeof backendState === 'undefined') { + console.log('No credentials found.'); + pageStateSetter((prevState) => ({ ...prevState, hasError: true, error: 'No credentials found.' })) + return; + } + if (typeof withdrawalId === 'undefined') { + console.log('No withdrawal ID found.'); + pageStateSetter((prevState) => ({ ...prevState, hasError: true, error: '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, + hasError: true, + error: `Could not confirm the withdrawal: ${error}` + })) + return; + } + if (res ? !res.ok : true) { // assume not ok if res is null + console.log(`Withdrawal confirmation gave response error (${res.status})`, res.statusText); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: `Withdrawal confirmation gave response error (${res.status})` + })) + 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: BackendStateTypeOpt, + pageStateSetter: StateUpdater, + /** + * Optional since the raw payto form doesn't have + * a stateful management of the input data yet. + */ + cleanUpForm: () => 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, + hasError: true, + error: `Could not create the wire transfer: ${error}` + })) + return; + } + // POST happened, status not sure yet. + if (!res.ok) { + const responseText = JSON.stringify(await res.json()); + console.log(`Transfer creation gave response error: ${responseText} (${res.status})`); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: `Transfer creation gave response error: ${responseText} (${res.status})` + })) + return; + } + // status is 200 OK here, tell the user. + console.log('Wire transfer created!'); + pageStateSetter((prevState) => ({ + ...prevState, + hasInfo: true, + 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: BackendStateTypeOpt, + pageStateSetter: StateUpdater +) { + if (typeof backendState === 'undefined') { + console.log('Page has a problem: no credentials found in the state.'); + pageStateSetter((prevState) => ({ ...prevState, hasError: true, error: '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, + hasError: true, + error: `Could not create withdrawal operation: ${error}` + })) + return; + } + if (!res.ok) { + const responseText = await res.text(); + console.log(`Withdrawal creation gave response error: ${responseText} (${res.status})`); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: `Withdrawal creation gave response error: ${responseText} (${res.status})` + })) + 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: CredentialsRequestType, + /** + * FIXME: figure out if the two following + * functions can be retrieved from the state. + */ + backendStateSetter: StateUpdater, + pageStateSetter: StateUpdater +) { + + /** + * 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 = getRootPath(); + 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: CredentialsRequestType, + /** + * FIXME: figure out if the two following + * functions can be retrieved somewhat from + * the state. + */ + backendStateSetter: StateUpdater, + pageStateSetter: StateUpdater +) { + + let baseUrl = getRootPath(); + /** + * 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:any; + try { + res = await fetch(url.href, { + method: 'POST', + body: JSON.stringify(req), + headers + }); + } catch (error) { + console.log(`Could not POST new registration to the bank (${url.href})`, error); + pageStateSetter((prevState) => ({ + ...prevState, hasError: true, error: 'Registration failed, please report.' + })); + return; + } + if (!res.ok) { + const errorRaw = await res.text(); + console.log(`New registration gave response error (${res.status})`, errorRaw); + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + error: errorRaw + })); + } else { + pageStateSetter((prevState) => ({ + ...prevState, + isLoggedIn: true, + tryRegister: false + })); + backendStateSetter((prevState) => ({ + ...prevState, + url: baseUrl, + username: req.username, + password: req.password, + })); + } +} + +/************************** + * Functional components. * + *************************/ + +function Currency(): VNode { + const { data, error } = useSWR(`${getRootPath()}integration-api/config`, fetcher); + if (typeof error !== 'undefined') + return error: currency could not be retrieved; + + if (typeof data === 'undefined') return "..."; + console.log('found bank config', data); + return data.currency; +} + +function ErrorBanner(Props: any): VNode | null { + const [pageState, pageStateSetter] = Props.pageState; + const i18n = useTranslator(); + if (!pageState.hasError) return null; + + const rval = ( +

{pageState.error} +

); + delete pageState.error; + pageState.hasError = false; + return rval; +} + +function StatusBanner(Props: any): VNode | null { + const [pageState, pageStateSetter] = Props.pageState; + const i18n = useTranslator(); + if (!pageState.hasInfo) return null; + + const rval = ( +

{pageState.error} +

); + delete pageState.info_msg; + pageState.hasInfo = false; + return rval; +} + +function BankFrame(Props: any): VNode { + const i18n = useTranslator(); + const [pageState, pageStateSetter] = useContext(PageContext); + console.log('BankFrame state', pageState); + const logOut = ( + ); + + // Prepare demo sites links. + const DEMO_SITES = [ + ['Landing', '__DEMO_SITE_LANDING_URL__'], + ['Bank', '__DEMO_SITE_BANK_URL__'], + ['Essay Shop', '__DEMO_SITE_BLOG_URL__'], + ['Donations', '__DEMO_SITE_DONATIONS_URL__'], + ['Survey', '__DEMO_SITE_SURVEY_URL__'], + ]; + const demo_sites = []; + for (const i in DEMO_SITES) + demo_sites.push({DEMO_SITES[i][0]}) + + return ( + +
+ +
+

+ + { + UI_BANK_NAME + } + + +

{ + maybeDemoContent(

+ This part of the demo shows how a bank that supports + Taler directly would work. In addition to using your own + bank account, you can also see the transaction history of + some Public Accounts. +

+ ) + } +
+ + {i18n`Taler logo`} + +
+ +
+ + + {pageState.isLoggedIn ? logOut : null} + {Props.children} +
+ +
); +} + + +function PaytoWireTransfer(Props: any): VNode { + const currency = useContext(CurrencyContext); + const [pageState, pageStateSetter] = useContext(PageContext); // NOTE: used for go-back button? + const [submitData, submitDataSetter] = useWireTransferRequestType(); + const [rawPaytoInput, rawPaytoInputSetter] = useRawPaytoInputType(); + const i18n = useTranslator(); + const { focus, backendState } = Props + const amountRegex = '^[0-9]+(\.[0-9]+)?$'; + const ibanRegex = '^[A-Z][A-Z][0-9]+$'; + const receiverInput = ''; + const subjectInput = ''; + let transactionData: TransactionRequestType; + const ref = useRef(null) + useEffect(() => { + if (focus) ref.current?.focus(); + }, [focus, pageState.isRawPayto]); + + 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.replace(',', '.'), + })) + }} /> +   + +

+

+ { + 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, hasError: true, error: i18n`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: '', + iban: '', + subject: '' + })) + ); + }} /> +

+
+

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

+
+ ); + + return ( +
+

+ {i18n`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(p => '') + ); + }} /> +

+

{ + console.log('switch to wire-transfer-form'); + pageStateSetter((prevState: any) => ({ ...prevState, isRawPayto: false })); + }}>{i18n`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] = useContext(PageContext); + const { backendState } = Props; + const i18n = useTranslator(); + const captchaNumbers = { + a: Math.floor(Math.random() * 10), + b: Math.floor(Math.random() * 10) + } + let captchaAnswer = ''; + + return ( +

{i18n`Confirm Withdrawal`}

+
+
+
+
+

{i18n`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. +

+
+
+
+
); +} + +function QrCodeSection({ talerWithdrawUri, abortButton }: { talerWithdrawUri: string, abortButton: h.JSX.Element }) { + const i18n = useTranslator(); + useEffect(() => { + //Taler Wallet WebExtension is listening to headers response and tab updates. + //In the SPA there is no header response with the Taler URI so + //this hack manually triggers the tab update after the QR is in the DOM. + window.location.href = `${window.location.href.split('#')[0]}#` + }, []) + + return
+

{i18n`Charge Taler Wallet`}

+

{i18n`You can use this QR code to withdraw to your mobile wallet:`}

+ {QR({ text: talerWithdrawUri })} +

Click {i18n`this link`} to open your Taler wallet!

+
+ {abortButton} +
+} + +/** + * 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] = useContext(PageContext); + const { + withdrawalId, + talerWithdrawUri, + accountLabel, + backendState } = Props; + const i18n = useTranslator(); + const abortButton = { + pageStateSetter((prevState: PageStateType) => { + const { withdrawalId, talerWithdrawUri, ...rest } = prevState; + return { ...rest, withdrawalInProgress: false }; + }) + }}>{i18n`Abort`} + + console.log(`Showing withdraw URI: ${talerWithdrawUri}`); + // waiting for the wallet: + + const { data, error, mutate } = useSWR(`integration-api/withdrawal-operation/${withdrawalId}`); + + if (typeof error !== 'undefined') { + console.log(`withdrawal (${withdrawalId}) was never (correctly) created at the bank...`, error); + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + hasError: true, + error: i18n`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`Waiting the bank to create the operaion...`}

+ + + /** + * 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, + hasError: true, + error: i18n`This withdrawal was aborted!` + }; + }) + + + if (!data.selection_done) { + setTimeout(() => mutate(), 1000); // check again after 1 second. + return (); + } + /** + * Wallet POSTed the withdrawal details! Ask the + * user to authorize the operation (here CAPTCHA). + */ + return (); +} + + + +function WalletWithdraw(Props: any): VNode { + const { backendState, pageStateSetter, focus } = Props; + const currency = useContext(CurrencyContext); + const i18n = useTranslator(); + let submitAmount = '5.00'; + const amountRegex = '^[0-9]+(\.[0-9]+)?$'; + + 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) return; + createWithdrawalCall( + `${currency}:${submitAmount}`, + backendState, + pageStateSetter + ) + }} /> +
+

+
+ ) +} + + +/** + * Let the user choose a payment option, + * then specify the details trigger the action. + */ +function PaymentOptions(Props: any): VNode { + const { backendState, pageStateSetter, focus } = Props; + const currency = useContext(CurrencyContext); + const i18n = useTranslator(); + + const [tab, setTab] = useState<'charge-wallet' | 'wire-transfer'>('charge-wallet') + + + return (
+
+
+ + +
+ {tab === 'charge-wallet' && +
+

{i18n`Charge Taler wallet`}

+ +
+ } + {tab === 'wire-transfer' && +
+

{i18n`Wire to bank account`}

+ +
+ } +
+
); +} + +function RegistrationButton(Props: any): VNode { + const { backendStateSetter, pageStateSetter } = Props; + const i18n = useTranslator(); + if (UI_ALLOW_REGISTRATIONS) + return (); + + + return (); + +} + +/** + * Collect and submit login data. + */ +function LoginForm(Props: any): VNode { + const { backendStateSetter, pageStateSetter } = Props; + const [submitData, submitDataSetter] = useCredentialsRequestType(); + const i18n = useTranslator(); + const ref = useRef(null) + useEffect(() => { + ref.current?.focus(); + }, []); + return (); +} + +/** + * Collect and submit registration data. + */ +function RegistrationForm(Props: any): VNode { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [pageState, pageStateSetter] = useContext(PageContext); + const [submitData, submitDataSetter] = useCredentialsRequestType(); + const i18n = useTranslator(); + // https://stackoverflow.com/questions/36683770/how-to-get-the-value-of-an-input-field-using-reactjs + return ( + +

+ { + i18n`Welcome to ${UI_BANK_NAME}!` + } +

+
+
+
+
+

{i18n`Please register!`}

+

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

+ { + submitDataSetter((submitData: any) => ({ + ...submitData, + password: e.currentTarget.value, + })) + }} /> +
+ {/* + + // FIXME: add input validation (must start with +, otherwise only numbers) + { + submitDataSetter((submitData: any) => ({ + ...submitData, + phone: e.currentTarget.value, + }))}} /> +
+ */} + + {/* FIXME: should use a different color */} + +
+
+
+
+
+ ) +} + +/** + * Show one page of transactions. + */ +function Transactions(Props: any): VNode { + const { pageNumber, accountLabel } = Props; + const i18n = useTranslator(); + const { data, error } = useSWR( + `access-api/accounts/${accountLabel}/transactions?page=${pageNumber}` + ); + if (typeof error !== 'undefined') { + console.log('transactions not found error', error); + switch (error.status) { + case 404: { + return

Transactions page {pageNumber} was not found.

+ } + case 401: { + return

Wrong credentials given.

+ } + default: { + return

Transaction page {pageNumber} could not be retrieved.

+ } + } + } + if (!data) { + console.log(`History data of ${accountLabel} not arrived`); + return

"Transactions page loading..."

; + } + console.log(`History data of ${accountLabel}`, data); + return (
+ + + + + + + + + + + {data.transactions.map((item: any, idx: number) => { + const sign = item.direction == 'DBIT' ? '-' : ''; + const counterpart = item.direction == 'DBIT' ? item.creditorIban : item.debtorIban; + // Pattern: + // + // DD/MM YYYY subject -5 EUR + // DD/MM YYYY subject 5 EUR + const dateRegex = /^([0-9]{4})-([0-9]{2})-([0-9]{1,2})/ + const dateParse = dateRegex.exec(item.date) + const date = dateParse !== null ? `${dateParse[3]}/${dateParse[2]} ${dateParse[1]}` : 'date not found' + return ( + + + + + ); + })} + +
{i18n`Date`}{i18n`Amount`}{i18n`Counterpart`}{i18n`Subject`}
{date}{sign}{item.amount} {item.currency}{counterpart}{item.subject}
+
); +} + +/** + * 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 } = useSWR(endpoint); + const [pageState, pageStateSetter] = useContext(PageContext); + const { + withdrawalInProgress, + withdrawalId, + isLoggedIn, + talerWithdrawUri } = pageState; + const i18n = useTranslator(); + /** + * 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() + + if (typeof error !== 'undefined') { + console.log('account error', error); + /** + * 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: { + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + hasError: true, + isLoggedIn: false, + error: i18n`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

Profile not found...

; + } + case 401: { + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + hasError: true, + isLoggedIn: false, + error: i18n`Wrong credentials given.` + })); + return

Wrong credentials...

; + } + default: { + pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + hasError: true, + isLoggedIn: false, + error: i18n`Account information could not be retrieved.` + })); + return

Unknown problem...

; + } + } + } + if (!data) return

Retrieving the profile page...

; + + /** + * 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 ( + + + + ); + } + const balance = parseAmount(data.balance.amount) + + return ( +
+

+ Welcome, {accountLabel} ({getIbanFromPayto(data.paytoUri)})! +

+
+
+
+

{i18n`Bank account balance`}

+ {data.balance.credit_debit_indicator == 'debit' ? (-) : null} +
{`${balance.value}`} {`${balance.currency}`}
+
+
+
+
+

{i18n`Payments`}

+ {/* FIXME: turn into button! */} + + {Props.children} + + +
+
+
+
+

{i18n`Latest transactions:`}

+ +
+
+
); +} + +/** + * Factor out login credentials. + */ +function SWRWithCredentials(props: any): VNode { + const { username, password, backendUrl } = props; + const headers = new Headers(); + headers.append( + 'Authorization', + `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` + ); + console.log('Likely backend base URL', backendUrl); + return ( + + fetch(backendUrl + url || '', { headers }).then( + (r) => { + if (!r.ok) + throw { status: r.status, json: r.json() }; + + return r.json() + } + ), + }}>{props.children} + ); +} + +function SWRWithoutCredentials(Props: any): VNode { + const { baseUrl } = Props; + console.log('Base URL', baseUrl); + return ( + + fetch(baseUrl + url || '').then( + (r) => { + if (!r.ok) + throw { status: r.status, json: r.json() }; + + return r.json() + } + ), + }}>{Props.children} + ); +} + +/** + * Show histories of public accounts. + */ +function PublicHistories(Props: any): VNode { + const [showAccount, setShowAccount] = useShowPublicAccount(); + const { data, error } = useSWR('access-api/public-accounts'); + const i18n = useTranslator(); + + if (typeof error !== 'undefined') { + console.log('account error', error); + switch (error.status) { + case 404: + console.log('public accounts: 404', error); + Props.pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + hasError: true, + showPublicHistories: false, + error: i18n`List of public accounts was not found.` + })); + break; + default: + console.log('public accounts: non-404 error', error); + Props.pageStateSetter((prevState: PageStateType) => ({ + ...prevState, + hasError: true, + showPublicHistories: false, + error: i18n`List of public accounts could not be retrieved.` + })); + break; + } + } + if (!data) + return (

Waiting public accounts list...

) + const txs: any = {}; + const accountsBar = []; + + /** + * Show the account specified in the props, or just one + * from the list if that's not given. + */ + if (typeof showAccount === 'undefined' && data.publicAccounts.length > 0) + setShowAccount(data.publicAccounts[1].accountLabel); + console.log(`Public history tab: ${showAccount}`); + + // Ask story of all the public accounts. + for (const account of data.publicAccounts) { + console.log('Asking transactions for', account.accountLabel) + const isSelected = account.accountLabel == showAccount; + accountsBar.push( +
  • + setShowAccount(account.accountLabel)}>{account.accountLabel} +
  • + ); + txs[account.accountLabel] = + } + + return ( +

    {i18n`History of public accounts`}

    +
    +
    +
    +
      {accountsBar}
    + {typeof showAccount !== 'undefined' ? txs[showAccount] :

    No public transactions found.

    } + {Props.children} +
    +
    +
    +
    ); +} + +/** + * If the user is logged in, it displays + * the balance, otherwise it offers to login. + */ +export function BankHome(): VNode { + const [backendState, backendStateSetter] = useBackendState(); + const [pageState, pageStateSetter] = usePageState(); + const [accountState, accountStateSetter] = useAccountState(); + const setTxPageNumber = useTransactionPageNumber()[1]; + const i18n = useTranslator(); + + if (pageState.showPublicHistories) + return ( + + + +
    + { + pageStateSetter((prevState: PageStateType) => + ({ ...prevState, showPublicHistories: false })) + }}>Go back +
    +
    +
    +
    ); + + if (pageState.tryRegister) { + console.log('allow registrations?', UI_ALLOW_REGISTRATIONS); + if (UI_ALLOW_REGISTRATIONS) + return ( + + + + + + ); + + return ( + + +

    {i18n`Currently, the bank is not accepting new registrations!`}

    +
    +
    + ); + } + if (pageState.isLoggedIn) { + if (typeof backendState === 'undefined') { + pageStateSetter((prevState) => ({ + ...prevState, + hasError: true, + isLoggedIn: false, + error: i18n`Page has a problem: logged in but backend state is lost.` + })); + return (

    Error: waiting for details...

    ); + } + console.log('Showing the profile page..'); + return ( + + + + + + ); + } // end of logged-in state. + + return ( + + +

    + { + i18n`Welcome to ${UI_BANK_NAME}!` + } +

    + +
    +
    + ); +} -- cgit v1.2.3