aboutsummaryrefslogtreecommitdiff
path: root/packages/aml-backoffice-ui/src/pages/Search.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/aml-backoffice-ui/src/pages/Search.tsx')
-rw-r--r--packages/aml-backoffice-ui/src/pages/Search.tsx731
1 files changed, 731 insertions, 0 deletions
diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx
new file mode 100644
index 000000000..e3684d71b
--- /dev/null
+++ b/packages/aml-backoffice-ui/src/pages/Search.tsx
@@ -0,0 +1,731 @@
+/*
+ This file is part of GNU Taler
+ (C) 2022-2024 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 {
+ AbsoluteTime,
+ assertUnreachable,
+ buildPayto,
+ encodeCrock,
+ hashNormalizedPaytoUri,
+ HttpStatusCode,
+ parsePaytoUri,
+ PaytoUri,
+ stringifyPaytoUri,
+ TalerError,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ convertUiField,
+ encodeCrockForURI,
+ getConverterById,
+ InternationalizationAPI,
+ Loading,
+ RenderAllFieldsByUiConfig,
+ Time,
+ UIFormElementConfig,
+ UIHandlerId,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useAccountDecisions } from "../hooks/decisions.js";
+import {
+ FormErrors,
+ FormStatus,
+ FormValues,
+ getShapeFromFields,
+ RecursivePartial,
+ useFormState,
+} from "../hooks/form.js";
+import { useOfficer } from "../hooks/officer.js";
+import { privatePages } from "../Routing.js";
+import { Pagination, ToInvestigateIcon } from "./Cases.js";
+import { undefinedIfEmpty } from "./CreateAccount.js";
+import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
+
+export function Search() {
+ const officer = useOfficer();
+ const { i18n } = useTranslationContext();
+
+ const [paytoUri, setPayto] = useState<PaytoUri | undefined>(undefined);
+
+ const paytoForm = useFormState(
+ getShapeFromFields(paytoTypeField(i18n)),
+ { paytoType: "iban" },
+ createFormValidator(i18n),
+ );
+
+ if (officer.state !== "ready") {
+ return <HandleAccountNotReady officer={officer} />;
+ }
+
+ return (
+ <div>
+ <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
+ <i18n.Translate>Search account</i18n.Translate>
+ </h1>
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(
+ i18n,
+ paytoTypeField(i18n),
+ paytoForm.handler,
+ getConverterById,
+ )}
+ />
+ </div>
+ </form>
+
+ {paytoForm.status.status !== "ok" ? undefined : paytoForm.status.result
+ .paytoType === "x-taler-bank" ? (
+ <XTalerBankForm onSearch={setPayto} />
+ ) : paytoForm.status.result.paytoType === "iban" ? (
+ <IbanForm onSearch={setPayto} />
+ ) : (
+ <GenericForm onSearch={setPayto} />
+ )}
+ {!paytoUri ? undefined : <ShowResult payto={paytoUri} />}
+ </div>
+ );
+}
+
+function ShowResult({ payto }: { payto: PaytoUri }): VNode {
+ const paytoStr = stringifyPaytoUri(payto);
+ const account = encodeCrock(hashNormalizedPaytoUri(paytoStr));
+ const { i18n } = useTranslationContext();
+
+ const history = useAccountDecisions(account);
+ if (!history) {
+ return <Loading />;
+ }
+ if (history instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={history} />;
+ }
+ if (history.type === "fail") {
+ switch (history.case) {
+ case HttpStatusCode.Forbidden: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account signature is wrong, contact administrator or create
+ a new one.
+ </i18n.Translate>
+ </Attention>
+ </Fragment>
+ );
+ }
+ case HttpStatusCode.Conflict: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account doesn't have access. Request account activation
+ sending your public key.
+ </i18n.Translate>
+ </Attention>
+ </Fragment>
+ );
+ }
+ case HttpStatusCode.NotFound: {
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>This account is not known.</i18n.Translate>
+ </Attention>
+ </Fragment>
+ );
+ }
+ default: {
+ assertUnreachable(history);
+ }
+ }
+ }
+
+ if (history.body.length) {
+ return (
+ <div class="mt-8">
+ <div class="mb-2">
+ <a
+ href={privatePages.caseDetails.url({
+ cid: account,
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>Check account details</i18n.Translate>
+ </a>
+ </div>
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <div>
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Account most recent decisions</i18n.Translate>
+ </h1>
+ </div>
+ </div>
+ </div>
+
+ <div class="flow-root">
+ <div class="overflow-x-auto">
+ {!history.body.length ? (
+ <div>empty result </div>
+ ) : (
+ <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
+ <table class="min-w-full divide-y divide-gray-300">
+ <thead>
+ <tr>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-80"
+ >
+ <i18n.Translate>When</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-80"
+ >
+ <i18n.Translate>Justification</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-40"
+ >
+ <i18n.Translate>Status</i18n.Translate>
+ </th>
+ </tr>
+ </thead>
+ <tbody class="divide-y divide-gray-200 bg-white">
+ {history.body.map((r, idx) => {
+ return (
+ <tr key={r.h_payto} class="hover:bg-gray-100 ">
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
+ <Time
+ format="dd/MM/yyyy HH:mm"
+ timestamp={AbsoluteTime.fromProtocolTimestamp(
+ r.decision_time,
+ )}
+ />
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
+ {r.justification}
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900">
+ {idx === 0 ? (
+ <span class="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">
+ <i18n.Translate>LATEST</i18n.Translate>
+ </span>
+ ) : undefined}
+ {r.is_active ? (
+ <span class="inline-flex items-center rounded-md bg-gray-50 px-2 py-1 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-500/10">
+ <i18n.Translate>ACTIVE</i18n.Translate>
+ </span>
+ ) : undefined}
+ {r.decision_time ? (
+ <span title="require investigation">
+ <ToInvestigateIcon />
+ </span>
+ ) : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ <Pagination
+ onFirstPage={
+ history.isFirstPage ? undefined : history.loadFirst
+ }
+ onNext={history.isLastPage ? undefined : history.loadNext}
+ />
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+ }
+ return (
+ <div class="mt-4">
+ <Attention title={i18n.str`Account not found`} type="warning">
+ <i18n.Translate>
+ There is no history known for this account yet.
+ </i18n.Translate>
+ &nbsp;
+ <a
+ href={privatePages.caseDetailsNewAccount.url({
+ cid: account,
+ payto: encodeCrockForURI(paytoStr),
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>
+ You can make a decision for this account anyway.
+ </i18n.Translate>
+ </a>
+ </Attention>
+ </div>
+ );
+}
+
+function XTalerBankForm({
+ onSearch,
+}: {
+ onSearch: (p: PaytoUri | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const fields = talerBankFields(i18n);
+ const form = useFormState(
+ getShapeFromFields(fields),
+ {},
+ createTalerBankPaytoValidator(i18n),
+ );
+ const paytoUri =
+ form.status.status === "fail"
+ ? undefined
+ : buildPayto(
+ "x-taler-bank",
+ form.status.result.hostname,
+ form.status.result.account,
+ {
+ "receiver-name": form.status.result.name,
+ },
+ );
+
+ return (
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(i18n, fields, form.handler, getConverterById)}
+ />
+ </div>
+ <button
+ disabled={form.status.status === "fail"}
+ class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ onClick={() => onSearch(paytoUri)}
+ >
+ <i18n.Translate>Search</i18n.Translate>
+ </button>
+ </form>
+ );
+}
+function IbanForm({
+ onSearch,
+}: {
+ onSearch: (p: PaytoUri | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const fields = ibanFields(i18n);
+ const form = useFormState(
+ getShapeFromFields(fields),
+ {},
+ createIbanPaytoValidator(i18n),
+ );
+ const paytoUri =
+ form.status.status === "fail"
+ ? undefined
+ : buildPayto("iban", form.status.result.account, form.status.result.bic, {
+ "receiver-name": form.status.result.name,
+ });
+
+ return (
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(i18n, fields, form.handler, getConverterById)}
+ />
+ </div>
+ <button
+ disabled={form.status.status === "fail"}
+ class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ onClick={() => onSearch(paytoUri)}
+ >
+ <i18n.Translate>Search</i18n.Translate>
+ </button>
+ </form>
+ );
+}
+function GenericForm({
+ onSearch,
+}: {
+ onSearch: (p: PaytoUri | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const fields = genericFields(i18n);
+ const form = useFormState(
+ getShapeFromFields(fields),
+ {},
+ createGenericPaytoValidator(i18n),
+ );
+ const paytoUri =
+ form.status.status === "fail"
+ ? undefined
+ : parsePaytoUri(form.status.result.payto);
+ return (
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(i18n, fields, form.handler, getConverterById)}
+ />
+ </div>
+ <button
+ disabled={form.status.status === "fail"}
+ class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ onClick={() => onSearch(paytoUri)}
+ >
+ Search
+ </button>
+ </form>
+ );
+}
+
+interface FormPayto {
+ paytoType: "generic" | "iban" | "x-taler-bank";
+}
+
+function createFormValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<FormPayto>>,
+ ): FormStatus<FormPayto> {
+ const errors = undefinedIfEmpty<FormErrors<FormPayto>>({
+ paytoType: !state?.paytoType ? i18n.str`required` : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: FormPayto = {
+ paytoType: state.paytoType! as any,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<FormPayto> = {
+ paytoType: state?.paytoType,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+interface PaytoUriGenericForm {
+ payto: string;
+}
+
+function createGenericPaytoValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<PaytoUriGenericForm>>,
+ ): FormStatus<PaytoUriGenericForm> {
+ const errors = undefinedIfEmpty<FormErrors<PaytoUriGenericForm>>({
+ payto: !state.payto
+ ? i18n.str`required`
+ : parsePaytoUri(state.payto) === undefined
+ ? i18n.str`invalid`
+ : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: PaytoUriGenericForm = {
+ payto: state.payto! as any,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<PaytoUriGenericForm> = {
+ // targetType: state.iban
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+interface PaytoUriIBANForm {
+ account: string;
+ name: string;
+ bic: string;
+}
+
+function createIbanPaytoValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<PaytoUriIBANForm>>,
+ ): FormStatus<PaytoUriIBANForm> {
+ const errors = undefinedIfEmpty<FormErrors<PaytoUriIBANForm>>({
+ account: !state.account ? i18n.str`required` : undefined,
+ name: !state.name ? i18n.str`required` : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: PaytoUriIBANForm = {
+ account: state.account!,
+ name: state.name!,
+ bic: state.bic!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<PaytoUriIBANForm> = {
+ account: state.account,
+ name: state.name,
+ bic: state.bic,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+interface PaytoUriTalerBankForm {
+ hostname: string;
+ account: string;
+ name: string;
+}
+function createTalerBankPaytoValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<PaytoUriTalerBankForm>>,
+ ): FormStatus<PaytoUriTalerBankForm> {
+ const errors = undefinedIfEmpty<FormErrors<PaytoUriTalerBankForm>>({
+ account: !state.account ? i18n.str`required` : undefined,
+ hostname: !state.hostname ? i18n.str`required` : undefined,
+ name: !state.name ? i18n.str`required` : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: PaytoUriTalerBankForm = {
+ account: state.account!,
+ hostname: state.hostname!,
+ name: state.name!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<PaytoUriTalerBankForm> = {
+ account: state.account,
+ hostname: state.hostname,
+ name: state.name,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+const paytoTypeField: (
+ i18n: InternationalizationAPI,
+) => UIFormElementConfig[] = (i18n) => [
+ {
+ id: "paytoType" as UIHandlerId,
+ type: "choiceHorizontal",
+ required: true,
+ choices: [
+ {
+ value: "iban",
+ label: i18n.str`IBAN`,
+ },
+ {
+ value: "x-taler-bank",
+ label: i18n.str`Taler Bank`,
+ },
+ {
+ value: "generic",
+ label: i18n.str`Generic Payto:// URI`,
+ },
+ ],
+ label: i18n.str`Account type`,
+ },
+];
+
+const receiverName: (i18n: InternationalizationAPI) => UIFormElementConfig = (
+ i18n,
+) => ({
+ id: "name" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Owner's name`,
+ help: i18n.str`It should match the bank account name.`,
+ placeholder: i18n.str`John Doe`,
+});
+
+const genericFields: (
+ i18n: InternationalizationAPI,
+) => UIFormElementConfig[] = (i18n) => [
+ {
+ id: "payto" as UIHandlerId,
+ type: "textArea",
+ required: true,
+ label: i18n.str`Payto URI`,
+ help: i18n.str`As defined by RFC 8905`,
+ placeholder: i18n.str`payto://`,
+ },
+];
+const ibanFields: (i18n: InternationalizationAPI) => UIFormElementConfig[] = (
+ i18n,
+) => [
+ {
+ id: "account" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Account`,
+ help: i18n.str`International Bank Account Number`,
+ placeholder: i18n.str`DE1231231231`,
+ // validator: (value) => validateIBAN(value, i18n),
+ },
+ receiverName(i18n),
+ {
+ id: "bic" as UIHandlerId,
+ type: "text",
+ label: i18n.str`Bank`,
+ help: i18n.str`Business Identifier Code`,
+ placeholder: i18n.str`GENODEM1GLS`,
+ // validator: (value) => validateIBAN(value, i18n),
+ },
+];
+
+const talerBankFields: (
+ i18n: InternationalizationAPI,
+) => UIFormElementConfig[] = (i18n) => [
+ {
+ id: "account" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Bank account`,
+ help: i18n.str`Bank account id`,
+ placeholder: i18n.str`DE123123123`,
+ },
+ {
+ id: "hostname" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Hostname`,
+ help: i18n.str`Without the scheme, may include subpath: bank.com, bank.com/path/`,
+ placeholder: i18n.str`bank.demo.taler.net`,
+ // validator: (value) => validateTalerBank(value, i18n),
+ },
+ receiverName(i18n),
+];
+
+function validateIBAN(
+ iban: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): TranslatedString | undefined {
+ // Check total length
+ if (iban.length < 4)
+ return i18n.str`IBAN numbers usually have more that 4 digits`;
+ if (iban.length > 34)
+ return i18n.str`IBAN numbers usually have less that 34 digits`;
+
+ const A_code = "A".charCodeAt(0);
+ const Z_code = "Z".charCodeAt(0);
+ const IBAN = iban.toUpperCase();
+
+ // check supported country
+ // const code = IBAN.substr(0, 2);
+ // const found = code in COUNTRY_TABLE;
+ // if (!found) return i18n.str`IBAN country code not found`;
+
+ // 2.- Move the four initial characters to the end of the string
+ const step2 = IBAN.substr(4) + iban.substr(0, 4);
+ const step3 = Array.from(step2)
+ .map((letter) => {
+ const code = letter.charCodeAt(0);
+ if (code < A_code || code > Z_code) return letter;
+ return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
+ })
+ .join("");
+
+ function calculate_iban_checksum(str: string): number {
+ const numberStr = str.substr(0, 5);
+ const rest = str.substr(5);
+ const number = parseInt(numberStr, 10);
+ const result = number % 97;
+ if (rest.length > 0) {
+ return calculate_iban_checksum(`${result}${rest}`);
+ }
+ return result;
+ }
+
+ const checksum = calculate_iban_checksum(step3);
+ if (checksum !== 1)
+ return i18n.str`IBAN number is invalid, checksum is wrong`;
+ return undefined;
+}
+
+const DOMAIN_REGEX =
+ /^[a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9-_](?:\.[a-zA-Z0-9-_]{2,})+(:[0-9]+)?(\/[a-zA-Z0-9-.]+)*\/?$/;
+
+function validateTalerBank(
+ addr: string,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ try {
+ const valid = DOMAIN_REGEX.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid host.`;
+}