admin refactor

This commit is contained in:
Sebastian 2023-09-21 10:31:10 -03:00
parent b3c747151b
commit 062939d9cc
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
14 changed files with 1083 additions and 1083 deletions

View File

@ -19,14 +19,14 @@ import { VNode, h } from "preact";
import { Route, Router, route } from "preact-router"; import { Route, Router, route } from "preact-router";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { BankFrame } from "../pages/BankFrame.js"; import { BankFrame } from "../pages/BankFrame.js";
import { BusinessAccount } from "../pages/BusinessAccount.js"; import { BusinessAccount } from "../pages/business/Home.js";
import { HomePage, WithdrawalOperationPage } from "../pages/HomePage.js"; import { HomePage, WithdrawalOperationPage } from "../pages/HomePage.js";
import { PublicHistoriesPage } from "../pages/PublicHistoriesPage.js"; import { PublicHistoriesPage } from "../pages/PublicHistoriesPage.js";
import { RegistrationPage } from "../pages/RegistrationPage.js"; import { RegistrationPage } from "../pages/RegistrationPage.js";
import { Test } from "../pages/Test.js"; import { Test } from "../pages/Test.js";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { LoginForm } from "../pages/LoginForm.js"; import { LoginForm } from "../pages/LoginForm.js";
import { AdminPage } from "../pages/AdminPage.js"; import { AdminHome } from "../pages/admin/Home.js";
export function Routing(): VNode { export function Routing(): VNode {
const history = createHashHistory(); const history = createHashHistory();
@ -34,6 +34,7 @@ export function Routing(): VNode {
if (backend.state.status === "loggedOut") { if (backend.state.status === "loggedOut") {
return <BankFrame return <BankFrame
account={undefined}
goToBusinessAccount={() => { goToBusinessAccount={() => {
route("/business"); route("/business");
}} }}
@ -63,7 +64,7 @@ export function Routing(): VNode {
</Router> </Router>
</BankFrame> </BankFrame>
} }
const isAdmin = backend.state.isUserAdministrator const { isUserAdministrator, username } = backend.state
return ( return (
<BankFrame <BankFrame
@ -108,14 +109,15 @@ export function Routing(): VNode {
<Route <Route
path="/account" path="/account"
component={() => { component={() => {
if (isAdmin) { if (isUserAdministrator) {
return <AdminPage return <AdminHome
onRegister={() => { onRegister={() => {
route("/register"); route("/register");
}} }}
/>; />;
} else { } else {
return <HomePage return <HomePage
account={username}
onPendingOperationFound={(wopid) => { onPendingOperationFound={(wopid) => {
route(`/operation/${wopid}`); route(`/operation/${wopid}`);
}} }}
@ -130,6 +132,7 @@ export function Routing(): VNode {
path="/business" path="/business"
component={() => ( component={() => (
<BusinessAccount <BusinessAccount
account={username}
onClose={() => { onClose={() => {
route("/account"); route("/account");
}} }}

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ import { useBackendContext } from "../context/backend.js";
import { getInitialBackendBaseURL } from "../hooks/backend.js"; import { getInitialBackendBaseURL } from "../hooks/backend.js";
import { useSettings } from "../hooks/settings.js"; import { useSettings } from "../hooks/settings.js";
import { AccountPage } from "./AccountPage/index.js"; import { AccountPage } from "./AccountPage/index.js";
import { AdminPage } from "./AdminPage.js"; import { AdminHome } from "./admin/Home.js";
import { LoginForm } from "./LoginForm.js"; import { LoginForm } from "./LoginForm.js";
import { WithdrawalQRCode } from "./WithdrawalQRCode.js"; import { WithdrawalQRCode } from "./WithdrawalQRCode.js";
import { error } from "console"; import { error } from "console";
@ -54,31 +54,24 @@ const logger = new Logger("AccountPage");
*/ */
export function HomePage({ export function HomePage({
onRegister, onRegister,
account,
onPendingOperationFound, onPendingOperationFound,
}: { }: {
account: string,
onPendingOperationFound: (id: string) => void; onPendingOperationFound: (id: string) => void;
onRegister: () => void; onRegister: () => void;
}): VNode { }): VNode {
const backend = useBackendContext();
const [settings] = useSettings(); const [settings] = useSettings();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
if (backend.state.status === "loggedOut") {
return <LoginForm onRegister={onRegister} />;
}
if (settings.currentWithdrawalOperationId) { if (settings.currentWithdrawalOperationId) {
onPendingOperationFound(settings.currentWithdrawalOperationId); onPendingOperationFound(settings.currentWithdrawalOperationId);
return <Loading />; return <Loading />;
} }
if (backend.state.isUserAdministrator) {
return <AdminPage onRegister={onRegister} />;
}
return ( return (
<AccountPage <AccountPage
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n, onRegister)}
/> />
); );
@ -105,8 +98,8 @@ export function WithdrawalOperationPage({
if (!parsedUri) { if (!parsedUri) {
notifyError( notifyError(
i18n.str`The Withdrawal URI is not valid: "${uri}"`, i18n.str`The Withdrawal URI is not valid`,
undefined uri as TranslatedString
); );
return <Loading />; return <Loading />;
} }

View File

@ -0,0 +1,143 @@
import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode,h } from "preact";
import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
import { useState } from "preact/hooks";
import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { buildRequestErrorMessage } from "../utils.js";
import { AccountForm } from "./admin/AccountForm.js";
export function ShowAccountDetails({
account,
onClear,
onUpdateSuccess,
onLoadNotOk,
onChangePassword,
}: {
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => 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
>();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return <div>account not found</div>;
}
return onLoadNotOk(result);
}
return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Business account details</i18n.Translate>
</h1>
</div>
<div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
<AccountForm
template={result.data}
purpose={update ? "update" : "show"}
onChange={(a) => setSubmitAccount(a)}
/>
<p class="buttons-account">
<div
style={{
display: "flex",
justifyContent: "space-between",
flexFlow: "wrap-reverse",
}}
>
<div>
{onClear ? (
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
onClear();
}}
/>
) : undefined}
</div>
<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) {
if (error instanceof RequestError) {
notify(
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 {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}
}}
/>
</div>
</div>
</div>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,131 @@
import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { useAdminAccountAPI, useBusinessAccountDetails } from "../hooks/circuit.js";
import { useState } from "preact/hooks";
import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { VNode,h ,Fragment} from "preact";
import { buildRequestErrorMessage, undefinedIfEmpty } from "../utils.js";
import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js";
export function UpdateAccountPassword({
account,
onClear,
onUpdateSuccess,
onLoadNotOk,
}: {
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
onClear: () => void;
onUpdateSuccess: () => void;
account: string;
}): VNode {
const { i18n } = useTranslationContext();
const result = useBusinessAccountDetails(account);
const { changePassword } = useAdminAccountAPI();
const [password, setPassword] = useState<string | undefined>();
const [repeat, setRepeat] = useState<string | undefined>();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return <div>account not found</div>;
}
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>Update password for {account}</i18n.Translate>
</h1>
</div>
<div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
<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`Repeat 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) {
if (error instanceof RequestError) {
notify(buildRequestErrorMessage(i18n, error.cause));
} else {
notifyError(i18n.str`Operation failed, please report`, (error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString)
}
}
}}
/>
</div>
</div>
</p>
</div>
</div>
);
}

View File

@ -317,7 +317,8 @@ export function WithdrawalConfirmationQuestion({
</div> </div>
<div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt>
<dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{Amounts.stringifyValue(details.amount)}</dd> <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">To be added</dd>
{/* Amounts.stringifyValue(details.amount) */}
</div> </div>
</dl> </dl>
</div> </div>

View File

@ -100,10 +100,6 @@ export function WithdrawalQRCode({
} }
if (data.confirmation_done) { if (data.confirmation_done) {
if (!settings.showWithdrawalSuccess) {
clearCurrentWithdrawal()
onContinue()
}
return <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> return <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
<div> <div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100"> <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-green-100">

View File

@ -0,0 +1,56 @@
import { Amounts } from "@gnu-taler/taler-util";
import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js";
import { handleNotOkResult } from "../HomePage.js";
import { useAccountDetails } from "../../hooks/access.js";
import { useBackendContext } from "../../context/backend.js";
import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode {
const { i18n } = useTranslationContext();
const r = useBackendContext();
const account = r.state.status === "loggedIn" ? r.state.username : "admin";
const result = useAccountDetails(account);
if (!result.ok) {
return handleNotOkResult(i18n, onRegister)(result);
}
const { data } = result;
const balance = Amounts.parseOrThrow(data.balance.amount);
const debitThreshold = Amounts.parseOrThrow(result.data.debitThreshold);
const balanceIsDebit = result.data.balance.credit_debit_indicator == "debit";
const limit = balanceIsDebit
? Amounts.sub(debitThreshold, balance).amount
: Amounts.add(balance, debitThreshold).amount;
if (!balance) return <Fragment />;
return (
<Fragment>
<section id="assets">
<div class="asset-summary">
<h2>{i18n.str`Bank account balance`}</h2>
{!balance ? (
<div class="large-amount" style={{ color: "gray" }}>
Waiting server response...
</div>
) : (
<div class="large-amount amount">
{balanceIsDebit ? <b>-</b> : null}
<span class="value">{`${Amounts.stringifyValue(balance)}`}</span>
&nbsp;
<span class="currency">{`${balance.currency}`}</span>
</div>
)}
</div>
</section>
<PaytoWireTransferForm
focus
limit={limit}
onSuccess={() => {
notifyInfo(i18n.str`Wire transfer created!`);
}}
onCancel={undefined}
/>
</Fragment>
);
}

View File

@ -0,0 +1,219 @@
import { VNode,h } from "preact";
import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
import { PartialButDefined, RecursivePartial, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js";
import { useState } from "preact/hooks";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { parsePaytoUri } from "@gnu-taler/taler-util";
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 ]*$/;
/**
* 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
*/
export 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<typeof initial> | undefined
>(undefined);
const { i18n } = useTranslationContext();
function updateForm(newForm: typeof initial): void {
const parsed = !newForm.cashout_address
? undefined
: parsePaytoUri(newForm.cashout_address);
const errors = undefinedIfEmpty<RecursivePartial<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`
: 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 class="pure-form">
<fieldset>
<label for="username">
{i18n.str`Username`}
{purpose === "create" && <b style={{ color: "red" }}>*</b>}
</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`}
{purpose === "create" && <b style={{ color: "red" }}>*</b>}
</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>
{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`Email`}
{purpose !== "show" && <b style={{ color: "red" }}>*</b>}
</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`}
{purpose !== "show" && <b style={{ color: "red" }}>*</b>}
</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`}
{purpose !== "show" && <b style={{ color: "red" }}>*</b>}
</label>
<input
disabled={purpose === "show"}
value={(form.cashout_address ?? "").substring("payto://iban/".length)}
onChange={(e) => {
form.cashout_address = "payto://iban/" + e.currentTarget.value;
updateForm(structuredClone(form));
}}
/>
<ShowInputErrorLabel
message={errors?.cashout_address}
isDirty={form.cashout_address !== undefined}
/>
</fieldset>
</form>
);
}
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;
}

View File

@ -0,0 +1,120 @@
import { h, VNode } from "preact";
import { useBusinessAccounts } from "../../hooks/circuit.js";
import { handleNotOkResult } from "../HomePage.js";
import { AccountAction } from "./Home.js";
import { Amounts } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
interface Props {
onAction: (type: AccountAction, account: string) => void;
account: string | undefined;
onRegister: () => void;
}
export function AccountList({ account, onAction, onRegister }: Props): VNode {
const result = useBusinessAccounts({ account });
const { i18n } = useTranslationContext();
if (result.loading) return <div />;
if (!result.ok) {
return handleNotOkResult(i18n, onRegister)(result);
}
const { customers } = result.data;
return <section
id="main"
style={{ width: 600, marginLeft: "auto", marginRight: "auto" }}
>
{!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>{i18n.str`Balance`}</th>
<th>{i18n.str`Actions`}</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={idx}>
<td>
<a
href="#"
onClick={(e) => {
e.preventDefault();
onAction("show-details", item.username)
}}
>
{item.username}
</a>
</td>
<td>{item.name}</td>
<td>
{!balance ? (
i18n.str`unknown`
) : (
<span class="amount">
{balanceIsDebit ? <b>-</b> : null}
<span class="value">{`${Amounts.stringifyValue(
balance,
)}`}</span>
&nbsp;
<span class="currency">{`${balance.currency}`}</span>
</span>
)}
</td>
<td>
<a
href="#"
onClick={(e) => {
e.preventDefault();
onAction("update-password", item.username)
}}
>
change password
</a>
&nbsp;
<a
href="#"
onClick={(e) => {
e.preventDefault();
onAction("show-cashout", item.username)
}}
>
cashouts
</a>
&nbsp;
<a
href="#"
onClick={(e) => {
e.preventDefault();
onAction("remove-account", item.username)
}}
>
remove
</a>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</article>
)}
</section>
}

View File

@ -0,0 +1,107 @@
import { RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h, Fragment } from "preact";
import { useAdminAccountAPI } from "../../hooks/circuit.js";
import { useState } from "preact/hooks";
import { buildRequestErrorMessage } from "../../utils.js";
import { HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { getRandomPassword } from "../rnd.js";
import { AccountForm } from "./AccountForm.js";
export 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
>();
return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>New account</i18n.Translate>
</h1>
</div>
<div style={{ maxWidth: 600, overflowX: "hidden", margin: "auto" }}>
<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: getRandomPassword(),
};
await createAccount(account);
onCreateSuccess(account.password);
} catch (error) {
if (error instanceof RequestError) {
notify(
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 {
notifyError(
i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString
)
}
}
}}
/>
</div>
</div>
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,162 @@
import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks";
import { Cashouts } from "../../components/Cashouts/index.js";
import { ShowCashoutDetails } from "../business/Home.js";
import { handleNotOkResult } from "../HomePage.js";
import { ShowAccountDetails } from "../ShowAccountDetails.js";
import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
import { AdminAccount } from "./Account.js";
import { AccountList } from "./AccountList.js";
import { CreateNewAccount } from "./CreateNewAccount.js";
import { RemoveAccount } from "./RemoveAccount.js";
/**
* Query account information and show QR code if there is pending withdrawal
*/
interface Props {
onRegister: () => void;
}
export type AccountAction = "show-details" |
"show-cashout" |
"update-password" |
"remove-account" |
"show-cashouts-details";
export function AdminHome({ onRegister }: Props): VNode {
const [action, setAction] = useState<{
type: AccountAction,
account: string
}>()
const [createAccount, setCreateAccount] = useState(false);
const { i18n } = useTranslationContext();
if (action) {
switch (action.type) {
case "show-details": return <ShowCashoutDetails
id={action.account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onCancel={() => {
setAction(undefined);
}}
/>
case "show-cashout": return (
<div>
<div>
<h1 class="nav welcome-text">
<i18n.Translate>Cashout for account {action.account}</i18n.Translate>
</h1>
</div>
<Cashouts
account={action.account}
onSelected={(id) => {
setAction({
type: "show-cashouts-details",
account: action.account
});
}}
/>
<p>
<input
class="pure-button"
type="submit"
value={i18n.str`Close`}
onClick={async (e) => {
e.preventDefault();
setAction(undefined);
}}
/>
</p>
</div>
)
case "update-password": return <UpdateAccountPassword
account={action.account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Password changed`);
setAction(undefined);
}}
onClear={() => {
setAction(undefined);
}}
/>
case "remove-account": return <RemoveAccount
account={action.account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account removed`);
setAction(undefined);
}}
onClear={() => {
setAction(undefined);
}}
/>
case "show-cashouts-details": return <ShowAccountDetails
account={action.account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onChangePassword={() => {
setAction({
type: "update-password",
account: action.account,
})
}}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`);
setAction(undefined);
}}
onClear={() => {
setAction(undefined);
}}
/>
}
}
if (createAccount) {
return (
<CreateNewAccount
onClose={() => setCreateAccount(false)}
onCreateSuccess={(password) => {
notifyInfo(
i18n.str`Account created with password "${password}". The user must change the password on the next login.`,
);
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>
<AdminAccount onRegister={onRegister} />
<AccountList account={undefined} onAction={(type,account) => setAction({account, type})} onRegister={onRegister}/>
</Fragment>
);
}

View File

@ -0,0 +1,112 @@
import { ErrorType, HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode,h,Fragment } from "preact";
import { useAccountDetails } from "../../hooks/access.js";
import { useAdminAccountAPI } from "../../hooks/circuit.js";
import { Amounts, HttpStatusCode, TranslatedString } from "@gnu-taler/taler-util";
import { buildRequestErrorMessage } from "../../utils.js";
export function RemoveAccount({
account,
onClear,
onUpdateSuccess,
onLoadNotOk,
}: {
onLoadNotOk: <T>(
error: HttpResponsePaginated<T, SandboxBackend.SandboxError>,
) => VNode;
onClear: () => void;
onUpdateSuccess: () => void;
account: string;
}): VNode {
const { i18n } = useTranslationContext();
const result = useAccountDetails(account);
const { deleteAccount } = useAdminAccountAPI();
if (!result.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return <div>account not found</div>;
}
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>
{/* {FXME: SHOW WARNING} */}
{/* {!isBalanceEmpty && (
<ErrorBannerFloat
error={{
title: i18n.str`Can't delete the account`,
description: i18n.str`Balance is not empty`,
}}
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) {
if (error instanceof RequestError) {
notify(
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 {
notifyError(i18n.str`Operation failed, please report`,
(error instanceof Error
? error.message
: JSON.stringify(error)) as TranslatedString);
}
}
}}
/>
</div>
</div>
</p>
</div>
);
}

View File

@ -30,52 +30,51 @@ import {
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Cashouts } from "../components/Cashouts/index.js"; import { Cashouts } from "../../components/Cashouts/index.js";
import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../../context/backend.js";
import { useAccountDetails } from "../hooks/access.js"; import { useAccountDetails } from "../../hooks/access.js";
import { import {
useCashoutDetails, useCashoutDetails,
useCircuitAccountAPI, useCircuitAccountAPI,
useEstimator, useEstimator,
useRatiosAndFeeConfig, useRatiosAndFeeConfig,
} from "../hooks/circuit.js"; } from "../../hooks/circuit.js";
import { import {
TanChannel, TanChannel,
buildRequestErrorMessage, buildRequestErrorMessage,
undefinedIfEmpty, undefinedIfEmpty,
} from "../utils.js"; } from "../../utils.js";
import { ShowAccountDetails, UpdateAccountPassword } from "./AdminPage.js"; import { handleNotOkResult } from "../HomePage.js";
import { handleNotOkResult } from "./HomePage.js"; import { LoginForm } from "../LoginForm.js";
import { LoginForm } from "./LoginForm.js"; import { Amount } from "../PaytoWireTransferForm.js";
import { Amount } from "./PaytoWireTransferForm.js"; import { ShowAccountDetails } from "../ShowAccountDetails.js";
import { UpdateAccountPassword } from "../UpdateAccountPassword.js";
interface Props { interface Props {
account: string,
onClose: () => void; onClose: () => void;
onRegister: () => void; onRegister: () => void;
onLoadNotOk: () => void; onLoadNotOk: () => void;
} }
export function BusinessAccount({ export function BusinessAccount({
onClose, onClose,
account,
onLoadNotOk, onLoadNotOk,
onRegister, onRegister,
}: Props): VNode { }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext();
const [updatePassword, setUpdatePassword] = useState(false); const [updatePassword, setUpdatePassword] = useState(false);
const [newCashout, setNewcashout] = useState(false); const [newCashout, setNewcashout] = useState(false);
const [showCashoutDetails, setShowCashoutDetails] = useState< const [showCashoutDetails, setShowCashoutDetails] = useState<
string | undefined string | undefined
>(); >();
if (backend.state.status === "loggedOut") {
return <LoginForm onRegister={onRegister} />;
}
if (newCashout) { if (newCashout) {
return ( return (
<CreateCashout <CreateCashout
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onCancel={() => { onCancel={() => {
setNewcashout(false); setNewcashout(false);
@ -104,7 +103,7 @@ export function BusinessAccount({
if (updatePassword) { if (updatePassword) {
return ( return (
<UpdateAccountPassword <UpdateAccountPassword
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onUpdateSuccess={() => { onUpdateSuccess={() => {
notifyInfo(i18n.str`Password changed`); notifyInfo(i18n.str`Password changed`);
@ -119,7 +118,7 @@ export function BusinessAccount({
return ( return (
<div> <div>
<ShowAccountDetails <ShowAccountDetails
account={backend.state.username} account={account}
onLoadNotOk={handleNotOkResult(i18n, onRegister)} onLoadNotOk={handleNotOkResult(i18n, onRegister)}
onUpdateSuccess={() => { onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`); notifyInfo(i18n.str`Account updated`);
@ -133,7 +132,7 @@ export function BusinessAccount({
<div class="active"> <div class="active">
<h3>{i18n.str`Latest cashouts`}</h3> <h3>{i18n.str`Latest cashouts`}</h3>
<Cashouts <Cashouts
account={backend.state.username} account={account}
onSelected={(id) => { onSelected={(id) => {
setShowCashoutDetails(id); setShowCashoutDetails(id);
}} }}