/* 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}!` }

    ); }