/*
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 { createHashHistory } from "history";
import Router, { Route, route } from "preact-router";
import { StateUpdater, useEffect, useRef, useState } from "preact/hooks";
import talerLogo from "../../assets/logo-white.svg";
import { LangSelectorLikePy as LangSelector } from "../../components/menu/LangSelector.js";
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";
/**
* 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. *
***********/
function maybeDemoContent(content: VNode): VNode {
if (bankUiSettings.showDemoNav) {
return content;
}
return ;
}
/**
* Bring the state to show the public accounts page.
*/
function goPublicAccounts(pageStateSetter: StateUpdater) {
return () =>
pageStateSetter((prevState) => ({
...prevState,
showPublicHistories: true,
}));
}
/**
* 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 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 = hooks.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 = 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];
}
/**
* 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 = hooks.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];
}
/**
* 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: CredentialsRequestType,
/**
* 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: CredentialsRequestType,
/**
* 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 ErrorBanner(Props: any): VNode | null {
const [pageState, pageStateSetter] = Props.pageState;
// const { i18n } = useTranslationContext();
if (!pageState.error) return null;
const rval = (
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
.
);
}
/**
* Show only the account's balance. NOTE: the backend state
* is mostly needed to provide the user's credentials to POST
* to the bank.
*/
function Account(Props: any): VNode {
const { cache } = useSWRConfig();
const { accountLabel, backendState } = Props;
// Getting the bank account balance:
const endpoint = `access-api/accounts/${accountLabel}`;
const { data, error, mutate } = useSWR(endpoint, {
// refreshInterval: 0,
// revalidateIfStale: false,
// revalidateOnMount: false,
// revalidateOnFocus: false,
// revalidateOnReconnect: false,
});
const { pageState, pageStateSetter: setPageState } = usePageContext();
const {
withdrawalInProgress,
withdrawalId,
isLoggedIn,
talerWithdrawUri,
timestamp,
} = pageState;
const { i18n } = useTranslationContext();
useEffect(() => {
mutate();
}, [timestamp]);
/**
* This part shows a list of transactions: with 5 elements by
* default and offers a "load more" button.
*/
const [txPageNumber, setTxPageNumber] = useTransactionPageNumber();
const txsPages = [];
for (let i = 0; i <= txPageNumber; i++)
txsPages.push();
if (typeof error !== "undefined") {
console.log("account error", error, endpoint);
/**
* FIXME: to minimize the code, try only one invocation
* of pageStateSetter, after having decided the error
* message in the case-branch.
*/
switch (error.status) {
case 404: {
setPageState((prevState: PageStateType) => ({
...prevState,
isLoggedIn: false,
error: {
title: i18n.str`Username or account label '${accountLabel}' not found. Won't login.`,
},
}));
/**
* 404 should never stick to the cache, because they
* taint successful future registrations. How? After
* registering, the user gets navigated to this page,
* therefore a previous 404 on this SWR key (the requested
* resource) would still appear as valid and cause this
* page not to be shown! A typical case is an attempted
* login of a unregistered user X, and then a registration
* attempt of the same user X: in this case, the failed
* login would cache a 404 error to X's profile, resulting
* in the legitimate request after the registration to still
* be flagged as 404. Clearing the cache should prevent
* this. */
(cache as any).clear();
return
;
}
default: {
setPageState((prevState: PageStateType) => ({
...prevState,
isLoggedIn: false,
error: {
title: i18n.str`Account information could not be retrieved.`,
debug: JSON.stringify(error),
},
}));
return
Unknown problem...
;
}
}
}
const balance = !data ? undefined : Amounts.parseOrThrow(data.balance.amount);
const accountNumber = !data ? undefined : getIbanFromPayto(data.paytoUri);
const balanceIsDebit = data && data.balance.credit_debit_indicator == "debit";
/**
* This block shows the withdrawal QR code.
*
* A withdrawal operation replaces everything in the page and
* (ToDo:) starts polling the backend until either the wallet
* selected a exchange and reserve public key, or a error / abort
* happened.
*
* After reaching one of the above states, the user should be
* brought to this ("Account") page where they get informed about
* the outcome.
*/
console.log(`maybe new withdrawal ${talerWithdrawUri}`);
if (talerWithdrawUri) {
console.log("Bank created a new Taler withdrawal");
return (
);
}
const balanceValue = !balance ? undefined : Amounts.stringifyValue(balance);
return (
);
}
/**
* Factor out login credentials.
*/
function SWRWithCredentials(props: any): VNode {
const { username, password, backendUrl } = props;
const headers = new Headers();
headers.append("Authorization", `Basic ${btoa(`${username}:${password}`)}`);
console.log("Likely backend base URL", backendUrl);
return (
{
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 } = useTranslationContext();
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,
showPublicHistories: false,
error: {
title: i18n.str`List of public accounts was not found.`,
debug: JSON.stringify(error),
},
}));
break;
default:
console.log("public accounts: non-404 error", error);
Props.pageStateSetter((prevState: PageStateType) => ({
...prevState,
showPublicHistories: false,
error: {
title: i18n.str`List of public accounts could not be retrieved.`,
debug: JSON.stringify(error),
},
}));
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(
{i18n.str`Welcome to ${bankUiSettings.bankName}!`}
);
}
if (typeof backendState === "undefined") {
pageStateSetter((prevState) => ({
...prevState,
isLoggedIn: false,
error: {
title: i18n.str`Page has a problem: logged in but backend state is lost.`,
},
}));
return
Error: waiting for details...
;
}
console.log("Showing the profile page..");
return (
);
}
function Redirect({ to }: { to: string }): VNode {
useEffect(() => {
route(to, true);
}, []);
return
being redirected to {to}
;
}
/**
* If the user is logged in, it displays
* the balance, otherwise it offers to login.
*/
export function BankHome(): VNode {
const history = createHashHistory();
return (
);
}