validate IBAN, removing internal iban from account form, add missing logo, do not save backend URL in login state

This commit is contained in:
Sebastian 2023-03-05 15:21:12 -03:00
parent 906eddd48a
commit 4de014927e
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
6 changed files with 339 additions and 110 deletions

View File

@ -2,7 +2,7 @@
[ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1 [ ! -d prebuilt ] && echo 'directory "prebuilt" not found. first checkout the prebuilt branch into a prebuilt directory' && exit 1
for file in index.html index.js index.css; do for file in index.html index.js index.css logo-white-U55BSKA2.svg; do
cp packages/demobank-ui/dist/$file prebuilt/demobank/ cp packages/demobank-ui/dist/$file prebuilt/demobank/
done done

View File

@ -18,6 +18,7 @@ By default, the demobank-ui points to `https://bank.demo.taler.net/demobanks/def
as the bank access API base URL. as the bank access API base URL.
This can be changed for testing by setting the URL via local storage (via your browser's devtools): This can be changed for testing by setting the URL via local storage (via your browser's devtools):
``` ```
localStorage.setItem("bank-base-url", OTHER_URL); localStorage.setItem("bank-base-url", OTHER_URL);
``` ```
@ -35,6 +36,7 @@ to the default settings:
``` ```
globalThis.talerDemobankSettings = { globalThis.talerDemobankSettings = {
backendBaseURL: "https://bank.demo.taler.net/demobanks/default/",
allowRegistrations: true, allowRegistrations: true,
bankName: "Taler Bank", bankName: "Taler Bank",
// Show explainer text and navbar to other demo sites // Show explainer text and navbar to other demo sites

View File

@ -42,26 +42,23 @@ export interface BackendCredentials {
} }
interface LoggedIn extends BackendCredentials { interface LoggedIn extends BackendCredentials {
url: string;
status: "loggedIn"; status: "loggedIn";
isUserAdministrator: boolean; isUserAdministrator: boolean;
} }
interface LoggedOut { interface LoggedOut {
url: string;
status: "loggedOut"; status: "loggedOut";
} }
const maybeRootPath = bankUiSettings.backendBaseURL;
export function getInitialBackendBaseURL(): string { export function getInitialBackendBaseURL(): string {
const overrideUrl = localStorage.getItem("bank-base-url"); const overrideUrl = localStorage.getItem("bank-base-url");
return canonicalizeBaseUrl(overrideUrl ? overrideUrl : maybeRootPath); return canonicalizeBaseUrl(
overrideUrl ? overrideUrl : bankUiSettings.backendBaseURL,
);
} }
export const defaultState: BackendState = { export const defaultState: BackendState = {
status: "loggedOut", status: "loggedOut",
url: getInitialBackendBaseURL(),
}; };
export interface BackendStateHandler { export interface BackendStateHandler {
@ -91,13 +88,12 @@ export function useBackendState(): BackendStateHandler {
return { return {
state, state,
logOut() { logOut() {
update(JSON.stringify({ ...defaultState, url: state.url })); update(JSON.stringify({ ...defaultState }));
}, },
logIn(info) { logIn(info) {
//admin is defined by the username //admin is defined by the username
const nextState: BackendState = { const nextState: BackendState = {
status: "loggedIn", status: "loggedIn",
url: state.url,
...info, ...info,
isUserAdministrator: info.username === "admin", isUserAdministrator: info.username === "admin",
}; };
@ -125,7 +121,7 @@ export function usePublicBackend(): useBackendType {
const { state } = useBackendContext(); const { state } = useBackendContext();
const { request: requestHandler } = useApiContext(); const { request: requestHandler } = useApiContext();
const baseUrl = state.url; const baseUrl = getInitialBackendBaseURL();
const request = useCallback( const request = useCallback(
function requestImpl<T>( function requestImpl<T>(
@ -201,7 +197,7 @@ export function useAuthenticatedBackend(): useBackendType {
const { request: requestHandler } = useApiContext(); const { request: requestHandler } = useApiContext();
const creds = state.status === "loggedIn" ? state : undefined; const creds = state.status === "loggedIn" ? state : undefined;
const baseUrl = state.url; const baseUrl = getInitialBackendBaseURL();
const request = useCallback( const request = useCallback(
function requestImpl<T>( function requestImpl<T>(

View File

@ -24,7 +24,11 @@ import {
import { useEffect, useMemo, useState } from "preact/hooks"; import { useEffect, useMemo, useState } from "preact/hooks";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js";
import { useAuthenticatedBackend, useMatchMutate } from "./backend.js"; import {
getInitialBackendBaseURL,
useAuthenticatedBackend,
useMatchMutate,
} from "./backend.js";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189 // FIX default import https://github.com/microsoft/TypeScript/issues/49189
import _useSWR, { SWRHook } from "swr"; import _useSWR, { SWRHook } from "swr";
@ -210,10 +214,10 @@ export interface CircuitAccountAPI {
async function getBusinessStatus( async function getBusinessStatus(
request: ReturnType<typeof useApiContext>["request"], request: ReturnType<typeof useApiContext>["request"],
url: string,
basicAuth: { username: string; password: string }, basicAuth: { username: string; password: string },
): Promise<boolean> { ): Promise<boolean> {
try { try {
const url = getInitialBackendBaseURL();
const result = await request< const result = await request<
HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData> HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData>
>(url, `circuit-api/accounts/${basicAuth.username}`, { basicAuth }); >(url, `circuit-api/accounts/${basicAuth.username}`, { basicAuth });
@ -234,7 +238,7 @@ export function useBusinessAccountFlag(): boolean | undefined {
useEffect(() => { useEffect(() => {
if (!creds) return; if (!creds) return;
getBusinessStatus(request, state.url, creds) getBusinessStatus(request, creds)
.then((result) => { .then((result) => {
setIsBusiness(result); setIsBusiness(result);
}) })

View File

@ -40,6 +40,7 @@ import {
PartialButDefined, PartialButDefined,
RecursivePartial, RecursivePartial,
undefinedIfEmpty, undefinedIfEmpty,
validateIBAN,
WithIntermediate, WithIntermediate,
} from "../utils.js"; } from "../utils.js";
import { ErrorBannerFloat } from "./BankFrame.js"; import { ErrorBannerFloat } from "./BankFrame.js";
@ -230,74 +231,78 @@ export function AdminPage({ onLoadNotOk }: Props): VNode {
</p> </p>
<section id="main"> <section id="main">
<article> {!customers.length ? (
<h2>{i18n.str`Accounts:`}</h2> <div></div>
<div class="results"> ) : (
<table class="pure-table pure-table-striped"> <article>
<thead> <h2>{i18n.str`Accounts:`}</h2>
<tr> <div class="results">
<th>{i18n.str`Username`}</th> <table class="pure-table pure-table-striped">
<th>{i18n.str`Name`}</th> <thead>
<th></th> <tr>
<th></th> <th>{i18n.str`Username`}</th>
</tr> <th>{i18n.str`Name`}</th>
</thead> <th></th>
<tbody> <th></th>
{customers.map((item, idx) => { </tr>
return ( </thead>
<tr key={idx}> <tbody>
<td> {customers.map((item, idx) => {
<a return (
href="#" <tr key={idx}>
onClick={(e) => { <td>
e.preventDefault(); <a
setShowDetails(item.username); href="#"
}} onClick={(e) => {
> e.preventDefault();
{item.username} setShowDetails(item.username);
</a> }}
</td> >
<td>{item.name}</td> {item.username}
<td> </a>
<a </td>
href="#" <td>{item.name}</td>
onClick={(e) => { <td>
e.preventDefault(); <a
setUpdatePassword(item.username); href="#"
}} onClick={(e) => {
> e.preventDefault();
change password setUpdatePassword(item.username);
</a> }}
</td> >
<td> change password
<a </a>
href="#" </td>
onClick={(e) => { <td>
e.preventDefault(); <a
setShowCashouts(item.username); href="#"
}} onClick={(e) => {
> e.preventDefault();
cashouts setShowCashouts(item.username);
</a> }}
</td> >
<td> cashouts
<a </a>
href="#" </td>
onClick={(e) => { <td>
e.preventDefault(); <a
setRemoveAccount(item.username); href="#"
}} onClick={(e) => {
> e.preventDefault();
remove setRemoveAccount(item.username);
</a> }}
</td> >
</tr> remove
); </a>
})} </td>
</tbody> </tr>
</table> );
</div> })}
</article> </tbody>
</table>
</div>
</article>
)}
</section> </section>
</Fragment> </Fragment>
); );
@ -835,15 +840,15 @@ function AccountForm({
? i18n.str`only "IBAN" target are supported` ? i18n.str`only "IBAN" target are supported`
: !IBAN_REGEX.test(parsed.iban) : !IBAN_REGEX.test(parsed.iban)
? i18n.str`IBAN should have just uppercased letters and numbers` ? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined, : validateIBAN(parsed.iban, i18n),
contact_data: undefinedIfEmpty({ contact_data: undefinedIfEmpty({
email: !newForm.contact_data?.email email: !newForm.contact_data?.email
? undefined ? i18n.str`required`
: !EMAIL_REGEX.test(newForm.contact_data.email) : !EMAIL_REGEX.test(newForm.contact_data.email)
? i18n.str`it should be an email` ? i18n.str`it should be an email`
: undefined, : undefined,
phone: !newForm.contact_data?.phone phone: !newForm.contact_data?.phone
? undefined ? i18n.str`required`
: !newForm.contact_data.phone.startsWith("+") : !newForm.contact_data.phone.startsWith("+")
? i18n.str`should start with +` ? i18n.str`should start with +`
: !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone) : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone)
@ -851,10 +856,10 @@ function AccountForm({
: undefined, : undefined,
}), }),
iban: !newForm.iban iban: !newForm.iban
? i18n.str`required` ? undefined //optional field
: !IBAN_REGEX.test(newForm.iban) : !IBAN_REGEX.test(newForm.iban)
? i18n.str`IBAN should have just uppercased letters and numbers` ? i18n.str`IBAN should have just uppercased letters and numbers`
: undefined, : validateIBAN(newForm.iban, i18n),
name: !newForm.name ? i18n.str`required` : undefined, name: !newForm.name ? i18n.str`required` : undefined,
username: !newForm.username ? i18n.str`required` : undefined, username: !newForm.username ? i18n.str`required` : undefined,
}); });
@ -866,7 +871,10 @@ function AccountForm({
return ( return (
<form class="pure-form"> <form class="pure-form">
<fieldset> <fieldset>
<label for="username">{i18n.str`Username`}</label> <label for="username">
{i18n.str`Username`}
{purpose === "create" && <b style={{ color: "red" }}>*</b>}
</label>
<input <input
name="username" name="username"
type="text" type="text"
@ -876,14 +884,17 @@ function AccountForm({
form.username = e.currentTarget.value; form.username = e.currentTarget.value;
updateForm(structuredClone(form)); updateForm(structuredClone(form));
}} }}
/> />{" "}
<ShowInputErrorLabel <ShowInputErrorLabel
message={errors?.username} message={errors?.username}
isDirty={form.username !== undefined} isDirty={form.username !== undefined}
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label>{i18n.str`Name`}</label> <label>
{i18n.str`Name`}
{purpose === "create" && <b style={{ color: "red" }}>*</b>}
</label>
<input <input
disabled={purpose !== "create"} disabled={purpose !== "create"}
value={form.name ?? ""} value={form.name ?? ""}
@ -897,23 +908,28 @@ function AccountForm({
isDirty={form.name !== undefined} isDirty={form.name !== undefined}
/> />
</fieldset> </fieldset>
{purpose !== "create" && (
<fieldset>
<label>{i18n.str`Internal IBAN`}</label>
<input
disabled={true}
value={form.iban ?? ""}
onChange={(e) => {
form.iban = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.iban}
isDirty={form.iban !== undefined}
/>
</fieldset>
)}
<fieldset> <fieldset>
<label>{i18n.str`Internal IBAN`}</label> <label>
<input {i18n.str`Email`}
disabled={purpose !== "create"} {purpose !== "show" && <b style={{ color: "red" }}>*</b>}
value={form.iban ?? ""} </label>
onChange={(e) => {
form.iban = e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.iban}
isDirty={form.iban !== undefined}
/>
</fieldset>
<fieldset>
<label>{i18n.str`Email`}</label>
<input <input
disabled={purpose === "show"} disabled={purpose === "show"}
value={form.contact_data.email ?? ""} value={form.contact_data.email ?? ""}
@ -928,7 +944,10 @@ function AccountForm({
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label>{i18n.str`Phone`}</label> <label>
{i18n.str`Phone`}
{purpose !== "show" && <b style={{ color: "red" }}>*</b>}
</label>
<input <input
disabled={purpose === "show"} disabled={purpose === "show"}
value={form.contact_data.phone ?? ""} value={form.contact_data.phone ?? ""}
@ -943,12 +962,15 @@ function AccountForm({
/> />
</fieldset> </fieldset>
<fieldset> <fieldset>
<label>{i18n.str`Cashout address`}</label> <label>
{i18n.str`Cashout address`}
{purpose !== "show" && <b style={{ color: "red" }}>*</b>}
</label>
<input <input
disabled={purpose === "show"} disabled={purpose === "show"}
value={form.cashout_address ?? ""} value={(form.cashout_address ?? "").substring("payto://iban/".length)}
onChange={(e) => { onChange={(e) => {
form.cashout_address = e.currentTarget.value; form.cashout_address = "payto://iban/" + e.currentTarget.value;
updateForm(structuredClone(form)); updateForm(structuredClone(form));
}} }}
/> />

View File

@ -161,3 +161,208 @@ export function buildRequestErrorMessage(
} }
return result; return result;
} }
export const COUNTRY_TABLE = {
AE: "U.A.E.",
AF: "Afghanistan",
AL: "Albania",
AM: "Armenia",
AN: "Netherlands Antilles",
AR: "Argentina",
AT: "Austria",
AU: "Australia",
AZ: "Azerbaijan",
BA: "Bosnia and Herzegovina",
BD: "Bangladesh",
BE: "Belgium",
BG: "Bulgaria",
BH: "Bahrain",
BN: "Brunei Darussalam",
BO: "Bolivia",
BR: "Brazil",
BT: "Bhutan",
BY: "Belarus",
BZ: "Belize",
CA: "Canada",
CG: "Congo",
CH: "Switzerland",
CI: "Cote d'Ivoire",
CL: "Chile",
CM: "Cameroon",
CN: "People's Republic of China",
CO: "Colombia",
CR: "Costa Rica",
CS: "Serbia and Montenegro",
CZ: "Czech Republic",
DE: "Germany",
DK: "Denmark",
DO: "Dominican Republic",
DZ: "Algeria",
EC: "Ecuador",
EE: "Estonia",
EG: "Egypt",
ER: "Eritrea",
ES: "Spain",
ET: "Ethiopia",
FI: "Finland",
FO: "Faroe Islands",
FR: "France",
GB: "United Kingdom",
GD: "Caribbean",
GE: "Georgia",
GL: "Greenland",
GR: "Greece",
GT: "Guatemala",
HK: "Hong Kong",
// HK: "Hong Kong S.A.R.",
HN: "Honduras",
HR: "Croatia",
HT: "Haiti",
HU: "Hungary",
ID: "Indonesia",
IE: "Ireland",
IL: "Israel",
IN: "India",
IQ: "Iraq",
IR: "Iran",
IS: "Iceland",
IT: "Italy",
JM: "Jamaica",
JO: "Jordan",
JP: "Japan",
KE: "Kenya",
KG: "Kyrgyzstan",
KH: "Cambodia",
KR: "South Korea",
KW: "Kuwait",
KZ: "Kazakhstan",
LA: "Laos",
LB: "Lebanon",
LI: "Liechtenstein",
LK: "Sri Lanka",
LT: "Lithuania",
LU: "Luxembourg",
LV: "Latvia",
LY: "Libya",
MA: "Morocco",
MC: "Principality of Monaco",
MD: "Moldava",
// MD: "Moldova",
ME: "Montenegro",
MK: "Former Yugoslav Republic of Macedonia",
ML: "Mali",
MM: "Myanmar",
MN: "Mongolia",
MO: "Macau S.A.R.",
MT: "Malta",
MV: "Maldives",
MX: "Mexico",
MY: "Malaysia",
NG: "Nigeria",
NI: "Nicaragua",
NL: "Netherlands",
NO: "Norway",
NP: "Nepal",
NZ: "New Zealand",
OM: "Oman",
PA: "Panama",
PE: "Peru",
PH: "Philippines",
PK: "Islamic Republic of Pakistan",
PL: "Poland",
PR: "Puerto Rico",
PT: "Portugal",
PY: "Paraguay",
QA: "Qatar",
RE: "Reunion",
RO: "Romania",
RS: "Serbia",
RU: "Russia",
RW: "Rwanda",
SA: "Saudi Arabia",
SE: "Sweden",
SG: "Singapore",
SI: "Slovenia",
SK: "Slovak",
SN: "Senegal",
SO: "Somalia",
SR: "Suriname",
SV: "El Salvador",
SY: "Syria",
TH: "Thailand",
TJ: "Tajikistan",
TM: "Turkmenistan",
TN: "Tunisia",
TR: "Turkey",
TT: "Trinidad and Tobago",
TW: "Taiwan",
TZ: "Tanzania",
UA: "Ukraine",
US: "United States",
UY: "Uruguay",
VA: "Vatican",
VE: "Venezuela",
VN: "Viet Nam",
YE: "Yemen",
ZA: "South Africa",
ZW: "Zimbabwe",
};
/**
* An IBAN is validated by converting it into an integer and performing a
* basic mod-97 operation (as described in ISO 7064) on it.
* If the IBAN is valid, the remainder equals 1.
*
* The algorithm of IBAN validation is as follows:
* 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid
* 2.- Move the four initial characters to the end of the string
* 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35
* 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97
*
* If the remainder is 1, the check digit test is passed and the IBAN might be valid.
*
*/
export function validateIBAN(
iban: string,
i18n: ReturnType<typeof useTranslationContext>["i18n"],
): string | undefined {
// Check total length
if (iban.length < 4)
return i18n.str`IBAN numbers usually have more that 4 digits`;
if (iban.length > 34)
return i18n.str`IBAN numbers usually have less that 34 digits`;
const A_code = "A".charCodeAt(0);
const Z_code = "Z".charCodeAt(0);
const IBAN = iban.toUpperCase();
// check supported country
const code = IBAN.substring(0, 2);
const found = code in COUNTRY_TABLE;
if (!found) return i18n.str`IBAN country code not found`;
// 2.- Move the four initial characters to the end of the string
const step2 = IBAN.substring(4) + iban.substring(0, 4);
const step3 = Array.from(step2)
.map((letter) => {
const code = letter.charCodeAt(0);
if (code < A_code || code > Z_code) return letter;
return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
})
.join("");
const checksum = calculate_iban_checksum(step3);
if (checksum !== 1)
return i18n.str`IBAN number is not valid, checksum is wrong`;
return undefined;
}
function calculate_iban_checksum(str: string): number {
const numberStr = str.substring(0, 5);
const rest = str.substring(5);
const number = parseInt(numberStr, 10);
const result = number % 97;
if (rest.length > 0) {
return calculate_iban_checksum(`${result}${rest}`);
}
return result;
}