diff options
Diffstat (limited to 'packages/aml-backoffice-ui')
19 files changed, 2373 insertions, 470 deletions
diff --git a/packages/aml-backoffice-ui/package.json b/packages/aml-backoffice-ui/package.json index 9c33862f7..19a3902f1 100644 --- a/packages/aml-backoffice-ui/package.json +++ b/packages/aml-backoffice-ui/package.json @@ -1,13 +1,12 @@ { "private": true, "name": "@gnu-taler/aml-backoffice-ui", - "version": "0.11.4", + "version": "0.13.11", "author": "sebasjm", "license": "AGPL-3.0-OR-LATER", "description": "Back-office SPA for GNU Taler Exchange.", "type": "module", "scripts": { - "build": "./build.mjs", "typedoc": "typedoc --out dist/typedoc ./src/", "check": "tsc", "clean": "rm -rf dist lib", diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx index e9be84441..c5a935044 100644 --- a/packages/aml-backoffice-ui/src/App.tsx +++ b/packages/aml-backoffice-ui/src/App.tsx @@ -13,7 +13,12 @@ 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 { canonicalizeBaseUrl } from "@gnu-taler/taler-util"; +import { + CacheEvictor, + TalerExchangeCacheEviction, + assertUnreachable, + canonicalizeBaseUrl, +} from "@gnu-taler/taler-util"; import { BrowserHashNavigationProvider, ExchangeApiProvider, @@ -31,6 +36,8 @@ import { strings } from "./i18n/strings.js"; import "./scss/main.css"; import { UiSettings, fetchUiSettings } from "./context/ui-settings.js"; import { UiFormsProvider, fetchUiForms } from "./context/ui-forms.js"; +import { revalidateAccountDecisions } from "./hooks/decisions.js"; +import { revalidateAccountInformation } from "./hooks/account.js"; const WITH_LOCAL_STORAGE_CACHE = false; @@ -56,6 +63,9 @@ export function App(): VNode { <ExchangeApiProvider baseUrl={new URL("/", baseUrl)} frameOnError={ExchangeAmlFrame} + evictors={{ + exchange: evictExchangeSwrCache, + }} > <SWRConfig value={{ @@ -111,7 +121,7 @@ function getInitialBackendBaseURL( ): string { const overrideUrl = typeof localStorage !== "undefined" - ? localStorage.getItem("exchange-base-url") + ? localStorage.getItem("aml-base-url") : undefined; let result: string; @@ -136,3 +146,21 @@ function getInitialBackendBaseURL( return canonicalizeBaseUrl(window.origin); } } + +const evictExchangeSwrCache: CacheEvictor<TalerExchangeCacheEviction> = { + async notifySuccess(op) { + switch (op) { + case TalerExchangeCacheEviction.MAKE_AML_DECISION: { + await revalidateAccountDecisions(); + await revalidateAccountInformation(); + return; + } + case TalerExchangeCacheEviction.UPLOAD_KYC_FORM: { + return; + } + default: { + assertUnreachable(op); + } + } + }, +}; diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx index 772fd1b70..a74cd09b9 100644 --- a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx +++ b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx @@ -33,7 +33,12 @@ import { getLabelForPreferences, usePreferences, } from "./hooks/preferences.js"; -import { HomeIcon } from "./pages/Cases.js"; +import { + HomeIcon, + PeopleIcon, + SearchIcon, + ToInvestigateIcon, +} from "./pages/Cases.js"; /** * mapping route to view @@ -110,7 +115,7 @@ export function ExchangeAmlFrame({ children, officer, }: { - officer?: OfficerState, + officer?: OfficerState; children?: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); @@ -133,7 +138,7 @@ export function ExchangeAmlFrame({ }, [error]); const [preferences, updatePreferences] = usePreferences(); - const settings = useUiSettingsContext() + const settings = useUiSettingsContext(); return ( <div @@ -208,12 +213,17 @@ export function ExchangeAmlFrame({ <div class="-mt-32 flex grow "> {officer?.state !== "ready" ? undefined : <Navigation />} <div class="flex mx-auto my-4"> - <main class="rounded-lg bg-white px-5 py-6 shadow">{children}</main> + <main + class="block rounded-lg bg-white px-5 py-6 shadow " + style={{ minWidth: 600 }} + > + {children} + </main> </div> </div> <Footer - testingUrlKey="exchange-base-url" + testingUrlKey="aml-base-url" GIT_HASH={GIT_HASH} VERSION={VERSION} /> @@ -224,8 +234,18 @@ export function ExchangeAmlFrame({ function Navigation(): VNode { const { i18n } = useTranslationContext(); const pageList = [ - { route: privatePages.account, Icon: HomeIcon, label: i18n.str`Account` }, - { route: privatePages.cases, Icon: HomeIcon, label: i18n.str`Cases` }, + { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` }, + { + route: privatePages.investigation, + Icon: ToInvestigateIcon, + label: i18n.str`Investigation`, + }, + { route: privatePages.active, Icon: HomeIcon, label: i18n.str`Active` }, + { + route: privatePages.search, + Icon: SearchIcon, + label: i18n.str`Search`, + }, ]; const { path } = useNavigationContext(); return ( diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx index f38fc29c2..21b0c9929 100644 --- a/packages/aml-backoffice-ui/src/Routing.tsx +++ b/packages/aml-backoffice-ui/src/Routing.tsx @@ -15,6 +15,7 @@ */ import { + decodeCrockFromURI, urlPattern, useCurrentLocation, useNavigationContext, @@ -22,15 +23,20 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { assertUnreachable } from "@gnu-taler/taler-util"; +import { + assertUnreachable, + parsePaytoUri, + PaytoString, +} from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js"; import { useOfficer } from "./hooks/officer.js"; -import { Cases } from "./pages/Cases.js"; +import { Cases, CasesUnderInvestigation } from "./pages/Cases.js"; import { Officer } from "./pages/Officer.js"; import { CaseDetails } from "./pages/CaseDetails.js"; import { CaseUpdate, SelectForm } from "./pages/CaseUpdate.js"; import { HandleAccountNotReady } from "./pages/HandleAccountNotReady.js"; +import { Search } from "./pages/Search.js"; export function Routing(): VNode { const session = useOfficer(); @@ -62,15 +68,14 @@ function PublicRounting(): VNode { // const [notification, notify, handleError] = useLocalNotification(); const session = useOfficer(); - if (location === undefined) { - if (session.state !== "ready") { - return <HandleAccountNotReady officer={session}/>; - } else { - return <div /> - } - } - switch (location.name) { + case undefined: { + if (session.state !== "ready") { + return <HandleAccountNotReady officer={session} />; + } else { + return <div />; + } + } case "config": { return ( <Fragment> @@ -95,8 +100,10 @@ function PublicRounting(): VNode { } export const privatePages = { - account: urlPattern(/\/account/, () => "#/account"), - cases: urlPattern(/\/cases/, () => "#/cases"), + profile: urlPattern(/\/profile/, () => "#/profile"), + search: urlPattern(/\/search/, () => "#/search"), + investigation: urlPattern(/\/investigation/, () => "#/investigation"), + active: urlPattern(/\/active/, () => "#/active"), caseUpdate: urlPattern<{ cid: string; type: string }>( /\/case\/(?<cid>[a-zA-Z0-9]+)\/new\/(?<type>[a-zA-Z0-9_.]+)/, ({ cid, type }) => `#/case/${cid}/new/${type}`, @@ -105,6 +112,10 @@ export const privatePages = { /\/case\/(?<cid>[a-zA-Z0-9]+)\/new/, ({ cid }) => `#/case/${cid}/new`, ), + caseDetailsNewAccount: urlPattern<{ cid: string; payto: string }>( + /\/case\/(?<cid>[a-zA-Z0-9]+)\/(?<payto>[a-zA-Z0-9]+)/, + ({ cid, payto }) => `#/case/${cid}/${payto}`, + ), caseDetails: urlPattern<{ cid: string }>( /\/case\/(?<cid>[a-zA-Z0-9]+)/, ({ cid }) => `#/case/${cid}`, @@ -115,36 +126,46 @@ function PrivateRouting(): VNode { const { navigateTo } = useNavigationContext(); const location = useCurrentLocation(privatePages); useEffect(() => { - if (location === undefined) { - navigateTo(privatePages.account.url({})); + if (location.name === undefined) { + navigateTo(privatePages.profile.url({})); } }, [location]); - if (location === undefined) { - return <Fragment />; - } - switch (location.name) { - case "account": { + case undefined: { + return <Fragment />; + } + case "profile": { return <Officer />; } + case "caseUpdate": { + return ( + <CaseUpdate account={location.values.cid} type={location.values.type} /> + ); + } case "caseDetails": { return <CaseDetails account={location.values.cid} />; } - case "caseUpdate": { + case "caseDetailsNewAccount": { return ( - <CaseUpdate + <CaseDetails account={location.values.cid} - type={location.values.type} + paytoString={decodeCrockFromURI(location.values.payto)} /> ); } case "caseNew": { return <SelectForm account={location.values.cid} />; } - case "cases": { + case "investigation": { + return <CasesUnderInvestigation />; + } + case "active": { return <Cases />; } + case "search": { + return <Search />; + } default: assertUnreachable(location); } diff --git a/packages/aml-backoffice-ui/src/components/ErrorLoadingWithDebug.tsx b/packages/aml-backoffice-ui/src/components/ErrorLoadingWithDebug.tsx new file mode 100644 index 000000000..8679af050 --- /dev/null +++ b/packages/aml-backoffice-ui/src/components/ErrorLoadingWithDebug.tsx @@ -0,0 +1,24 @@ +/* + 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 { TalerError } from "@gnu-taler/taler-util"; +import { ErrorLoading } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { usePreferences } from "../hooks/preferences.js"; + +export function ErrorLoadingWithDebug({ error }: { error: TalerError }): VNode { + const [pref] = usePreferences(); + return <ErrorLoading error={error} showDetail={pref.showDebugInfo} />; +} diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts index 4cd781b74..215b0ba51 100644 --- a/packages/aml-backoffice-ui/src/forms/simplest.ts +++ b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -29,8 +29,7 @@ export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({ fields: [ { type: "textArea", - id: ".comment" as UIHandlerId, - name: "comment", + id: "comment" as UIHandlerId, label: i18n.str`Comment`, }, ], @@ -59,8 +58,7 @@ export function resolutionSection( fields: [ { type: "choiceHorizontal", - id: ".state" as UIHandlerId, - name: "state", + id: "state" as UIHandlerId, label: i18n.str`New state`, converterId: "TalerExchangeApi.AmlState", choices: [ @@ -80,9 +78,8 @@ export function resolutionSection( }, { type: "amount", - id: ".threshold" as UIHandlerId, + id: "threshold" as UIHandlerId, currency: "NETZBON", - name: "threshold", converterId: "Taler.Amount", label: i18n.str`New threshold`, }, diff --git a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts b/packages/aml-backoffice-ui/src/hooks/account.ts index 78574ada4..dbc8fd79f 100644 --- a/packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts +++ b/packages/aml-backoffice-ui/src/hooks/account.ts @@ -13,25 +13,43 @@ 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 { OfficerAccount, PaytoString, TalerExchangeResultByMethod, TalerHttpError } from "@gnu-taler/taler-util"; +import { + OfficerAccount, + PaytoString, + TalerExchangeResultByMethod, + TalerHttpError, +} from "@gnu-taler/taler-util"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook } from "swr"; -import { useOfficer } from "./officer.js"; import { useExchangeApiContext } from "@gnu-taler/web-util/browser"; +import _useSWR, { mutate, SWRHook } from "swr"; +import { useOfficer } from "./officer.js"; const useSWR = _useSWR as unknown as SWRHook; -export function useCaseDetails(paytoHash: string) { +export function revalidateAccountInformation() { + return mutate( + (key) => + Array.isArray(key) && + key[key.length - 1] === "getAmlAttributesForAccount", + undefined, + { revalidate: true }, + ); +} +export function useAccountInformation(paytoHash: string) { const officer = useOfficer(); const session = officer.state === "ready" ? officer.account : undefined; - const { lib: {exchange: api} } = useExchangeApiContext(); + const { + lib: { exchange: api }, + } = useExchangeApiContext(); async function fetcher([officer, account]: [OfficerAccount, PaytoString]) { - return await api.getDecisionDetails(officer, account) + return await api.getAmlAttributesForAccount(officer, account); } - const { data, error } = useSWR<TalerExchangeResultByMethod<"getDecisionDetails">, TalerHttpError>( - !session ? undefined : [session, paytoHash], fetcher, { + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getAmlAttributesForAccount">, + TalerHttpError + >(!session ? undefined : [session, paytoHash], fetcher, { refreshInterval: 0, refreshWhenHidden: false, revalidateOnFocus: false, @@ -47,5 +65,3 @@ export function useCaseDetails(paytoHash: string) { if (error) return error; return undefined; } - - diff --git a/packages/aml-backoffice-ui/src/hooks/useCases.ts b/packages/aml-backoffice-ui/src/hooks/decisions.ts index d3a1c1018..24941b29e 100644 --- a/packages/aml-backoffice-ui/src/hooks/useCases.ts +++ b/packages/aml-backoffice-ui/src/hooks/decisions.ts @@ -19,13 +19,12 @@ import { useState } from "preact/hooks"; import { OfficerAccount, OperationOk, - TalerExchangeApi, TalerExchangeResultByMethod, TalerHttpError, } from "@gnu-taler/taler-util"; -import _useSWR, { SWRHook } from "swr"; -import { useOfficer } from "./officer.js"; import { useExchangeApiContext } from "@gnu-taler/web-util/browser"; +import _useSWR, { SWRHook, mutate } from "swr"; +import { useOfficer } from "./officer.js"; const useSWR = _useSWR as unknown as SWRHook; export const PAGINATED_LIST_SIZE = 10; @@ -34,12 +33,111 @@ export const PAGINATED_LIST_SIZE = 10; export const PAGINATED_LIST_REQUEST = PAGINATED_LIST_SIZE + 1; /** - * FIXME: mutate result when balance change (transaction ) * @param account * @param args * @returns */ -export function useCases(state: TalerExchangeApi.AmlState) { +export function useCurrentDecisionsUnderInvestigation() { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const [offset, setOffset] = useState<string>(); + + async function fetcher([officer, offset, investigation]: [ + OfficerAccount, + string | undefined, + boolean | undefined, + ]) { + return await api.getAmlDecisions(officer, { + order: "dec", + offset, + investigation: true, + active: true, + limit: PAGINATED_LIST_REQUEST, + }); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getAmlDecisions">, + TalerHttpError + >( + !session + ? undefined + : [session, offset, "getAmlDecisions"], + fetcher, + ); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult(data.body.records, offset, setOffset, (d) => + String(d.rowid), + ); +} + +/** + * @param account + * @param args + * @returns + */ +export function useCurrentDecisions() { + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + + const [offset, setOffset] = useState<string>(); + + async function fetcher([officer, offset]: [ + OfficerAccount, + string | undefined, + boolean | undefined, + ]) { + return await api.getAmlDecisions(officer, { + order: "dec", + offset, + active: true, + limit: PAGINATED_LIST_REQUEST, + }); + } + + const { data, error } = useSWR< + TalerExchangeResultByMethod<"getAmlDecisions">, + TalerHttpError + >( + !session + ? undefined + : [session, offset, "getAmlDecisions"], + fetcher, + ); + + if (error) return error; + if (data === undefined) return undefined; + if (data.type !== "ok") return data; + + return buildPaginatedResult(data.body.records, offset, setOffset, (d) => + String(d.rowid), + ); +} + +export function revalidateAccountDecisions() { + return mutate( + (key) => Array.isArray(key) && key[key.length - 1] === "getAmlDecisions", + undefined, + { revalidate: true }, + ); +} +/** + * @param account + * @param args + * @returns + */ +export function useAccountDecisions(accountStr: string) { const officer = useOfficer(); const session = officer.state === "ready" ? officer.account : undefined; const { @@ -48,23 +146,24 @@ export function useCases(state: TalerExchangeApi.AmlState) { const [offset, setOffset] = useState<string>(); - async function fetcher([officer, state, offset]: [ + async function fetcher([officer, account, offset]: [ OfficerAccount, - TalerExchangeApi.AmlState, + string, string | undefined, ]) { - return await api.getDecisionsByState(officer, state, { - order: "asc", + return await api.getAmlDecisions(officer, { + order: "dec", offset, + account, limit: PAGINATED_LIST_REQUEST, }); } const { data, error } = useSWR< - TalerExchangeResultByMethod<"getDecisionsByState">, + TalerExchangeResultByMethod<"getAmlDecisions">, TalerHttpError >( - !session ? undefined : [session, state, offset, "getDecisionsByState"], + !session ? undefined : [session, accountStr, offset, "getAmlDecisions"], fetcher, ); diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts index 70b2db571..375dbb190 100644 --- a/packages/aml-backoffice-ui/src/hooks/form.ts +++ b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -126,14 +126,14 @@ export function useFormState<T>( shape: Array<UIHandlerId>, defaultValue: RecursivePartial<FormValues<T>>, check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, -): [FormHandler<T>, FormStatus<T>] { +): { handler: FormHandler<T>; status: FormStatus<T> } { const [form, updateForm] = useState<RecursivePartial<FormValues<T>>>(defaultValue); const status = check(form); const handler = constructFormHandler(shape, form, updateForm, status.errors); - return [handler, status]; + return { handler, status }; } interface Tree<T> extends Record<string, Tree<T> | T> {} @@ -163,7 +163,10 @@ export function setValueDeeper(object: any, names: string[], value: any): any { if (object === undefined) { return undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); } - return undefinedIfEmpty({ ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }); + return undefinedIfEmpty({ + ...object, + [head]: setValueDeeper(object[head] ?? {}, rest, value), + }); } export function getShapeFromFields( @@ -179,10 +182,7 @@ export function getShapeFromFields( } shape.push(field.id); } else if (field.type === "group") { - Array.prototype.push.apply( - shape, - getShapeFromFields(field.fields), - ); + Array.prototype.push.apply(shape, getShapeFromFields(field.fields)); } }); return shape; @@ -204,10 +204,7 @@ export function getRequiredFields( } shape.push(field.id); } else if (field.type === "group") { - Array.prototype.push.apply( - shape, - getRequiredFields(field.fields), - ); + Array.prototype.push.apply(shape, getRequiredFields(field.fields)); } }); return shape; diff --git a/packages/aml-backoffice-ui/src/hooks/preferences.ts b/packages/aml-backoffice-ui/src/hooks/preferences.ts index 12e85d249..d329cdbb2 100644 --- a/packages/aml-backoffice-ui/src/hooks/preferences.ts +++ b/packages/aml-backoffice-ui/src/hooks/preferences.ts @@ -27,6 +27,7 @@ import { } from "@gnu-taler/web-util/browser"; interface Preferences { + showDebugInfo: boolean; allowInsecurePassword: boolean; keepSessionAfterReload: boolean; } @@ -34,16 +35,18 @@ interface Preferences { export const codecForPreferences = (): Codec<Preferences> => buildCodecForObject<Preferences>() .property("allowInsecurePassword", (codecForBoolean())) + .property("showDebugInfo", codecForBoolean()) .property("keepSessionAfterReload", (codecForBoolean())) .build("Preferences"); const defaultPreferences: Preferences = { allowInsecurePassword: false, + showDebugInfo: false, keepSessionAfterReload: false, }; const PREFERENCES_KEY = buildStorageKey( - "exchange-preferences", + "aml-preferences", codecForPreferences(), ); /** @@ -69,6 +72,7 @@ export function usePreferences(): [ export function getAllBooleanPreferences(): Array<keyof Preferences> { return [ + "showDebugInfo", "allowInsecurePassword", "keepSessionAfterReload", ]; @@ -79,6 +83,7 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { + case "showDebugInfo": return i18n.str`Show debug info` case "allowInsecurePassword": return i18n.str`Allow Insecure password` case "keepSessionAfterReload": return i18n.str`Keep session after reload` } diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx index bb936cebf..d15b088a1 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -15,12 +15,16 @@ */ import { AbsoluteTime, + AmlDecisionRequest, AmountJson, Amounts, Codec, + CurrencySpecification, HttpStatusCode, + LegitimizationRuleSet, OperationFail, OperationOk, + PaytoString, TalerError, TalerErrorDetail, TalerExchangeApi, @@ -32,21 +36,37 @@ import { codecOptional, } from "@gnu-taler/taler-util"; import { + Attention, + Button, + convertUiField, DefaultForm, - ErrorLoading, + FormConfiguration, FormMetadata, + getConverterById, InternationalizationAPI, Loading, + LocalNotificationBanner, + RenderAllFieldsByUiConfig, + ShowInputErrorLabel, + Time, + UIFormElementConfig, + UIHandlerId, + useExchangeApiContext, + useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; -import { format } from "date-fns"; -import { VNode, h } from "preact"; +import { format, formatDuration, intervalToDuration } from "date-fns"; +import { Fragment, Ref, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { privatePages } from "../Routing.js"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; -import { useCaseDetails } from "../hooks/useCaseDetails.js"; +import { useAccountInformation } from "../hooks/account.js"; +import { useAccountDecisions } from "../hooks/decisions.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; +import { useOfficer } from "../hooks/officer.js"; +import { getShapeFromFields, useFormState } from "../hooks/form.js"; +import { privatePages } from "../Routing.js"; export type AmlEvent = | AmlFormEvent @@ -77,7 +97,7 @@ type KycCollectionEvent = { when: AbsoluteTime; title: TranslatedString; values: object; - provider: string; + provider?: string; }; type KycExpirationEvent = { type: "kyc-expiration"; @@ -115,68 +135,90 @@ function titleForJustification( } export function getEventsFromAmlHistory( - aml: TalerExchangeApi.AmlDecisionDetail[], - kyc: TalerExchangeApi.KycDetail[], + events: TalerExchangeApi.KycAttributeCollectionEvent[], i18n: InternationalizationAPI, forms: FormMetadata[], ): AmlEvent[] { - const ae: AmlEvent[] = aml.map((a) => { - const just = parseJustification(a.justification, forms); + // const ae: AmlEvent[] = aml.map((a) => { + // const just = parseJustification(a.justification, forms); + // return { + // type: just.type === "ok" ? "aml-form" : "aml-form-error", + // state: a.new_state, + // threshold: Amounts.parseOrThrow(a.new_threshold), + // title: titleForJustification(just, i18n), + // metadata: just.type === "ok" ? just.body.metadata : undefined, + // justification: just.type === "ok" ? just.body.justification : undefined, + // when: { + // t_ms: + // a.decision_time.t_s === "never" + // ? "never" + // : a.decision_time.t_s * 1000, + // }, + // } as AmlEvent; + // }); + // const ke = kyc.reduce((prev, k) => { + // prev.push({ + // type: "kyc-collection", + // title: i18n.str`collection`, + // when: AbsoluteTime.fromProtocolTimestamp(k.collection_time), + // values: !k.attributes ? {} : k.attributes, + // provider: k.provider_section, + // }); + // prev.push({ + // type: "kyc-expiration", + // title: i18n.str`expiration`, + // when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), + // fields: !k.attributes ? [] : Object.keys(k.attributes), + // }); + // return prev; + // }, [] as AmlEvent[]); + + const ke = events.map((event) => { return { - type: just.type === "ok" ? "aml-form" : "aml-form-error", - state: a.new_state, - threshold: Amounts.parseOrThrow(a.new_threshold), - title: titleForJustification(just, i18n), - metadata: just.type === "ok" ? just.body.metadata : undefined, - justification: just.type === "ok" ? just.body.justification : undefined, - when: { - t_ms: - a.decision_time.t_s === "never" - ? "never" - : a.decision_time.t_s * 1000, - }, - } as AmlEvent; - }); - const ke = kyc.reduce((prev, k) => { - prev.push({ type: "kyc-collection", title: i18n.str`collection`, - when: AbsoluteTime.fromProtocolTimestamp(k.collection_time), - values: !k.attributes ? {} : k.attributes, - provider: k.provider_section, - }); - prev.push({ - type: "kyc-expiration", - title: i18n.str`expiration`, - when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), - fields: !k.attributes ? [] : Object.keys(k.attributes), - }); - return prev; - }, [] as AmlEvent[]); - return ae.concat(ke).sort(selectSooner); + when: AbsoluteTime.fromProtocolTimestamp(event.collection_time), + values: !event.attributes ? {} : event.attributes, + provider: event.provider_name, + } as AmlEvent; + }); + return ke.sort(selectSooner); } -export function CaseDetails({ account }: { account: string }) { +type NewDecision = { + request: Omit<Omit<AmlDecisionRequest, "justification">, "officer_sig">; + askInformation: boolean; +}; + +export function CaseDetails({ + account, + paytoString, +}: { + account: string; + paytoString?: PaytoString; +}) { const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now()); - const [showForm, setShowForm] = useState<{ - justification: Justification; - metadata: FormMetadata; - }>(); + const [request, setDesicionRequest] = useState<NewDecision | undefined>( + undefined, + ); + const { config } = useExchangeApiContext(); const { i18n } = useTranslationContext(); - const details = useCaseDetails(account); + const details = useAccountInformation(account); + const history = useAccountDecisions(account); + const { forms } = useUiFormsContext(); const allForms = [...forms, ...preloadedForms(i18n)]; - if (!details) { + if (!details || !history) { return <Loading />; } if (details instanceof TalerError) { - return <ErrorLoading error={details} />; + return <ErrorLoadingWithDebug error={details} />; } if (details.type === "fail") { switch (details.case) { - case HttpStatusCode.Unauthorized: + // case HttpStatusCode.Unauthorized: case HttpStatusCode.Forbidden: case HttpStatusCode.NotFound: case HttpStatusCode.Conflict: @@ -185,44 +227,41 @@ export function CaseDetails({ account }: { account: string }) { assertUnreachable(details); } } - const { aml_history, kyc_attributes } = details.body; + if (history instanceof TalerError) { + return <ErrorLoadingWithDebug error={history} />; + } + if (history.type === "fail") { + switch (history.case) { + // case HttpStatusCode.Unauthorized: + case HttpStatusCode.Forbidden: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + return <div />; + default: + assertUnreachable(history); + } + } + const { details: accountDetails } = details.body; + const activeDecision = history.body.find((d) => d.is_active); + const restDecisions = !activeDecision + ? history.body + : history.body.filter((d) => d.rowid !== activeDecision.rowid); - const events = getEventsFromAmlHistory( - aml_history, - kyc_attributes, - i18n, - allForms, - ); + const events = getEventsFromAmlHistory(accountDetails, i18n, allForms); - if (showForm !== undefined) { + if (request) { return ( - <DefaultForm - readOnly={true} - initial={showForm.justification.value} - form={showForm.metadata as any} // FIXME: HERE - > - <div class="mt-6 flex items-center justify-end gap-x-6"> - <button - class="text-sm font-semibold leading-6 text-gray-900" - onClick={() => { - setShowForm(undefined); - }} - > - <i18n.Translate>Cancel</i18n.Translate> - </button> - </div> - </DefaultForm> + <SubmitNewDecision + decision={request} + onComplete={() => { + setDesicionRequest(undefined); + }} + /> ); } - return ( - <div> - <a - href={privatePages.caseNew.url({ cid: account })} - class="m-4 block 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" - > - <i18n.Translate>New AML form</i18n.Translate> - </a> + return ( + <div class="min-w-60"> <header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8"> <h1 class="text-base font-semibold leading-7 text-black"> <i18n.Translate> @@ -231,30 +270,629 @@ export function CaseDetails({ account }: { account: string }) { </i18n.Translate> </h1> </header> - <ShowTimeline - history={events} - onSelect={(e) => { - switch (e.type) { - case "aml-form": { - const { justification, metadata } = e; - setShowForm({ justification, metadata }); - break; + + {!activeDecision || !activeDecision.to_investigate ? undefined : ( + <Attention title={i18n.str`Under investigation`} type="warning"> + <i18n.Translate> + This account requires a manual review and is waiting for a decision + to be made. + </i18n.Translate> + </Attention> + )} + + <div> + <button + onClick={async () => { + setDesicionRequest({ + request: { + payto_uri: paytoString, + decision_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.now(), + ), + h_payto: account, + keep_investigating: false, + properties: {}, + new_rules: { + custom_measures: {}, + expiration_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.never(), + ), + rules: FREEZE_RULES(config.currency), + successor_measure: "verboten", + }, + }, + askInformation: false, + }); + }} + class="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" + > + <i18n.Translate>Freeze account</i18n.Translate> + </button> + <button + onClick={async () => { + setDesicionRequest({ + request: { + payto_uri: paytoString, + decision_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.now(), + ), + h_payto: account, + keep_investigating: false, + properties: {}, + new_rules: { + custom_measures: {}, + expiration_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.never(), + ), + rules: THRESHOLD_100_HOUR(config.currency), + successor_measure: "verboten", + }, + }, + askInformation: false, + }); + }} + class="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" + > + <i18n.Translate>Set threshold to 100 / hour</i18n.Translate> + </button> + <button + onClick={async () => { + setDesicionRequest({ + request: { + payto_uri: paytoString, + decision_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.now(), + ), + h_payto: account, + keep_investigating: false, + properties: {}, + new_rules: { + custom_measures: {}, + expiration_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.never(), + ), + rules: THRESHOLD_2000_WEEK(config.currency), + successor_measure: "verboten", + }, + }, + askInformation: false, + }); + }} + class="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" + > + <i18n.Translate>Set threshold to 2000 / week</i18n.Translate> + </button> + <button + onClick={async () => { + setDesicionRequest({ + request: { + payto_uri: paytoString, + decision_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.now(), + ), + h_payto: account, + keep_investigating: false, + properties: {}, + // the custom meaure with context + new_measures: "askMoreInfo", + new_rules: { + // this value is going to be overriden + custom_measures: {}, + expiration_time: AbsoluteTime.toProtocolTimestamp( + AbsoluteTime.never(), + ), + rules: FREEZE_RULES(config.currency), + }, + }, + askInformation: true, + }); + }} + class="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" + > + <i18n.Translate>Ask for more information</i18n.Translate> + </button> + </div> + + {!activeDecision ? ( + <Attention title={i18n.str`No active rules found`} type="warning" /> + ) : ( + <div class="my-4"> + <h1 class="mb-4 text-base font-semibold leading-6 text-black"> + <i18n.Translate>Current active rules</i18n.Translate> + </h1> + <ShowDecisionLimitInfo + since={AbsoluteTime.fromProtocolTimestamp( + activeDecision.decision_time, + )} + until={AbsoluteTime.fromProtocolTimestamp( + activeDecision.limits.expiration_time, + )} + justification={activeDecision.justification} + ruleSet={activeDecision.limits} + startOpen + /> + </div> + )} + <div class="px-4 sm:px-0"> + <h1 class="text-base font-semibold leading-6 text-black"> + <i18n.Translate>KYC collection events</i18n.Translate> + </h1> + <p class="mt-1 text-sm leading-6 text-gray-600"> + <i18n.Translate> + Every event when the user was asked information. + </i18n.Translate> + </p> + </div> + {events.length === 0 ? ( + <Attention title={i18n.str`The event list is empty`} type="warning" /> + ) : ( + <ShowTimeline + history={events} + onSelect={(e) => { + switch (e.type) { + case "aml-form": { + // const { justification, metadata } = e; + // setShowForm({ justification, metadata }); + break; + } + case "kyc-collection": + case "kyc-expiration": { + setSelected(e.when); + break; + } + case "aml-form-error": } - case "kyc-collection": - case "kyc-expiration": { - setSelected(e.when); - break; + }} + /> + )} + {/* {selected && <ShowEventDetails event={selected} />} */} + {selected && <ShowConsolidated history={events} until={selected} />} + {restDecisions.length > 0 ? ( + <div class="my-4 grid gap-y-4"> + <h1 class="text-base font-semibold leading-6 text-black"> + <i18n.Translate>Previous AML decisions</i18n.Translate> + </h1> + {restDecisions.map((d) => { + return ( + <ShowDecisionLimitInfo + since={AbsoluteTime.fromProtocolTimestamp(d.decision_time)} + until={AbsoluteTime.fromProtocolTimestamp( + d.limits.expiration_time, + )} + justification={d.justification} + ruleSet={d.limits} + /> + ); + })} + </div> + ) : !activeDecision ? ( + <div class="ty-4"> + <Attention title={i18n.str`No aml history found`} type="warning" /> + </div> + ) : undefined} + </div> + ); +} + +function SubmitNewDecision({ + decision, + onComplete, +}: { + onComplete: () => void; + decision: NewDecision; +}): VNode { + const { i18n } = useTranslationContext(); + const { lib } = useExchangeApiContext(); + const [notification, withErrorHandler] = useLocalNotificationHandler(); + + const formDesign: UIFormElementConfig[] = [ + { + id: "justification" as UIHandlerId, + type: "textArea", + required: true, + label: i18n.str`Justification`, + }, + ]; + + if (decision.askInformation) { + formDesign.push({ + type: "caption", + label: i18n.str`Form definition`, + help: i18n.str`The user will need to complete this form.`, + }); + formDesign.push({ + id: "fields" as UIHandlerId, + type: "array", + required: true, + label: i18n.str`Fields`, + fields: [ + { + id: "name" as UIHandlerId, + type: "text", + required: true, + label: i18n.str`Name`, + help: i18n.str`Name of the field in the form`, + }, + { + id: "type" as UIHandlerId, + type: "choiceStacked", + required: true, + label: i18n.str`Type`, + help: i18n.str`Type of information being asked`, + choices: [ + { + value: "integer", + label: i18n.str`Number`, + description: i18n.str`Numeric information`, + }, + { + value: "text", + label: i18n.str`Text`, + description: i18n.str`Free form text input`, + }, + ], + }, + ], + labelFieldId: "name" as UIHandlerId, + }); + } + const officer = useOfficer(); + const session = officer.state === "ready" ? officer.account : undefined; + const decisionForm = useFormState<{ justification: string; fields: object }>( + getShapeFromFields(formDesign), + { justification: "" }, + (d) => { + d.justification; + return { + status: "ok", + errors: undefined, + result: d as any, + }; + }, + ); + + const customFields = decisionForm.status.result.fields as [ + { name: string; type: string }, + ]; + + const customForm: FormConfiguration | undefined = !decisionForm.status.result + .fields + ? undefined + : { + type: "double-column", + design: [ + { + fields: customFields.map((f) => { + return { + id: f.name, + label: f.name, + type: f.type, + } as UIFormElementConfig; + }), + title: "Required information", + }, + ], + }; + + const submitHandler = + decisionForm === undefined || !session || customForm === undefined + ? undefined + : withErrorHandler( + () => { + const request: Omit<AmlDecisionRequest, "officer_sig"> = { + ...decision.request, + properties: { + ...decision.request.properties, + fields: decisionForm.status.result.fields, + }, + justification: + decisionForm.status.result.justification ?? "empty", + new_rules: { + ...decision.request.new_rules, + custom_measures: { + ...decision.request.new_rules.custom_measures, + askMoreInfo: { + context: { + form: customForm, + }, + // check of type form, it will use the officer defined form + check_name: "askContext", + // after that, mark as investigate to read what the user sent + prog_name: "markInvestigate", + }, + }, + }, + }; + return lib.exchange.makeAmlDesicion(session, request); + }, + onComplete, + (fail) => { + switch (fail.case) { + case HttpStatusCode.Forbidden: + if (session) { + return i18n.str`Wrong credentials for "${session}"`; + } else { + return i18n.str`Wrong credentials.`; + } + case HttpStatusCode.NotFound: + return i18n.str`The account was not found`; + case HttpStatusCode.Conflict: + return i18n.str`Officer disabled or more recent decision was already submitted.`; + default: + assertUnreachable(fail); } - case "aml-form-error": - } + }, + ); + + return ( + <div> + <LocalNotificationBanner notification={notification} /> + <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> + <i18n.Translate>Submit decision</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, + formDesign, + decisionForm.handler, + getConverterById, + )} + /> + </div> + + <div class="mt-6 flex items-center justify-end gap-x-6"> + <button + onClick={onComplete} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + + <Button + type="submit" + handler={submitHandler} + disabled={!submitHandler} + class="disabled:opacity-50 disabled:cursor-default rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Confirm</i18n.Translate> + </Button> + </div> + </form> + + <h1 class="my-2 text-xl font-bold tracking-tight text-gray-900 "> + <i18n.Translate>New rules to submit</i18n.Translate> + </h1> + + <ShowDecisionLimitInfo + since={AbsoluteTime.fromProtocolTimestamp( + decision.request.decision_time, + )} + until={AbsoluteTime.fromProtocolTimestamp( + decision.request.new_rules.expiration_time, + )} + ruleSet={decision.request.new_rules} + startOpen /> - {/* {selected && <ShowEventDetails event={selected} />} */} - {selected && <ShowConsolidated history={events} until={selected} />} </div> ); } +function ShowDecisionLimitInfo({ + ruleSet, + since, + until, + startOpen, + justification, +}: { + since: AbsoluteTime; + until: AbsoluteTime; + justification?: string; + ruleSet: LegitimizationRuleSet; + startOpen?: boolean; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useExchangeApiContext(); + const [opened, setOpened] = useState(startOpen ?? false); + + function Header() { + return ( + <div + class="p-4 relative bg-gray-50 flex justify-between cursor-pointer" + onClick={() => setOpened((o) => !o)} + > + <div class="flex min-w-0 gap-x-4"> + <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="pointer-events-none bg-gray-200 inset-y-0 flex items-center px-3"> + <i18n.Translate>Since</i18n.Translate> + </div> + <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> + <Time format="dd/MM/yyyy HH:mm:ss" timestamp={since} /> + </div> + </div> + </div> + <div class="flex shrink-0 items-center gap-x-4"> + <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="pointer-events-none bg-gray-200 inset-y-0 flex items-center px-3"> + {AbsoluteTime.isExpired(until) ? ( + <i18n.Translate>Expired</i18n.Translate> + ) : ( + <i18n.Translate>Expires</i18n.Translate> + )} + </div> + <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> + <Time format="dd/MM/yyyy HH:mm:ss" timestamp={until} /> + </div> + </div> + </div> + </div> + ); + } + + if (!opened) { + return ( + <div class="overflow-hidden ring-1 ring-gray-900/5 rounded-xl"> + <Header /> + </div> + ); + } + const balanceLimit = ruleSet.rules.find( + (r) => r.operation_type === "BALANCE", + ); + + return ( + <div class="overflow-hidden ring-1 ring-gray-900/5 rounded-xl"> + <Header /> + <div class="p-4 grid gap-y-4"> + {!justification ? undefined : ( + <div class=""> + <label + for="comment" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>AML officer justification</i18n.Translate> + </label> + <div class="mt-2"> + <textarea + rows={2} + readOnly + name="comment" + id="comment" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + > + {justification} + </textarea> + </div> + </div> + )} + + <div class=""> + <div class="flex mt-2 rounded-md w-fit shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="whitespace-nowrap pointer-events-none bg-gray-200 inset-y-0 items-center px-3 flex"> + <i18n.Translate>Max balance</i18n.Translate> + </div> + <div class="p-2 disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6"> + {!balanceLimit ? ( + <i18n.Translate>Unlimited</i18n.Translate> + ) : ( + <RenderAmount + value={Amounts.parseOrThrow(balanceLimit.threshold)} + spec={config.currency_specification} + /> + )} + </div> + </div> + </div> + + {!ruleSet.rules.length ? ( + <Attention + title={i18n.str`There are no rules for operations`} + type="warning" + /> + ) : ( + <div class=""> + <table class="min-w-full divide-y divide-gray-300"> + <thead class="bg-gray-50"> + <tr> + <th + scope="col" + class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-6" + > + <i18n.Translate>Operation</i18n.Translate> + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + <i18n.Translate>Timeframe</i18n.Translate> + </th> + <th + scope="col" + class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right" + > + <i18n.Translate>Amount</i18n.Translate> + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200"> + {ruleSet.rules.map((r) => { + if (r.operation_type === "BALANCE") return; + return ( + <tr> + <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left"> + {r.operation_type} + </td> + <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + {r.timeframe.d_us === "forever" ? ( + <i18n.Translate>Forever</i18n.Translate> + ) : ( + formatDuration( + intervalToDuration({ + start: 0, + end: r.timeframe.d_us / 1000, + }), + ) + )} + </td> + <td class=" relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6 text-right"> + <RenderAmount + value={Amounts.parseOrThrow(r.threshold)} + spec={config.currency_specification} + /> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + )} + </div> + </div> + ); +} + +export function RenderAmount({ + value, + spec, + negative, + withColor, + hideSmall, +}: { + spec: CurrencySpecification; + value: AmountJson; + hideSmall?: boolean; + negative?: boolean; + withColor?: boolean; +}): VNode { + const neg = !!negative; // convert to true or false + + const { currency, normal, small } = Amounts.stringifyValueWithSpec( + value, + spec, + ); + + return ( + <span + data-negative={withColor ? neg : undefined} + class="whitespace-nowrap data-[negative=false]:text-green-600 data-[negative=true]:text-red-600" + > + {negative ? "- " : undefined} + {currency} {normal}{" "} + {!hideSmall && small && <sup class="-ml-1">{small}</sup>} + </span> + ); +} + function AmlStateBadge({ state }: { state: TalerExchangeApi.AmlState }): VNode { switch (state) { case TalerExchangeApi.AmlState.normal: { @@ -382,7 +1020,7 @@ function ShowTimeline({ "never" ) : ( <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}> - {format(e.when.t_ms, "dd MMM yyyy")} + {format(e.when.t_ms, "dd MMM yyyy HH:mm:ss")} </time> )} </div> @@ -397,6 +1035,66 @@ function ShowTimeline({ ); } +function InputAmount( + { + currency, + name, + value, + left, + onChange, + }: { + currency: string; + name: string; + left?: boolean | undefined; + value: string | undefined; + onChange?: (s: string) => void; + }, + ref: Ref<HTMLInputElement>, +): VNode { + const FRAC_SEPARATOR = ","; + const { config } = useExchangeApiContext(); + return ( + <div class="mt-2"> + <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"> + <div class="pointer-events-none inset-y-0 flex items-center px-3"> + <span class="text-gray-500 sm:text-sm">{currency}</span> + </div> + <input + type="number" + data-left={left} + class="disabled:bg-gray-200 text-right rounded-md rounded-l-none data-[left=true]:text-left w-full py-1.5 pl-3 text-gray-900 placeholder:text-gray-400 sm:text-sm sm:leading-6" + placeholder="0.00" + aria-describedby="price-currency" + ref={ref} + name={name} + id={name} + autocomplete="off" + value={value ?? ""} + disabled={!onChange} + onInput={(e) => { + if (!onChange) return; + const l = e.currentTarget.value.length; + const sep_pos = e.currentTarget.value.indexOf(FRAC_SEPARATOR); + if ( + sep_pos !== -1 && + l - sep_pos - 1 > + config.currency_specification.num_fractional_input_digits + ) { + e.currentTarget.value = e.currentTarget.value.substring( + 0, + sep_pos + + config.currency_specification.num_fractional_input_digits + + 1, + ); + } + onChange(e.currentTarget.value); + }} + /> + </div> + </div> + ); +} + export type Justification<T = Record<string, unknown>> = { // form values value: T; @@ -470,3 +1168,216 @@ function parseJustification( }; } } + +const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = ( + currency, +) => [ + { + operation_type: "WITHDRAW", + threshold: `${currency}:2000`, + timeframe: { + d_us: 7 * 24 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "DEPOSIT", + threshold: `${currency}:2000`, + timeframe: { + d_us: 7 * 24 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "AGGREGATE", + threshold: `${currency}:2000`, + timeframe: { + d_us: 7 * 24 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "MERGE", + threshold: `${currency}:2000`, + timeframe: { + d_us: 7 * 24 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "BALANCE", + threshold: `${currency}:2000`, + timeframe: { + d_us: 7 * 24 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "CLOSE", + threshold: `${currency}:2000`, + timeframe: { + d_us: 7 * 24 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, +]; + +const THRESHOLD_100_HOUR: (currency: string) => TalerExchangeApi.KycRule[] = ( + currency, +) => [ + { + operation_type: "WITHDRAW", + threshold: `${currency}:100`, + timeframe: { + d_us: 1 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "DEPOSIT", + threshold: `${currency}:100`, + timeframe: { + d_us: 1 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "AGGREGATE", + threshold: `${currency}:100`, + timeframe: { + d_us: 1 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "MERGE", + threshold: `${currency}:100`, + timeframe: { + d_us: 1 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "BALANCE", + threshold: `${currency}:100`, + timeframe: { + d_us: 1 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "CLOSE", + threshold: `${currency}:100`, + timeframe: { + d_us: 1 * 60 * 60 * 1000 * 1000, + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, +]; + +const FREEZE_RULES: (currency: string) => TalerExchangeApi.KycRule[] = ( + currency, +) => [ + { + operation_type: "WITHDRAW", + threshold: `${currency}:0`, + timeframe: { + d_us: "forever", + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "DEPOSIT", + threshold: `${currency}:0`, + timeframe: { + d_us: "forever", + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "AGGREGATE", + threshold: `${currency}:0`, + timeframe: { + d_us: "forever", + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "MERGE", + threshold: `${currency}:0`, + timeframe: { + d_us: "forever", + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "BALANCE", + threshold: `${currency}:0`, + timeframe: { + d_us: "forever", + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, + { + operation_type: "CLOSE", + threshold: `${currency}:0`, + timeframe: { + d_us: "forever", + }, + measures: ["verboten"], + display_priority: 1, + exposed: true, + is_and_combinator: true, + }, +]; diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx index 7801625d0..87f1aed5f 100644 --- a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx +++ b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -117,7 +117,7 @@ export function CaseUpdate({ ); }); - const [form, state] = useFormState<FormType>(shape, initial, (st) => { + const { handler, status } = useFormState<FormType>(shape, initial, (st) => { const partialErrors = undefinedIfEmpty<FormErrors<FormType>>({ state: st.state === undefined ? i18n.str`required` : undefined, threshold: !st.threshold ? i18n.str`required` : undefined, @@ -143,7 +143,7 @@ export function CaseUpdate({ }; }); - const validatedForm = state.status !== "ok" ? undefined : state.result; + const validatedForm = status.status !== "ok" ? undefined : status.result; const submitHandler = validatedForm === undefined @@ -157,31 +157,37 @@ export function CaseUpdate({ value: validatedForm, }; - const decision: Omit<TalerExchangeApi.AmlDecision, "officer_sig"> = - { - justification: JSON.stringify(justification), - decision_time: TalerProtocolTimestamp.now(), - h_payto: account, - new_state: justification.value - .state as TalerExchangeApi.AmlState, - new_threshold: Amounts.stringify( - justification.value.threshold as AmountJson, - ), - kyc_requirements: undefined, - }; + const decision: Omit< + TalerExchangeApi.AmlDecisionRequest, + "officer_sig" + > = { + justification: JSON.stringify(justification), + decision_time: TalerProtocolTimestamp.now(), + h_payto: account, + keep_investigating: false, + new_rules: { + custom_measures: {}, + expiration_time: { + t_s: "never", + }, + rules: [], + successor_measure: undefined, + }, + properties: {}, + new_measures: undefined, + }; - return api.addDecisionDetails(officer.account, decision); + return api.makeAmlDesicion(officer.account, decision); }, () => { - window.location.href = privatePages.cases.url({}); + window.location.href = privatePages.profile.url({}); }, (fail) => { switch (fail.case) { case HttpStatusCode.Forbidden: - case HttpStatusCode.Unauthorized: return i18n.str`Wrong credentials for "${officer.account}"`; case HttpStatusCode.NotFound: - return i18n.str`Officer or account not found`; + return i18n.str`The account was not found`; case HttpStatusCode.Conflict: return i18n.str`Officer disabled or more recent decision was already submitted.`; default: @@ -218,7 +224,7 @@ export function CaseUpdate({ fields={convertUiField( i18n, section.fields, - form, + handler, getConverterById, )} /> diff --git a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx index 22a6d1867..372fb912f 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx @@ -21,21 +21,33 @@ import * as tests from "@gnu-taler/web-util/testing"; import { CasesUI as TestedComponent } from "./Cases.js"; -import { AmountString, TalerExchangeApi } from "@gnu-taler/taler-util"; export default { title: "cases", }; export const OneRow = tests.createExample(TestedComponent, { - filter: TalerExchangeApi.AmlState.normal, - onChangeFilter: () => null, records: [ { - current_state: TalerExchangeApi.AmlState.normal, + // current_state: TalerExchangeApi.AmlState.normal, h_payto: "QWEQWEQWEQWE", rowid: 1, - threshold: "USD:1" as AmountString, + decision_time: { + t_s: "never" + }, + is_active: false, + limits: { + custom_measures: {}, + expiration_time: { + t_s: "never" + }, + rules: [], + successor_measure: undefined, + }, + to_investigate: false, + justification: undefined, + properties: undefined, + // threshold: "USD:1" as AmountString, }, ], }); diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx index f66eca33f..278d4bac2 100644 --- a/packages/aml-backoffice-ui/src/pages/Cases.tsx +++ b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -21,111 +21,98 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ErrorLoading, - InputChoiceHorizontal, Loading, - UIHandlerId, - amlStateConverter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { useCases } from "../hooks/useCases.js"; +import { + useCurrentDecisions, + useCurrentDecisionsUnderInvestigation, +} from "../hooks/decisions.js"; import { privatePages } from "../Routing.js"; -import { FormErrors, RecursivePartial, useFormState } from "../hooks/form.js"; -import { undefinedIfEmpty } from "./CreateAccount.js"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { Officer } from "./Officer.js"; type FormType = { - state: TalerExchangeApi.AmlState; + // state: TalerExchangeApi.AmlState; }; export function CasesUI({ records, - filter, - onChangeFilter, onFirstPage, onNext, + filtered, }: { + filtered: boolean; onFirstPage?: () => void; onNext?: () => void; - filter: TalerExchangeApi.AmlState; - onChangeFilter: (f: TalerExchangeApi.AmlState) => void; - records: TalerExchangeApi.AmlRecord[]; + records: TalerExchangeApi.AmlDecision[]; }): VNode { const { i18n } = useTranslationContext(); - const [form, status] = useFormState<FormType>( - [".state"] as Array<UIHandlerId>, - { - state: filter, - }, - (state) => { - const errors = undefinedIfEmpty<FormErrors<FormType>>({ - state: state.state === undefined ? i18n.str`required` : undefined, - }); - if (errors === undefined) { - const result: FormType = { - state: state.state!, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<FormType> = { - state: state.state, - }; - return { - status: "fail", - result, - errors, - }; - }, - ); - useEffect(() => { - if (status.status === "ok" && filter !== status.result.state) { - onChangeFilter(status.result.state); - } - }, [form?.state?.value]); + // const [form, status] = useFormState<FormType>( + // [".state"] as Array<UIHandlerId>, + // { + // // state: filter, + // }, + // (state) => { + // const errors = undefinedIfEmpty<FormErrors<FormType>>({ + // state: state.state === undefined ? i18n.str`required` : undefined, + // }); + // if (errors === undefined) { + // const result: FormType = { + // state: state.state!, + // }; + // return { + // status: "ok", + // result, + // errors, + // }; + // } + // const result: RecursivePartial<FormType> = { + // state: state.state, + // }; + // return { + // status: "fail", + // result, + // errors, + // }; + // }, + // ); + // useEffect(() => { + // if (status.status === "ok" && filter !== status.result.state) { + // onChangeFilter(status.result.state); + // } + // }, [form?.state?.value]); return ( <div> <div class="sm:flex sm:items-center"> - <div class="px-2 sm:flex-auto"> - <h1 class="text-base font-semibold leading-6 text-gray-900"> - <i18n.Translate>Cases</i18n.Translate> - </h1> - <p class="mt-2 text-sm text-gray-700 w-80"> - <i18n.Translate> - A list of all the account with the status - </i18n.Translate> - </p> - </div> - <div class="px-2"> - <InputChoiceHorizontal<FormType, "state"> - name="state" - label={i18n.str`Filter`} - handler={form.state} - converter={amlStateConverter} - choices={[ - { - label: i18n.str`Pending`, - value: "pending", - }, - { - label: i18n.str`Frozen`, - value: "frozen", - }, - { - label: i18n.str`Normal`, - value: "normal", - }, - ]} - /> - </div> + {filtered ? ( + <div class="px-2 sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Cases under investigation</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-700 w-80"> + <i18n.Translate> + A list of all the accounts which are waiting for a deicison to + be made. + </i18n.Translate> + </p> + </div> + ) : ( + <div class="px-2 sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Cases</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-700 w-80"> + <i18n.Translate> + A list of all the known account by the exchange. + </i18n.Translate> + </p> + </div> + )} </div> <div class="mt-8 flow-root"> <div class="overflow-x-auto"> @@ -148,12 +135,6 @@ export function CasesUI({ > <i18n.Translate>Status</i18n.Translate> </th> - <th - scope="col" - class="sm:hidden px-3 py-3.5 text-left text-sm font-semibold text-gray-900 w-40" - > - <i18n.Translate>Threshold</i18n.Translate> - </th> </tr> </thead> <tbody class="divide-y divide-gray-200 bg-white"> @@ -172,35 +153,12 @@ export function CasesUI({ </a> </div> </td> - <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> - {((state: TalerExchangeApi.AmlState): VNode => { - switch (state) { - case TalerExchangeApi.AmlState.normal: { - return ( - <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> - Normal - </span> - ); - } - case TalerExchangeApi.AmlState.pending: { - return ( - <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> - Pending - </span> - ); - } - case TalerExchangeApi.AmlState.frozen: { - return ( - <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> - Frozen - </span> - ); - } - } - })(r.current_state)} - </td> <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900"> - {r.threshold} + {r.to_investigate ? ( + <span title="require investigation"> + <ToInvestigateIcon /> + </span> + ) : undefined} </td> </tr> ); @@ -217,18 +175,14 @@ export function CasesUI({ } export function Cases() { - const [stateFilter, setStateFilter] = useState( - TalerExchangeApi.AmlState.pending, - ); - - const list = useCases(stateFilter); + const list = useCurrentDecisions(); const { i18n } = useTranslationContext(); if (!list) { return <Loading />; } if (list instanceof TalerError) { - return <ErrorLoading error={list} />; + return <ErrorLoadingWithDebug error={list} />; } if (list.type === "fail") { @@ -238,28 +192,107 @@ export function Cases() { <Fragment> <Attention type="danger" title={i18n.str`Operation denied`}> <i18n.Translate> - This account doesn't have access. Request account activation - sending your public key. + This account signature is wrong, contact administrator or create + a new one. </i18n.Translate> </Attention> <Officer /> </Fragment> ); } - case HttpStatusCode.Unauthorized: { + case HttpStatusCode.NotFound: { + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate>This account is not known.</i18n.Translate> + </Attention> + <Officer /> + </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> + <Officer /> + </Fragment> + ); + } + return <Officer />; + default: + assertUnreachable(list); + } + } + + return ( + <CasesUI + filtered={false} + records={list.body} + onFirstPage={list.isFirstPage ? undefined : list.loadFirst} + onNext={list.isLastPage ? undefined : list.loadNext} + // filter={stateFilter} + // onChangeFilter={(d) => { + // setStateFilter(d); + // }} + /> + ); +} +export function CasesUnderInvestigation() { + const list = useCurrentDecisionsUnderInvestigation(); + const { i18n } = useTranslationContext(); + + if (!list) { + return <Loading />; + } + if (list instanceof TalerError) { + return <ErrorLoadingWithDebug error={list} />; + } + + if (list.type === "fail") { + switch (list.case) { + case HttpStatusCode.Forbidden: { return ( <Fragment> <Attention type="danger" title={i18n.str`Operation denied`}> <i18n.Translate> - This account is not allowed to perform list the cases. + This account signature is wrong, contact administrator or create + a new one. </i18n.Translate> </Attention> <Officer /> </Fragment> ); } - case HttpStatusCode.NotFound: + case HttpStatusCode.NotFound: { + return ( + <Fragment> + <Attention type="danger" title={i18n.str`Operation denied`}> + <i18n.Translate>This account is not known.</i18n.Translate> + </Attention> + <Officer /> + </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> + <Officer /> + </Fragment> + ); + } return <Officer />; default: assertUnreachable(list); @@ -268,17 +301,41 @@ export function Cases() { return ( <CasesUI + filtered={true} records={list.body} onFirstPage={list.isFirstPage ? undefined : list.loadFirst} onNext={list.isLastPage ? undefined : list.loadNext} - filter={stateFilter} - onChangeFilter={(d) => { - setStateFilter(d); - }} + // filter={stateFilter} + // onChangeFilter={(d) => { + // setStateFilter(d); + // }} /> ); } +// function ToInvestigateIcon(): VNode { +// return <svg title="requires investigation" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 w-6"> +// <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /> +// </svg> +// } +export const ToInvestigateIcon = () => ( + <svg + title="requires investigation" + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-6 w-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" + /> + </svg> +); + export const PeopleIcon = () => ( <svg xmlns="http://www.w3.org/2000/svg" @@ -313,7 +370,24 @@ export const HomeIcon = () => ( </svg> ); -function Pagination({ +export const SearchIcon = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" + /> + </svg> +); + +export function Pagination({ onFirstPage, onNext, }: { diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx index 87310aa27..328d8459b 100644 --- a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -89,7 +89,9 @@ function createFormValidator( }; } -export function undefinedIfEmpty<T extends object | undefined>(obj: T): T | undefined { +export function undefinedIfEmpty<T extends object | undefined>( + obj: T, +): T | undefined { if (obj === undefined) return undefined; return Object.keys(obj).some( (k) => (obj as Record<string, T>)[k] !== undefined, @@ -105,7 +107,7 @@ export function CreateAccount(): VNode { const [notification, withErrorHandler] = useLocalNotificationHandler(); - const [form, status] = useFormState<FormType>( + const { handler, status } = useFormState<FormType>( [".password", ".repeat"] as Array<UIHandlerId>, { password: undefined, @@ -118,7 +120,7 @@ export function CreateAccount(): VNode { status.status === "fail" || officer.state !== "not-found" ? undefined : withErrorHandler( - async () => officer.create(form.password!.value!), + async () => officer.create(handler.password!.value!), () => {}, ); return ( @@ -148,7 +150,7 @@ export function CreateAccount(): VNode { name="password" type="password" required - handler={form.password} + handler={handler.password} /> </div> @@ -158,7 +160,7 @@ export function CreateAccount(): VNode { name="repeat" type="password" required - handler={form.repeat} + handler={handler.repeat} /> </div> 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.`; +} diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx index 714bf6580..c104aaa3b 100644 --- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx +++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx @@ -21,7 +21,6 @@ import { AbsoluteTime, - AmountString, Duration, TranslatedString, } from "@gnu-taler/taler-util"; @@ -35,6 +34,9 @@ export default { }; const nullTranslator: InternationalizationAPI = { + ctx(ctx) { + return (str: TemplateStringsArray) => str.join() as TranslatedString; + }, str: (str: TemplateStringsArray) => str.join() as TranslatedString, singular: (str: TemplateStringsArray) => str.join() as TranslatedString, translate: (str: TemplateStringsArray) => [str.join()] as TranslatedString[], @@ -42,7 +44,7 @@ const nullTranslator: InternationalizationAPI = { }; export const WithEmptyHistory = tests.createExample(TestedComponent, { - history: getEventsFromAmlHistory([], [], nullTranslator, []), + history: getEventsFromAmlHistory([], nullTranslator, []), until: AbsoluteTime.now(), }); @@ -50,79 +52,17 @@ export const WithSomeEvents = tests.createExample(TestedComponent, { history: getEventsFromAmlHistory( [ { - decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", - justification: - '{"index":0,"name":"Simple comment","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700207199558},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"test"}}', - new_threshold: "STATER:0" as AmountString, - new_state: 1, - decision_time: { - t_s: 1700208199, - }, - }, - { - decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", - justification: - '{"index":0,"name":"Simple comment","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700207199558},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"test"}}', - new_threshold: "STATER:0" as AmountString, - new_state: 1, - decision_time: { - t_s: 1700208211, - }, - }, - { - decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", - justification: - '{"index":0,"name":"Simple comment","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700207199558},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"test"}}', - new_threshold: "STATER:0" as AmountString, - new_state: 1, - decision_time: { - t_s: 1700208220, - }, - }, - { - decider_pub: "JD70N2XZ8FZKB7C146ZWR6XBDCS4Z84PJKJMPB73PMJ2B1X35ZFG", - justification: - '{"index":4,"name":"Declaration for trusts (902.13e)","value":{"fullName":"loggedIn_user_fullname","when":{"t_ms":1700208362854},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"contractingPartner":"f","knownAs":"a","trust":{"name":"b","type":"discretionary","revocability":"irrevocable"}}}', - new_threshold: "STATER:0" as AmountString, - new_state: 1, - decision_time: { - t_s: 1700208385, - }, - }, - { - decider_pub: "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG", - justification: - '{"id":"simple_comment","label":"Simple comment","version":1,"value":{"when":{"t_ms":1700488420810},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"qwe"}}', - new_threshold: "STATER:0" as AmountString, - new_state: 1, - decision_time: { - t_s: 1700488423, - }, - }, - { - decider_pub: "6CD3J8XSKWQPFFDJY4SP4RK2D7T7WW7JRJDTXHNZY7YKGXDCE2QG", - justification: - '{"id":"simple_comment","label":"Simple comment","version":1,"value":{"when":{"t_ms":1700488671251},"state":1,"threshold":{"currency":"STATER","fraction":0,"value":0},"comment":"asd asd asd "}}', - new_threshold: "STATER:0" as AmountString, - new_state: 1, - decision_time: { - t_s: 1700488677, - }, - }, - ], - [ - { collection_time: AbsoluteTime.toProtocolTimestamp( AbsoluteTime.subtractDuraction( AbsoluteTime.now(), Duration.fromPrettyString("1d"), ), ), - expiration_time: { t_s: "never" }, - provider_section: "asd", + provider_name: "asd", attributes: { email: "sebasjm@qwdde.com", }, + rowid: 1, }, ], nullTranslator, diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx index cdc5d0bc1..fcec8609a 100644 --- a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx +++ b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -20,16 +20,39 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { - DefaultForm, FormConfiguration, + RenderAllFieldsByUiConfig, UIFormElementConfig, UIHandlerId, - useTranslationContext + convertUiField, + getConverterById, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; +import { getShapeFromFields, useFormState } from "../hooks/form.js"; import { AmlEvent } from "./CaseDetails.js"; +/** + * the exchange doesn't have a consistent api + * https://bugs.gnunet.org/view.php?id=9142 + * + * @param data + * @returns + */ +function fixProvidedInfo(data: object): object { + return Object.entries(data).reduce((prev, [key, value]) => { + prev[key] = value; + if (typeof value === "object" && value["value"]) { + const v = value["value"]; + if (typeof v === "object" && v["text"]) { + prev[key].value = v["text"]; + } + } + return prev; + }, {} as any); +} + export function ShowConsolidated({ history, until, @@ -41,77 +64,77 @@ export function ShowConsolidated({ const cons = getConsolidated(history, until); - const form: FormConfiguration = { + const fixed = fixProvidedInfo(cons.kyc); + + const formConfig: FormConfiguration = { type: "double-column", - design: [ + design: Object.entries(fixed).length > 0 ? [ + { - title: i18n.str`AML`, - fields: [ - { - type: "amount", - id: ".aml.threshold" as UIHandlerId, - currency: "NETZBON", - label: i18n.str`Threshold`, - name: "aml.threshold", - }, - { - type: "choiceHorizontal", - label: i18n.str`State`, - name: "aml.state", - id: ".aml.state" as UIHandlerId, - choices: [ - { - label: i18n.str`Frozen`, - value: "frozen", - }, - { - label: i18n.str`Pending`, - value: "pending", - }, - { - label: i18n.str`Normal`, - value: "normal", - }, - ], - }, - ], - }, - Object.entries(cons.kyc).length > 0 - ? { - title: i18n.str`KYC`, - fields: Object.entries(cons.kyc).map(([key, field]) => { - const result: UIFormElementConfig = { - type: "text", - label: key as TranslatedString, - id: `kyc.${key}.value` as UIHandlerId, - name: `kyc.${key}.value`, - help: `${field.provider} since ${ - field.since.t_ms === "never" - ? "never" - : format(field.since.t_ms, "dd/MM/yyyy") - }` as TranslatedString, - }; - return result; - }), - } - : undefined!, - ], + title: i18n.str`KYC collected info`, + fields: Object.entries(fixed).map(([key, field]) => { + const result: UIFormElementConfig = { + type: "text", + label: key as TranslatedString, + id: `${key}.value` as UIHandlerId, + disabled: true, + help: `At ${field.since.t_ms === "never" + ? "never" + : format(field.since.t_ms, "dd/MM/yyyy HH:mm:ss") + }` as TranslatedString, + }; + return result; + }), + } + ] : [], }; + const shape: Array<UIHandlerId> = formConfig.design.flatMap((field) => + getShapeFromFields(field.fields), + ); + + const { handler } = useFormState<{}>(shape, fixed, (result) => { + return { status: "ok", errors: undefined, result }; + }); + return ( <Fragment> - <h1 class="text-base font-semibold leading-7 text-black"> - Consolidated information{" "} - {until.t_ms === "never" - ? "" - : `after ${format(until.t_ms, "dd MMMM yyyy")}`} - </h1> - <DefaultForm - key={`${String(Date.now())}`} - form={form as any} - initial={cons} - readOnly - onUpdate={() => {}} - /> + <div class="space-y-10 divide-y divide-gray-900/10"> + {formConfig.design.map((section, i) => { + if (!section) return <Fragment />; + return ( + <div + key={i} + class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" + > + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {section.title} + </h2> + {section.description && ( + <p class="mt-1 text-sm leading-6 text-gray-600"> + {section.description} + </p> + )} + </div> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <div class="p-3"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + key={i} + fields={convertUiField( + i18n, + section.fields, + handler, + getConverterById, + )} + /> + </div> + </div> + </div> + </div> + ); + })} + </div> </Fragment> ); } @@ -125,7 +148,7 @@ interface Consolidated { kyc: { [field: string]: { value: unknown; - provider: string; + provider?: string; since: AbsoluteTime; }; }; diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx index 084e639bf..72656bb98 100644 --- a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx +++ b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -19,7 +19,7 @@ import { LocalNotificationBanner, UIHandlerId, useLocalNotificationHandler, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { FormErrors, useFormState } from "../hooks/form.js"; @@ -36,7 +36,7 @@ export function UnlockAccount(): VNode { const officer = useOfficer(); const [notification, withErrorHandler] = useLocalNotificationHandler(); - const [form, status] = useFormState<FormType>( + const { handler, status } = useFormState<FormType>( [".password"] as Array<UIHandlerId>, { password: undefined, @@ -64,7 +64,7 @@ export function UnlockAccount(): VNode { status.status === "fail" || officer.state !== "locked" ? undefined : withErrorHandler( - async () => officer.tryUnlock(form.password!.value!), + async () => officer.tryUnlock(handler.password!.value!), () => {}, ); @@ -94,14 +94,13 @@ export function UnlockAccount(): VNode { <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> - <div class="mb-4"> <InputLine<FormType, "password"> label={i18n.str`Password`} name="password" type="password" required - handler={form.password} + handler={handler.password} /> </div> @@ -115,7 +114,6 @@ export function UnlockAccount(): VNode { <i18n.Translate>Unlock</i18n.Translate> </Button> </div> - </div> <Button type="button" |