wallet-core/packages/demobank-ui/src/pages/AdminPage.tsx

897 lines
26 KiB
TypeScript
Raw Normal View History

2023-02-08 21:41:19 +01:00
/*
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/>
*/
2023-02-17 20:23:37 +01:00
import {
Amounts,
parsePaytoUri,
TranslatedString,
} from "@gnu-taler/taler-util";
2023-02-08 21:41:19 +01:00
import {
HttpResponsePaginated,
RequestError,
useTranslationContext,
} from "@gnu-taler/web-util/lib/index.browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
2023-02-17 20:23:37 +01:00
import { Cashouts } from "../components/Cashouts/index.js";
2023-02-08 21:41:19 +01:00
import { ErrorMessage, usePageContext } from "../context/pageState.js";
2023-02-17 20:23:37 +01:00
import { useAccountDetails } from "../hooks/access.js";
2023-02-08 21:41:19 +01:00
import {
2023-02-10 13:51:37 +01:00
useBusinessAccountDetails,
useBusinessAccounts,
2023-02-08 21:41:19 +01:00
useAdminAccountAPI,
} from "../hooks/circuit.js";
import {
PartialButDefined,
undefinedIfEmpty,
WithIntermediate,
} from "../utils.js";
import { ErrorBanner } from "./BankFrame.js";
2023-02-20 14:18:02 +01:00
import { ShowCashoutDetails } from "./BusinessAccount.js";
2023-02-08 21:41:19 +01:00
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>();
2023-02-17 20:23:37 +01:00
const [showCashouts, setShowCashouts] = useState<string | undefined>();
2023-02-08 21:41:19 +01:00
const [updatePassword, setUpdatePassword] = useState<string | undefined>();
2023-02-17 20:23:37 +01:00
const [removeAccount, setRemoveAccount] = useState<string | undefined>();
2023-02-20 14:18:02 +01:00
const [showCashoutDetails, setShowCashoutDetails] = useState<
string | undefined
>();
2023-02-17 20:23:37 +01:00
2023-02-08 21:41:19 +01:00
const [createAccount, setCreateAccount] = useState(false);
const { pageStateSetter } = usePageContext();
function showInfoMessage(info: TranslatedString): void {
pageStateSetter((prev) => ({
...prev,
info,
}));
}
2023-02-10 13:51:37 +01:00
const result = useBusinessAccounts({ account });
2023-02-08 21:41:19 +01:00
const { i18n } = useTranslationContext();
if (result.loading) return <div />;
if (!result.ok) {
return onLoadNotOk(result);
}
const { customers } = result.data;
2023-02-20 14:18:02 +01:00
if (showCashoutDetails) {
return (
<ShowCashoutDetails
id={showCashoutDetails}
onLoadNotOk={onLoadNotOk}
onCancel={() => {
setShowCashoutDetails(undefined);
}}
/>
);
}
2023-02-17 20:23:37 +01:00
if (showCashouts) {
return (
<div>
2023-02-20 14:18:02 +01:00
<Cashouts
account={showCashouts}
onSelected={(id) => {
setShowCashouts(id);
setShowCashouts(undefined);
}}
/>
2023-02-17 20:23:37 +01:00
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
setShowCashouts(undefined);
}}
/>
</div>
);
}
2023-02-08 21:41:19 +01:00
if (showDetails) {
return (
<ShowAccountDetails
account={showDetails}
onLoadNotOk={onLoadNotOk}
2023-02-10 13:51:37 +01:00
onChangePassword={() => {
setUpdatePassword(showDetails);
setShowDetails(undefined);
}}
2023-02-08 21:41:19 +01:00
onUpdateSuccess={() => {
showInfoMessage(i18n.str`Account updated`);
setShowDetails(undefined);
}}
onClear={() => {
setShowDetails(undefined);
}}
/>
);
}
2023-02-17 20:23:37 +01:00
if (removeAccount) {
return (
<RemoveAccount
account={removeAccount}
onLoadNotOk={onLoadNotOk}
onUpdateSuccess={() => {
showInfoMessage(i18n.str`Account removed`);
setRemoveAccount(undefined);
}}
onClear={() => {
setRemoveAccount(undefined);
}}
/>
);
}
2023-02-08 21:41:19 +01:00
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>
2023-02-17 20:23:37 +01:00
<th></th>
2023-02-08 21:41:19 +01:00
</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>
2023-02-17 20:23:37 +01:00
<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>
2023-02-08 21:41:19 +01:00
</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;
}
2023-02-10 13:51:37 +01:00
export function UpdateAccountPassword({
2023-02-08 21:41:19 +01:00
account,
onClear,
onUpdateSuccess,
onLoadNotOk,
}: {
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
onClear: () => void;
onUpdateSuccess: () => void;
account: string;
}): VNode {
const { i18n } = useTranslationContext();
2023-02-10 13:51:37 +01:00
const result = useBusinessAccountDetails(account);
2023-02-08 21:41:19 +01:00
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">
2023-02-10 13:51:37 +01:00
<i18n.Translate>Update password for {account}</i18n.Translate>
2023-02-08 21:41:19 +01:00
</h1>
</div>
{error && (
<ErrorBanner error={error} onClear={() => saveError(undefined)} />
)}
<form class="pure-form">
<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">
2023-02-10 13:51:37 +01:00
<i18n.Translate>New account</i18n.Translate>
2023-02-08 21:41:19 +01:00
</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>
);
}
2023-02-10 13:51:37 +01:00
export function ShowAccountDetails({
2023-02-08 21:41:19 +01:00
account,
onClear,
onUpdateSuccess,
onLoadNotOk,
2023-02-10 13:51:37 +01:00
onChangePassword,
2023-02-08 21:41:19 +01:00
}: {
onLoadNotOk: <T, E>(error: HttpResponsePaginated<T, E>) => VNode;
2023-02-10 13:51:37 +01:00
onClear?: () => void;
onChangePassword: () => void;
2023-02-08 21:41:19 +01:00
onUpdateSuccess: () => void;
account: string;
}): VNode {
const { i18n } = useTranslationContext();
2023-02-10 13:51:37 +01:00
const result = useBusinessAccountDetails(account);
2023-02-08 21:41:19 +01:00
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">
2023-02-10 13:51:37 +01:00
<i18n.Translate>Business account details</i18n.Translate>
2023-02-08 21:41:19 +01:00
</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>
2023-02-10 13:51:37 +01:00
{onClear ? (
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
onClear();
}}
/>
) : undefined}
2023-02-08 21:41:19 +01:00
</div>
2023-02-10 13:51:37 +01:00
<div style={{ display: "flex" }}>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={update && !submitAccount}
type="submit"
value={i18n.str`Change password`}
onClick={async (e) => {
e.preventDefault();
onChangePassword();
}}
/>
</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);
}
2023-02-08 21:41:19 +01:00
}
2023-02-10 13:51:37 +01:00
}}
/>
</div>
2023-02-08 21:41:19 +01:00
</div>
</div>
</p>
</div>
);
}
2023-02-17 20:23:37 +01:00
function RemoveAccount({
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 { deleteAccount } = useAdminAccountAPI();
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 balance = Amounts.parse(result.data.balance.amount);
if (!balance) {
return <div>there was an error reading the balance</div>;
}
const isBalanceEmpty = Amounts.isZero(balance);
return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Remove account: {account}</i18n.Translate>
</h1>
</div>
{!isBalanceEmpty && (
<ErrorBanner
error={{
title: i18n.str`Can't delete the account`,
description: i18n.str`Balance is not empty`,
}}
/>
)}
{error && (
<ErrorBanner error={error} onClear={() => saveError(undefined)} />
)}
<p>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<div>
<input
class="pure-button"
type="submit"
value={i18n.str`Cancel`}
onClick={async (e) => {
e.preventDefault();
onClear();
}}
/>
</div>
<div>
<input
id="select-exchange"
class="pure-button pure-button-primary content"
disabled={!isBalanceEmpty}
type="submit"
value={i18n.str`Confirm`}
onClick={async (e) => {
e.preventDefault();
try {
const r = await deleteAccount(account);
onUpdateSuccess();
} catch (error) {
handleError(error, saveError, i18n);
}
}}
/>
</div>
</div>
</p>
</div>
);
}
2023-02-08 21:41:19 +01:00
/**
* 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),
});
}
}