/*
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 } from "@gnu-taler/taler-util";
import {
ErrorType,
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 { useBackendContext } from "../context/backend.js";
import { useAccountDetails } from "../hooks/access.js";
import {
useAdminAccountAPI,
useBusinessAccountDetails,
useBusinessAccounts,
} 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 { handleNotOkResult } from "./HomePage.js";
import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js";
import { ShowInputErrorLabel } from "./ShowInputErrorLabel.js";
import { ErrorMessage, notifyInfo } from "../hooks/notification.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 {
onRegister: () => void;
}
/**
* Query account information and show QR code if there is pending withdrawal
*/
export function AdminPage({ onRegister }: 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 result = useBusinessAccounts({ account });
const { i18n } = useTranslationContext();
if (result.loading) return
;
if (!result.ok) {
return handleNotOkResult(i18n, onRegister)(result);
}
const { customers } = result.data;
if (showCashoutDetails) {
return (
{
setShowCashoutDetails(undefined);
}}
/>
);
}
if (showCashouts) {
return (
);
}
if (showDetails) {
return (
{
setUpdatePassword(showDetails);
setShowDetails(undefined);
}}
onUpdateSuccess={() => {
notifyInfo(i18n.str`Account updated`);
setShowDetails(undefined);
}}
onClear={() => {
setShowDetails(undefined);
}}
/>
);
}
if (removeAccount) {
return (
{
notifyInfo(i18n.str`Account removed`);
setRemoveAccount(undefined);
}}
onClear={() => {
setRemoveAccount(undefined);
}}
/>
);
}
if (updatePassword) {
return (
{
notifyInfo(i18n.str`Password changed`);
setUpdatePassword(undefined);
}}
onClear={() => {
setUpdatePassword(undefined);
}}
/>
);
}
if (createAccount) {
return (
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 (
Admin panel
{!customers.length ? (
) : (
{i18n.str`Accounts:`}
)}
);
}
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 ;
return (
{i18n.str`Bank account balance`}
{!balance ? (
Waiting server response...
) : (
{balanceIsDebit ? - : null}
{`${Amounts.stringifyValue(balance)}`}
{`${balance.currency}`}
)}
{
notifyInfo(i18n.str`Wire transfer created!`);
}}
/>
);
}
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.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return account not found
;
}
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)} />
)}
);
}
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)} />
)}
{
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.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return account not found
;
}
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.ok) {
if (result.loading || result.type === ErrorType.TIMEOUT) {
return onLoadNotOk(result);
}
if (result.status === HttpStatusCode.NotFound) {
return account not found
;
}
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 && (
saveError(undefined)}
/>
)}
{error && (
saveError(undefined)} />
)}
);
}
/**
* 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 (
);
}