diff options
Diffstat (limited to 'packages/aml-backoffice-ui/src/pages/Search.tsx')
-rw-r--r-- | packages/aml-backoffice-ui/src/pages/Search.tsx | 731 |
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> + + <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.`; +} |