diff options
-rwxr-xr-x | copy-demobank-into-prebuilt.sh | 2 | ||||
-rw-r--r-- | packages/demobank-ui/README.md | 2 | ||||
-rw-r--r-- | packages/demobank-ui/src/hooks/backend.ts | 16 | ||||
-rw-r--r-- | packages/demobank-ui/src/hooks/circuit.ts | 10 | ||||
-rw-r--r-- | packages/demobank-ui/src/pages/AdminPage.tsx | 214 | ||||
-rw-r--r-- | packages/demobank-ui/src/utils.ts | 205 |
6 files changed, 339 insertions, 110 deletions
diff --git a/copy-demobank-into-prebuilt.sh b/copy-demobank-into-prebuilt.sh index 5927eac96..3fd4ec5c5 100755 --- a/copy-demobank-into-prebuilt.sh +++ b/copy-demobank-into-prebuilt.sh @@ -2,7 +2,7 @@ [ ! -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/ done diff --git a/packages/demobank-ui/README.md b/packages/demobank-ui/README.md index b8f96c5ea..1732b5f38 100644 --- a/packages/demobank-ui/README.md +++ b/packages/demobank-ui/README.md @@ -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. 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); ``` @@ -35,6 +36,7 @@ to the default settings: ``` globalThis.talerDemobankSettings = { + backendBaseURL: "https://bank.demo.taler.net/demobanks/default/", allowRegistrations: true, bankName: "Taler Bank", // Show explainer text and navbar to other demo sites diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts index 3f2981edf..3eaf1f186 100644 --- a/packages/demobank-ui/src/hooks/backend.ts +++ b/packages/demobank-ui/src/hooks/backend.ts @@ -42,26 +42,23 @@ export interface BackendCredentials { } interface LoggedIn extends BackendCredentials { - url: string; status: "loggedIn"; isUserAdministrator: boolean; } interface LoggedOut { - url: string; status: "loggedOut"; } -const maybeRootPath = bankUiSettings.backendBaseURL; - export function getInitialBackendBaseURL(): string { const overrideUrl = localStorage.getItem("bank-base-url"); - return canonicalizeBaseUrl(overrideUrl ? overrideUrl : maybeRootPath); + return canonicalizeBaseUrl( + overrideUrl ? overrideUrl : bankUiSettings.backendBaseURL, + ); } export const defaultState: BackendState = { status: "loggedOut", - url: getInitialBackendBaseURL(), }; export interface BackendStateHandler { @@ -91,13 +88,12 @@ export function useBackendState(): BackendStateHandler { return { state, logOut() { - update(JSON.stringify({ ...defaultState, url: state.url })); + update(JSON.stringify({ ...defaultState })); }, logIn(info) { //admin is defined by the username const nextState: BackendState = { status: "loggedIn", - url: state.url, ...info, isUserAdministrator: info.username === "admin", }; @@ -125,7 +121,7 @@ export function usePublicBackend(): useBackendType { const { state } = useBackendContext(); const { request: requestHandler } = useApiContext(); - const baseUrl = state.url; + const baseUrl = getInitialBackendBaseURL(); const request = useCallback( function requestImpl<T>( @@ -201,7 +197,7 @@ export function useAuthenticatedBackend(): useBackendType { const { request: requestHandler } = useApiContext(); const creds = state.status === "loggedIn" ? state : undefined; - const baseUrl = state.url; + const baseUrl = getInitialBackendBaseURL(); const request = useCallback( function requestImpl<T>( diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index c2563adb4..423ed1a5b 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -24,7 +24,11 @@ import { import { useEffect, useMemo, useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.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 import _useSWR, { SWRHook } from "swr"; @@ -210,10 +214,10 @@ export interface CircuitAccountAPI { async function getBusinessStatus( request: ReturnType<typeof useApiContext>["request"], - url: string, basicAuth: { username: string; password: string }, ): Promise<boolean> { try { + const url = getInitialBackendBaseURL(); const result = await request< HttpResponseOk<SandboxBackend.Circuit.CircuitAccountData> >(url, `circuit-api/accounts/${basicAuth.username}`, { basicAuth }); @@ -234,7 +238,7 @@ export function useBusinessAccountFlag(): boolean | undefined { useEffect(() => { if (!creds) return; - getBusinessStatus(request, state.url, creds) + getBusinessStatus(request, creds) .then((result) => { setIsBusiness(result); }) diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx index 2a5701a95..3d0c09cbf 100644 --- a/packages/demobank-ui/src/pages/AdminPage.tsx +++ b/packages/demobank-ui/src/pages/AdminPage.tsx @@ -40,6 +40,7 @@ import { PartialButDefined, RecursivePartial, undefinedIfEmpty, + validateIBAN, WithIntermediate, } from "../utils.js"; import { ErrorBannerFloat } from "./BankFrame.js"; @@ -230,74 +231,78 @@ export function AdminPage({ onLoadNotOk }: Props): VNode { </p> <section id="main"> - <article> - <h2>{i18n.str`Accounts:`}</h2> - <div class="results"> - <table class="pure-table pure-table-striped"> - <thead> - <tr> - <th>{i18n.str`Username`}</th> - <th>{i18n.str`Name`}</th> - <th></th> - <th></th> - </tr> - </thead> - <tbody> - {customers.map((item, idx) => { - return ( - <tr key={idx}> - <td> - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setShowDetails(item.username); - }} - > - {item.username} - </a> - </td> - <td>{item.name}</td> - <td> - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setUpdatePassword(item.username); - }} - > - change password - </a> - </td> - <td> - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setShowCashouts(item.username); - }} - > - cashouts - </a> - </td> - <td> - <a - href="#" - onClick={(e) => { - e.preventDefault(); - setRemoveAccount(item.username); - }} - > - remove - </a> - </td> - </tr> - ); - })} - </tbody> - </table> - </div> - </article> + {!customers.length ? ( + <div></div> + ) : ( + <article> + <h2>{i18n.str`Accounts:`}</h2> + <div class="results"> + <table class="pure-table pure-table-striped"> + <thead> + <tr> + <th>{i18n.str`Username`}</th> + <th>{i18n.str`Name`}</th> + <th></th> + <th></th> + </tr> + </thead> + <tbody> + {customers.map((item, idx) => { + return ( + <tr key={idx}> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowDetails(item.username); + }} + > + {item.username} + </a> + </td> + <td>{item.name}</td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setUpdatePassword(item.username); + }} + > + change password + </a> + </td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setShowCashouts(item.username); + }} + > + cashouts + </a> + </td> + <td> + <a + href="#" + onClick={(e) => { + e.preventDefault(); + setRemoveAccount(item.username); + }} + > + remove + </a> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + </article> + )} </section> </Fragment> ); @@ -835,15 +840,15 @@ function AccountForm({ ? i18n.str`only "IBAN" target are supported` : !IBAN_REGEX.test(parsed.iban) ? i18n.str`IBAN should have just uppercased letters and numbers` - : undefined, + : validateIBAN(parsed.iban, i18n), contact_data: undefinedIfEmpty({ email: !newForm.contact_data?.email - ? undefined + ? i18n.str`required` : !EMAIL_REGEX.test(newForm.contact_data.email) ? i18n.str`it should be an email` : undefined, phone: !newForm.contact_data?.phone - ? undefined + ? i18n.str`required` : !newForm.contact_data.phone.startsWith("+") ? i18n.str`should start with +` : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone) @@ -851,10 +856,10 @@ function AccountForm({ : undefined, }), iban: !newForm.iban - ? i18n.str`required` + ? undefined //optional field : !IBAN_REGEX.test(newForm.iban) ? i18n.str`IBAN should have just uppercased letters and numbers` - : undefined, + : validateIBAN(newForm.iban, i18n), name: !newForm.name ? i18n.str`required` : undefined, username: !newForm.username ? i18n.str`required` : undefined, }); @@ -866,7 +871,10 @@ function AccountForm({ return ( <form class="pure-form"> <fieldset> - <label for="username">{i18n.str`Username`}</label> + <label for="username"> + {i18n.str`Username`} + {purpose === "create" && <b style={{ color: "red" }}>*</b>} + </label> <input name="username" type="text" @@ -876,14 +884,17 @@ function AccountForm({ form.username = e.currentTarget.value; updateForm(structuredClone(form)); }} - /> + />{" "} <ShowInputErrorLabel message={errors?.username} isDirty={form.username !== undefined} /> </fieldset> <fieldset> - <label>{i18n.str`Name`}</label> + <label> + {i18n.str`Name`} + {purpose === "create" && <b style={{ color: "red" }}>*</b>} + </label> <input disabled={purpose !== "create"} value={form.name ?? ""} @@ -897,23 +908,28 @@ function AccountForm({ isDirty={form.name !== undefined} /> </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> - <label>{i18n.str`Internal IBAN`}</label> - <input - disabled={purpose !== "create"} - value={form.iban ?? ""} - 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> + <label> + {i18n.str`Email`} + {purpose !== "show" && <b style={{ color: "red" }}>*</b>} + </label> <input disabled={purpose === "show"} value={form.contact_data.email ?? ""} @@ -928,7 +944,10 @@ function AccountForm({ /> </fieldset> <fieldset> - <label>{i18n.str`Phone`}</label> + <label> + {i18n.str`Phone`} + {purpose !== "show" && <b style={{ color: "red" }}>*</b>} + </label> <input disabled={purpose === "show"} value={form.contact_data.phone ?? ""} @@ -943,12 +962,15 @@ function AccountForm({ /> </fieldset> <fieldset> - <label>{i18n.str`Cashout address`}</label> + <label> + {i18n.str`Cashout address`} + {purpose !== "show" && <b style={{ color: "red" }}>*</b>} + </label> <input disabled={purpose === "show"} - value={form.cashout_address ?? ""} + value={(form.cashout_address ?? "").substring("payto://iban/".length)} onChange={(e) => { - form.cashout_address = e.currentTarget.value; + form.cashout_address = "payto://iban/" + e.currentTarget.value; updateForm(structuredClone(form)); }} /> diff --git a/packages/demobank-ui/src/utils.ts b/packages/demobank-ui/src/utils.ts index 81dd450a4..0796db65d 100644 --- a/packages/demobank-ui/src/utils.ts +++ b/packages/demobank-ui/src/utils.ts @@ -161,3 +161,208 @@ export function buildRequestErrorMessage( } 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; +} |