/* 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 { Amounts, HttpStatusCode, 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 { Cashouts } from "../components/Cashouts/index.js"; import { ErrorMessage, usePageContext } from "../context/pageState.js"; import { useAccountDetails } from "../hooks/access.js"; import { useBusinessAccountDetails, useBusinessAccounts, useAdminAccountAPI, } from "../hooks/circuit.js"; import { buildRequestErrorMessage, PartialButDefined, RecursivePartial, undefinedIfEmpty, validateIBAN, WithIntermediate, } from "../utils.js"; import { ErrorBannerFloat } from "./BankFrame.js"; import { ShowCashoutDetails } from "./BusinessAccount.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 [showCashouts, setShowCashouts] = useState(); const [updatePassword, setUpdatePassword] = useState(); const [removeAccount, setRemoveAccount] = useState(); const [showCashoutDetails, setShowCashoutDetails] = useState< string | undefined >(); const [createAccount, setCreateAccount] = useState(false); const { pageStateSetter } = usePageContext(); function showInfoMessage(info: TranslatedString): void { pageStateSetter((prev) => ({ ...prev, info, })); } const result = useBusinessAccounts({ account }); const { i18n } = useTranslationContext(); if (result.loading) return
; if (!result.ok) { return onLoadNotOk(result); } const { customers } = result.data; if (showCashoutDetails) { return ( { setShowCashoutDetails(undefined); }} /> ); } if (showCashouts) { return (

Cashout for account {showCashouts}

{ setShowCashouts(id); setShowCashouts(undefined); }} />

{ e.preventDefault(); setShowCashouts(undefined); }} />

); } if (showDetails) { return ( { setUpdatePassword(showDetails); setShowDetails(undefined); }} onUpdateSuccess={() => { showInfoMessage(i18n.str`Account updated`); setShowDetails(undefined); }} onClear={() => { setShowDetails(undefined); }} /> ); } if (removeAccount) { return ( { showInfoMessage(i18n.str`Account removed`); setRemoveAccount(undefined); }} onClear={() => { setRemoveAccount(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}". The user must change the password on the next login.`, ); setCreateAccount(false); }} /> ); } return (

Admin panel

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

{!customers.length ? (
) : (

{i18n.str`Accounts:`}

{customers.map((item, idx) => { const balance = !item.balance ? undefined : Amounts.parse(item.balance.amount); const balanceIsDebit = item.balance && item.balance.credit_debit_indicator == "debit"; return ( ); })}
{i18n.str`Username`} {i18n.str`Name`} {i18n.str`Balance`} {i18n.str`Actions`}
{ e.preventDefault(); setShowDetails(item.username); }} > {item.username} {item.name} {!balance ? ( i18n.str`unknown` ) : ( {balanceIsDebit ? - : null} {`${Amounts.stringifyValue( balance, )}`}   {`${balance.currency}`} )} { e.preventDefault(); setUpdatePassword(item.username); }} > change password   { e.preventDefault(); setShowCashouts(item.username); }} > cashouts   { e.preventDefault(); setRemoveAccount(item.username); }} > remove
)}
); } 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; } export function UpdateAccountPassword({ account, onClear, onUpdateSuccess, onLoadNotOk, }: { onLoadNotOk: ( error: HttpResponsePaginated, ) => VNode; onClear: () => void; onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); const result = useBusinessAccountDetails(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 (

Update password for {account}

{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) { if (error instanceof RequestError) { saveError(buildRequestErrorMessage(i18n, error.cause)); } else { saveError({ title: i18n.str`Operation failed, please report`, description: error instanceof Error ? error.message : JSON.stringify(error), }); } } }} />

); } 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 (

New account

{error && ( saveError(undefined)} /> )}
{ console.log(a); 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) { if (error instanceof RequestError) { saveError( buildRequestErrorMessage(i18n, error.cause, { onClientError: (status) => status === HttpStatusCode.Forbidden ? i18n.str`The rights to perform the operation are not sufficient` : status === HttpStatusCode.BadRequest ? i18n.str`Input data was invalid` : status === HttpStatusCode.Conflict ? i18n.str`At least one registration detail was not available` : undefined, }), ); } else { saveError({ title: i18n.str`Operation failed, please report`, description: error instanceof Error ? error.message : JSON.stringify(error), }); } } }} />

); } export function ShowAccountDetails({ account, onClear, onUpdateSuccess, onLoadNotOk, onChangePassword, }: { onLoadNotOk: ( error: HttpResponsePaginated, ) => VNode; onClear?: () => void; onChangePassword: () => void; onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); const result = useBusinessAccountDetails(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 (

Business account details

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

{onClear ? ( { e.preventDefault(); onClear(); }} /> ) : undefined}
{ e.preventDefault(); onChangePassword(); }} />
{ 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) { if (error instanceof RequestError) { saveError( buildRequestErrorMessage(i18n, error.cause, { onClientError: (status) => status === HttpStatusCode.Forbidden ? i18n.str`The rights to change the account are not sufficient` : status === HttpStatusCode.NotFound ? i18n.str`The username was not found` : undefined, }), ); } else { saveError({ title: i18n.str`Operation failed, please report`, description: error instanceof Error ? error.message : JSON.stringify(error), }); } } } }} />

); } function RemoveAccount({ account, onClear, onUpdateSuccess, onLoadNotOk, }: { onLoadNotOk: ( error: HttpResponsePaginated, ) => VNode; onClear: () => void; onUpdateSuccess: () => void; account: string; }): VNode { const { i18n } = useTranslationContext(); const result = useAccountDetails(account); const { deleteAccount } = useAdminAccountAPI(); const [error, saveError] = useState(); if (result.clientError) { if (result.isNotfound) return
account not found
; } if (!result.ok) { return onLoadNotOk(result); } const balance = Amounts.parse(result.data.balance.amount); if (!balance) { return
there was an error reading the balance
; } const isBalanceEmpty = Amounts.isZero(balance); return (

Remove account: {account}

{!isBalanceEmpty && ( )} {error && ( saveError(undefined)} /> )}

{ e.preventDefault(); onClear(); }} />
{ e.preventDefault(); try { const r = await deleteAccount(account); onUpdateSuccess(); } catch (error) { if (error instanceof RequestError) { saveError( buildRequestErrorMessage(i18n, error.cause, { onClientError: (status) => status === HttpStatusCode.Forbidden ? i18n.str`The administrator specified a institutional username` : status === HttpStatusCode.NotFound ? i18n.str`The username was not found` : status === HttpStatusCode.PreconditionFailed ? i18n.str`Balance was not zero` : undefined, }), ); } else { saveError({ title: i18n.str`Operation failed, please report`, description: error instanceof Error ? error.message : JSON.stringify(error), }); } } }} />

); } /** * 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< RecursivePartial | undefined >(undefined); const { i18n } = useTranslationContext(); function updateForm(newForm: typeof initial): void { const parsed = !newForm.cashout_address ? undefined : parsePaytoUri(newForm.cashout_address); const errors = 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` : validateIBAN(parsed.iban, i18n), contact_data: undefinedIfEmpty({ email: !newForm.contact_data?.email ? i18n.str`required` : !EMAIL_REGEX.test(newForm.contact_data.email) ? i18n.str`it should be an email` : undefined, phone: !newForm.contact_data?.phone ? i18n.str`required` : !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 ? undefined //optional field : !IBAN_REGEX.test(newForm.iban) ? i18n.str`IBAN should have just uppercased letters and numbers` : validateIBAN(newForm.iban, i18n), name: !newForm.name ? i18n.str`required` : undefined, username: !newForm.username ? i18n.str`required` : undefined, }); setErrors(errors); setForm(newForm); onChange(errors === undefined ? (newForm as any) : undefined); } return (
{ form.username = e.currentTarget.value; updateForm(structuredClone(form)); }} />{" "}
{ form.name = e.currentTarget.value; updateForm(structuredClone(form)); }} />
{purpose !== "create" && (
{ 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 = "payto://iban/" + e.currentTarget.value; updateForm(structuredClone(form)); }} />
); }