From a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 8 Feb 2023 17:41:19 -0300 Subject: impl accout management and refactor --- packages/demobank-ui/src/pages/AdminPage.tsx | 707 +++++++++++++++++++++++++++ 1 file changed, 707 insertions(+) create mode 100644 packages/demobank-ui/src/pages/AdminPage.tsx (limited to 'packages/demobank-ui/src/pages/AdminPage.tsx') diff --git a/packages/demobank-ui/src/pages/AdminPage.tsx b/packages/demobank-ui/src/pages/AdminPage.tsx new file mode 100644 index 000000000..9efd37f12 --- /dev/null +++ b/packages/demobank-ui/src/pages/AdminPage.tsx @@ -0,0 +1,707 @@ +/* + 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 + */ + +import { parsePaytoUri, TranslatedString } from "@gnu-taler/taler-util"; +import { + HttpResponsePaginated, + RequestError, + useTranslationContext, +} from "@gnu-taler/web-util/lib/index.browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorMessage, usePageContext } from "../context/pageState.js"; +import { + useAccountDetails, + useAccounts, + useAdminAccountAPI, +} from "../hooks/circuit.js"; +import { + PartialButDefined, + undefinedIfEmpty, + WithIntermediate, +} from "../utils.js"; +import { ErrorBanner } from "./BankFrame.js"; +import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js"; + +const charset = + "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const upperIdx = charset.indexOf("A"); + +function randomPassword(): string { + const random = Array.from({ length: 16 }).map(() => { + return charset.charCodeAt(Math.random() * charset.length); + }); + // first char can't be upper + const charIdx = charset.indexOf(String.fromCharCode(random[0])); + random[0] = + charIdx > upperIdx ? charset.charCodeAt(charIdx - upperIdx) : random[0]; + return String.fromCharCode(...random); +} + +interface Props { + onLoadNotOk: (error: HttpResponsePaginated) => VNode; +} +/** + * Query account information and show QR code if there is pending withdrawal + */ +export function AdminPage({ onLoadNotOk }: Props): VNode { + const [account, setAccount] = useState(); + const [showDetails, setShowDetails] = useState(); + const [updatePassword, setUpdatePassword] = useState(); + const [createAccount, setCreateAccount] = useState(false); + const { pageStateSetter } = usePageContext(); + + function showInfoMessage(info: TranslatedString): void { + pageStateSetter((prev) => ({ + ...prev, + info, + })); + } + + const result = useAccounts({ account }); + const { i18n } = useTranslationContext(); + + if (result.loading) return
; + if (!result.ok) { + return onLoadNotOk(result); + } + + const { customers } = result.data; + + if (showDetails) { + return ( + { + showInfoMessage(i18n.str`Account updated`); + setShowDetails(undefined); + }} + onClear={() => { + setShowDetails(undefined); + }} + /> + ); + } + if (updatePassword) { + return ( + { + showInfoMessage(i18n.str`Password changed`); + setUpdatePassword(undefined); + }} + onClear={() => { + setUpdatePassword(undefined); + }} + /> + ); + } + if (createAccount) { + return ( + setCreateAccount(false)} + onCreateSuccess={(password) => { + showInfoMessage( + i18n.str`Account created with password "${password}"`, + ); + setCreateAccount(false); + }} + /> + ); + } + return ( + +
+

+ Admin panel +

+
+ +

+

+
+
+ { + e.preventDefault(); + + setCreateAccount(true); + }} + /> +
+
+

+ +
+ +
+
+ ); +} + +const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; +const EMAIL_REGEX = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; +const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; + +function initializeFromTemplate( + account: SandboxBackend.Circuit.CircuitAccountData | undefined, +): WithIntermediate { + const emptyAccount = { + cashout_address: undefined, + iban: undefined, + name: undefined, + username: undefined, + contact_data: undefined, + }; + const emptyContact = { + email: undefined, + phone: undefined, + }; + + const initial: PartialButDefined = + structuredClone(account) ?? emptyAccount; + if (typeof initial.contact_data === "undefined") { + initial.contact_data = emptyContact; + } + initial.contact_data.email; + return initial as any; +} + +function UpdateAccountPassword({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: (error: HttpResponsePaginated) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { changePassword } = useAdminAccountAPI(); + const [password, setPassword] = useState(); + const [repeat, setRepeat] = useState(); + const [error, saveError] = useState(); + + if (result.clientError) { + if (result.isNotfound) return
account not found
; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + const errors = undefinedIfEmpty({ + password: !password ? i18n.str`required` : undefined, + repeat: !repeat + ? i18n.str`required` + : password !== repeat + ? i18n.str`password doesn't match` + : undefined, + }); + + return ( +
+
+

+ Admin panel +

+
+ {error && ( + saveError(undefined)} /> + )} + +
+
+ + +
+
+ + { + setPassword(e.currentTarget.value); + }} + /> + +
+
+ + { + setRepeat(e.currentTarget.value); + }} + /> + +
+
+

+

+
+ { + e.preventDefault(); + onClear(); + }} + /> +
+
+ { + e.preventDefault(); + if (!!errors || !password) return; + try { + const r = await changePassword(account, { + new_password: password, + }); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> +
+
+

+
+ ); +} + +function CreateNewAccount({ + onClose, + onCreateSuccess, +}: { + onClose: () => void; + onCreateSuccess: (password: string) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { createAccount } = useAdminAccountAPI(); + const [submitAccount, setSubmitAccount] = useState< + SandboxBackend.Circuit.CircuitAccountData | undefined + >(); + const [error, saveError] = useState(); + return ( +
+
+

+ Admin panel +

+
+ {error && ( + saveError(undefined)} /> + )} + + setSubmitAccount(a)} + /> + +

+

+
+ { + e.preventDefault(); + onClose(); + }} + /> +
+
+ { + e.preventDefault(); + + if (!submitAccount) return; + try { + const account: SandboxBackend.Circuit.CircuitAccountRequest = + { + cashout_address: submitAccount.cashout_address, + contact_data: submitAccount.contact_data, + internal_iban: submitAccount.iban, + name: submitAccount.name, + username: submitAccount.username, + password: randomPassword(), + }; + + await createAccount(account); + onCreateSuccess(account.password); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> +
+
+

+
+ ); +} + +function ShowAccountDetails({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: (error: HttpResponsePaginated) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { updateAccount } = useAdminAccountAPI(); + const [update, setUpdate] = useState(false); + const [submitAccount, setSubmitAccount] = useState< + SandboxBackend.Circuit.CircuitAccountData | undefined + >(); + const [error, saveError] = useState(); + + if (result.clientError) { + if (result.isNotfound) return
account not found
; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + return ( +
+
+

+ Admin panel +

+
+ {error && ( + saveError(undefined)} /> + )} + setSubmitAccount(a)} + /> + +

+

+
+ { + e.preventDefault(); + onClear(); + }} + /> +
+
+ { + e.preventDefault(); + + if (!update) { + setUpdate(true); + } else { + if (!submitAccount) return; + try { + await updateAccount(account, { + cashout_address: submitAccount.cashout_address, + contact_data: submitAccount.contact_data, + }); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + } + }} + /> +
+
+

+
+ ); +} + +/** + * Create valid account object to update or create + * Take template as initial values for the form + * Purpose indicate if all field al read only (show), part of them (update) + * or none (create) + * @param param0 + * @returns + */ +function AccountForm({ + template, + purpose, + onChange, +}: { + template: SandboxBackend.Circuit.CircuitAccountData | undefined; + onChange: (a: SandboxBackend.Circuit.CircuitAccountData | undefined) => void; + purpose: "create" | "update" | "show"; +}): VNode { + const initial = initializeFromTemplate(template); + const [form, setForm] = useState(initial); + const [errors, setErrors] = useState(undefined); + const { i18n } = useTranslationContext(); + + function updateForm(newForm: typeof initial): void { + const parsed = !newForm.cashout_address + ? undefined + : parsePaytoUri(newForm.cashout_address); + + const validationResult = undefinedIfEmpty({ + cashout_address: !newForm.cashout_address + ? i18n.str`required` + : !parsed + ? i18n.str`does not follow the pattern` + : !parsed.isKnown || parsed.targetType !== "iban" + ? i18n.str`only "IBAN" target are supported` + : !IBAN_REGEX.test(parsed.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : undefined, + contact_data: { + email: !newForm.contact_data.email + ? undefined + : !EMAIL_REGEX.test(newForm.contact_data.email) + ? i18n.str`it should be an email` + : undefined, + phone: !newForm.contact_data.phone + ? undefined + : !newForm.contact_data.phone.startsWith("+") + ? i18n.str`should start with +` + : !REGEX_JUST_NUMBERS_REGEX.test(newForm.contact_data.phone) + ? i18n.str`phone number can't have other than numbers` + : undefined, + }, + iban: !newForm.iban + ? i18n.str`required` + : !IBAN_REGEX.test(newForm.iban) + ? i18n.str`IBAN should have just uppercased letters and numbers` + : undefined, + name: !newForm.name ? i18n.str`required` : undefined, + username: !newForm.username ? i18n.str`required` : undefined, + }); + + setErrors(validationResult); + setForm(newForm); + onChange(validationResult === undefined ? undefined : (newForm as any)); + } + + return ( +
+
+ + { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.iban = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.contact_data.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.contact_data.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ + { + form.cashout_address = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + +
+
+ ); +} + +function handleError( + error: unknown, + saveError: (e: ErrorMessage) => void, + i18n: ReturnType["i18n"], +): void { + if (error instanceof RequestError) { + const payload = error.info.error as SandboxBackend.SandboxError; + saveError({ + title: error.info.serverError + ? i18n.str`Server had an error` + : i18n.str`Server didn't accept the request`, + description: payload.error.description, + }); + } else if (error instanceof Error) { + saveError({ + title: i18n.str`Could not update account`, + description: error.message, + }); + } else { + saveError({ + title: i18n.str`Error, please report`, + debug: JSON.stringify(error), + }); + } +} -- cgit v1.2.3