diff options
author | Sebastian <sebasjm@gmail.com> | 2023-02-08 17:41:19 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-02-08 17:41:19 -0300 |
commit | a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d (patch) | |
tree | fc24dbf06b548925dbc065a49060473fdd220c94 /packages/demobank-ui/src/pages/AdminPage.tsx | |
parent | 9b0d887a1bc292f652352c1dba4ed4243a88bbbe (diff) | |
download | wallet-core-a8c5a9696c1735a178158cbc9ac4f9bb4b6f013d.tar.xz |
impl accout management and refactor
Diffstat (limited to 'packages/demobank-ui/src/pages/AdminPage.tsx')
-rw-r--r-- | packages/demobank-ui/src/pages/AdminPage.tsx | 707 |
1 files changed, 707 insertions, 0 deletions
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 <http://www.gnu.org/licenses/> + */ + +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: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; +} +/** + * Query account information and show QR code if there is pending withdrawal + */ +export function AdminPage({ onLoadNotOk }: Props): VNode { + const [account, setAccount] = useState<string | undefined>(); + const [showDetails, setShowDetails] = useState<string | undefined>(); + const [updatePassword, setUpdatePassword] = useState<string | undefined>(); + 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 <div />; + if (!result.ok) { + return onLoadNotOk(result); + } + + const { customers } = result.data; + + if (showDetails) { + return ( + <ShowAccountDetails + account={showDetails} + onLoadNotOk={onLoadNotOk} + onUpdateSuccess={() => { + showInfoMessage(i18n.str`Account updated`); + setShowDetails(undefined); + }} + onClear={() => { + setShowDetails(undefined); + }} + /> + ); + } + if (updatePassword) { + return ( + <UpdateAccountPassword + account={updatePassword} + onLoadNotOk={onLoadNotOk} + onUpdateSuccess={() => { + showInfoMessage(i18n.str`Password changed`); + setUpdatePassword(undefined); + }} + onClear={() => { + setUpdatePassword(undefined); + }} + /> + ); + } + if (createAccount) { + return ( + <CreateNewAccount + onClose={() => setCreateAccount(false)} + onCreateSuccess={(password) => { + showInfoMessage( + i18n.str`Account created with password "${password}"`, + ); + setCreateAccount(false); + }} + /> + ); + } + return ( + <Fragment> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div></div> + <div> + <input + class="pure-button pure-button-primary content" + type="submit" + value={i18n.str`Create account`} + onClick={async (e) => { + e.preventDefault(); + + setCreateAccount(true); + }} + /> + </div> + </div> + </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> + </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> + </tr> + ); + })} + </tbody> + </table> + </div> + </article> + </section> + </Fragment> + ); +} + +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<SandboxBackend.Circuit.CircuitAccountData> { + const emptyAccount = { + cashout_address: undefined, + iban: undefined, + name: undefined, + username: undefined, + contact_data: undefined, + }; + const emptyContact = { + email: undefined, + phone: undefined, + }; + + const initial: PartialButDefined<SandboxBackend.Circuit.CircuitAccountData> = + 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: <T, E>(error: HttpResponsePaginated<T, E>) => VNode; + onClear: () => void; + onUpdateSuccess: () => void; + account: string; +}): VNode { + const { i18n } = useTranslationContext(); + const result = useAccountDetails(account); + const { changePassword } = useAdminAccountAPI(); + const [password, setPassword] = useState<string | undefined>(); + const [repeat, setRepeat] = useState<string | undefined>(); + const [error, saveError] = useState<ErrorMessage | undefined>(); + + if (result.clientError) { + if (result.isNotfound) return <div>account not found</div>; + } + 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 ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + + <form class="pure-form"> + <fieldset> + <label for="username">{i18n.str`Username`}</label> + <input name="username" type="text" readOnly value={account} /> + </fieldset> + <fieldset> + <label>{i18n.str`Password`}</label> + <input + type="password" + value={password ?? ""} + onChange={(e) => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Repeast password`}</label> + <input + type="password" + value={repeat ?? ""} + onChange={(e) => { + setRepeat(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.repeat} + isDirty={repeat !== undefined} + /> + </fieldset> + </form> + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClear(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={!!errors} + type="submit" + value={i18n.str`Confirm`} + onClick={async (e) => { + e.preventDefault(); + if (!!errors || !password) return; + try { + const r = await changePassword(account, { + new_password: password, + }); + onUpdateSuccess(); + } catch (error) { + handleError(error, saveError, i18n); + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + +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<ErrorMessage | undefined>(); + return ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + + <AccountForm + template={undefined} + purpose="create" + onChange={(a) => setSubmitAccount(a)} + /> + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClose(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={!submitAccount} + type="submit" + value={i18n.str`Confirm`} + onClick={async (e) => { + 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); + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + +function ShowAccountDetails({ + account, + onClear, + onUpdateSuccess, + onLoadNotOk, +}: { + onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => 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<ErrorMessage | undefined>(); + + if (result.clientError) { + if (result.isNotfound) return <div>account not found</div>; + } + if (!result.ok) { + return onLoadNotOk(result); + } + + return ( + <div> + <div> + <h1 class="nav welcome-text"> + <i18n.Translate>Admin panel</i18n.Translate> + </h1> + </div> + {error && ( + <ErrorBanner error={error} onClear={() => saveError(undefined)} /> + )} + <AccountForm + template={result.data} + purpose={update ? "update" : "show"} + onChange={(a) => setSubmitAccount(a)} + /> + + <p> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <div> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClear(); + }} + /> + </div> + <div> + <input + id="select-exchange" + class="pure-button pure-button-primary content" + disabled={update && !submitAccount} + type="submit" + value={update ? i18n.str`Confirm` : i18n.str`Update`} + onClick={async (e) => { + 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); + } + } + }} + /> + </div> + </div> + </p> + </div> + ); +} + +/** + * 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<typeof initial | undefined>(undefined); + const { i18n } = useTranslationContext(); + + function updateForm(newForm: typeof initial): void { + const parsed = !newForm.cashout_address + ? undefined + : parsePaytoUri(newForm.cashout_address); + + const validationResult = undefinedIfEmpty<typeof initial>({ + 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 class="pure-form"> + <fieldset> + <label for="username">{i18n.str`Username`}</label> + <input + name="username" + type="text" + disabled={purpose !== "create"} + value={form.username} + onChange={(e) => { + form.username = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={form.username !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Name`}</label> + <input + disabled={purpose !== "create"} + value={form.name ?? ""} + onChange={(e) => { + form.name = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.name} + isDirty={form.name !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`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> + <input + disabled={purpose === "show"} + value={form.contact_data.email ?? ""} + onChange={(e) => { + form.contact_data.email = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.contact_data.email} + isDirty={form.contact_data.email !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Phone`}</label> + <input + disabled={purpose === "show"} + value={form.contact_data.phone ?? ""} + onChange={(e) => { + form.contact_data.phone = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.contact_data.phone} + isDirty={form.contact_data?.phone !== undefined} + /> + </fieldset> + <fieldset> + <label>{i18n.str`Cashout address`}</label> + <input + disabled={purpose === "show"} + value={form.cashout_address ?? ""} + onChange={(e) => { + form.cashout_address = e.currentTarget.value; + updateForm(structuredClone(form)); + }} + /> + <ShowInputErrorLabel + message={errors?.cashout_address} + isDirty={form.cashout_address !== undefined} + /> + </fieldset> + </form> + ); +} + +function handleError( + error: unknown, + saveError: (e: ErrorMessage) => void, + i18n: ReturnType<typeof useTranslationContext>["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), + }); + } +} |