aboutsummaryrefslogtreecommitdiff
path: root/packages/aml-backoffice-ui
diff options
context:
space:
mode:
Diffstat (limited to 'packages/aml-backoffice-ui')
-rw-r--r--packages/aml-backoffice-ui/package.json3
-rw-r--r--packages/aml-backoffice-ui/src/App.tsx32
-rw-r--r--packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx34
-rw-r--r--packages/aml-backoffice-ui/src/Routing.tsx67
-rw-r--r--packages/aml-backoffice-ui/src/components/ErrorLoadingWithDebug.tsx24
-rw-r--r--packages/aml-backoffice-ui/src/forms/simplest.ts9
-rw-r--r--packages/aml-backoffice-ui/src/hooks/account.ts (renamed from packages/aml-backoffice-ui/src/hooks/useCaseDetails.ts)36
-rw-r--r--packages/aml-backoffice-ui/src/hooks/decisions.ts (renamed from packages/aml-backoffice-ui/src/hooks/useCases.ts)121
-rw-r--r--packages/aml-backoffice-ui/src/hooks/form.ts19
-rw-r--r--packages/aml-backoffice-ui/src/hooks/preferences.ts7
-rw-r--r--packages/aml-backoffice-ui/src/pages/CaseDetails.tsx1105
-rw-r--r--packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx44
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.stories.tsx22
-rw-r--r--packages/aml-backoffice-ui/src/pages/Cases.tsx334
-rw-r--r--packages/aml-backoffice-ui/src/pages/CreateAccount.tsx12
-rw-r--r--packages/aml-backoffice-ui/src/pages/Search.tsx731
-rw-r--r--packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx72
-rw-r--r--packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx161
-rw-r--r--packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx10
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>
+ &nbsp;
+ <a
+ href={privatePages.caseDetailsNewAccount.url({
+ cid: account,
+ payto: encodeCrockForURI(paytoStr),
+ })}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ <i18n.Translate>
+ You can make a decision for this account anyway.
+ </i18n.Translate>
+ </a>
+ </Attention>
+ </div>
+ );
+}
+
+function XTalerBankForm({
+ onSearch,
+}: {
+ onSearch: (p: PaytoUri | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const fields = talerBankFields(i18n);
+ const form = useFormState(
+ getShapeFromFields(fields),
+ {},
+ createTalerBankPaytoValidator(i18n),
+ );
+ const paytoUri =
+ form.status.status === "fail"
+ ? undefined
+ : buildPayto(
+ "x-taler-bank",
+ form.status.result.hostname,
+ form.status.result.account,
+ {
+ "receiver-name": form.status.result.name,
+ },
+ );
+
+ return (
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(i18n, fields, form.handler, getConverterById)}
+ />
+ </div>
+ <button
+ disabled={form.status.status === "fail"}
+ class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ onClick={() => onSearch(paytoUri)}
+ >
+ <i18n.Translate>Search</i18n.Translate>
+ </button>
+ </form>
+ );
+}
+function IbanForm({
+ onSearch,
+}: {
+ onSearch: (p: PaytoUri | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const fields = ibanFields(i18n);
+ const form = useFormState(
+ getShapeFromFields(fields),
+ {},
+ createIbanPaytoValidator(i18n),
+ );
+ const paytoUri =
+ form.status.status === "fail"
+ ? undefined
+ : buildPayto("iban", form.status.result.account, form.status.result.bic, {
+ "receiver-name": form.status.result.name,
+ });
+
+ return (
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(i18n, fields, form.handler, getConverterById)}
+ />
+ </div>
+ <button
+ disabled={form.status.status === "fail"}
+ class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ onClick={() => onSearch(paytoUri)}
+ >
+ <i18n.Translate>Search</i18n.Translate>
+ </button>
+ </form>
+ );
+}
+function GenericForm({
+ onSearch,
+}: {
+ onSearch: (p: PaytoUri | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const fields = genericFields(i18n);
+ const form = useFormState(
+ getShapeFromFields(fields),
+ {},
+ createGenericPaytoValidator(i18n),
+ );
+ const paytoUri =
+ form.status.status === "fail"
+ ? undefined
+ : parsePaytoUri(form.status.result.payto);
+ return (
+ <form
+ class="space-y-6"
+ noValidate
+ onSubmit={(e) => {
+ e.preventDefault();
+ }}
+ autoCapitalize="none"
+ autoCorrect="off"
+ >
+ <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
+ <RenderAllFieldsByUiConfig
+ fields={convertUiField(i18n, fields, form.handler, getConverterById)}
+ />
+ </div>
+ <button
+ disabled={form.status.status === "fail"}
+ class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ onClick={() => onSearch(paytoUri)}
+ >
+ Search
+ </button>
+ </form>
+ );
+}
+
+interface FormPayto {
+ paytoType: "generic" | "iban" | "x-taler-bank";
+}
+
+function createFormValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<FormPayto>>,
+ ): FormStatus<FormPayto> {
+ const errors = undefinedIfEmpty<FormErrors<FormPayto>>({
+ paytoType: !state?.paytoType ? i18n.str`required` : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: FormPayto = {
+ paytoType: state.paytoType! as any,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<FormPayto> = {
+ paytoType: state?.paytoType,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+interface PaytoUriGenericForm {
+ payto: string;
+}
+
+function createGenericPaytoValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<PaytoUriGenericForm>>,
+ ): FormStatus<PaytoUriGenericForm> {
+ const errors = undefinedIfEmpty<FormErrors<PaytoUriGenericForm>>({
+ payto: !state.payto
+ ? i18n.str`required`
+ : parsePaytoUri(state.payto) === undefined
+ ? i18n.str`invalid`
+ : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: PaytoUriGenericForm = {
+ payto: state.payto! as any,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<PaytoUriGenericForm> = {
+ // targetType: state.iban
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+interface PaytoUriIBANForm {
+ account: string;
+ name: string;
+ bic: string;
+}
+
+function createIbanPaytoValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<PaytoUriIBANForm>>,
+ ): FormStatus<PaytoUriIBANForm> {
+ const errors = undefinedIfEmpty<FormErrors<PaytoUriIBANForm>>({
+ account: !state.account ? i18n.str`required` : undefined,
+ name: !state.name ? i18n.str`required` : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: PaytoUriIBANForm = {
+ account: state.account!,
+ name: state.name!,
+ bic: state.bic!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<PaytoUriIBANForm> = {
+ account: state.account,
+ name: state.name,
+ bic: state.bic,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+interface PaytoUriTalerBankForm {
+ hostname: string;
+ account: string;
+ name: string;
+}
+function createTalerBankPaytoValidator(i18n: InternationalizationAPI) {
+ return function check(
+ state: RecursivePartial<FormValues<PaytoUriTalerBankForm>>,
+ ): FormStatus<PaytoUriTalerBankForm> {
+ const errors = undefinedIfEmpty<FormErrors<PaytoUriTalerBankForm>>({
+ account: !state.account ? i18n.str`required` : undefined,
+ hostname: !state.hostname ? i18n.str`required` : undefined,
+ name: !state.name ? i18n.str`required` : undefined,
+ });
+
+ if (errors === undefined) {
+ const result: PaytoUriTalerBankForm = {
+ account: state.account!,
+ hostname: state.hostname!,
+ name: state.name!,
+ };
+ return {
+ status: "ok",
+ result,
+ errors,
+ };
+ }
+ const result: RecursivePartial<PaytoUriTalerBankForm> = {
+ account: state.account,
+ hostname: state.hostname,
+ name: state.name,
+ };
+ return {
+ status: "fail",
+ result,
+ errors,
+ };
+ };
+}
+
+const paytoTypeField: (
+ i18n: InternationalizationAPI,
+) => UIFormElementConfig[] = (i18n) => [
+ {
+ id: "paytoType" as UIHandlerId,
+ type: "choiceHorizontal",
+ required: true,
+ choices: [
+ {
+ value: "iban",
+ label: i18n.str`IBAN`,
+ },
+ {
+ value: "x-taler-bank",
+ label: i18n.str`Taler Bank`,
+ },
+ {
+ value: "generic",
+ label: i18n.str`Generic Payto:// URI`,
+ },
+ ],
+ label: i18n.str`Account type`,
+ },
+];
+
+const receiverName: (i18n: InternationalizationAPI) => UIFormElementConfig = (
+ i18n,
+) => ({
+ id: "name" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Owner's name`,
+ help: i18n.str`It should match the bank account name.`,
+ placeholder: i18n.str`John Doe`,
+});
+
+const genericFields: (
+ i18n: InternationalizationAPI,
+) => UIFormElementConfig[] = (i18n) => [
+ {
+ id: "payto" as UIHandlerId,
+ type: "textArea",
+ required: true,
+ label: i18n.str`Payto URI`,
+ help: i18n.str`As defined by RFC 8905`,
+ placeholder: i18n.str`payto://`,
+ },
+];
+const ibanFields: (i18n: InternationalizationAPI) => UIFormElementConfig[] = (
+ i18n,
+) => [
+ {
+ id: "account" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Account`,
+ help: i18n.str`International Bank Account Number`,
+ placeholder: i18n.str`DE1231231231`,
+ // validator: (value) => validateIBAN(value, i18n),
+ },
+ receiverName(i18n),
+ {
+ id: "bic" as UIHandlerId,
+ type: "text",
+ label: i18n.str`Bank`,
+ help: i18n.str`Business Identifier Code`,
+ placeholder: i18n.str`GENODEM1GLS`,
+ // validator: (value) => validateIBAN(value, i18n),
+ },
+];
+
+const talerBankFields: (
+ i18n: InternationalizationAPI,
+) => UIFormElementConfig[] = (i18n) => [
+ {
+ id: "account" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Bank account`,
+ help: i18n.str`Bank account id`,
+ placeholder: i18n.str`DE123123123`,
+ },
+ {
+ id: "hostname" as UIHandlerId,
+ type: "text",
+ required: true,
+ label: i18n.str`Hostname`,
+ help: i18n.str`Without the scheme, may include subpath: bank.com, bank.com/path/`,
+ placeholder: i18n.str`bank.demo.taler.net`,
+ // validator: (value) => validateTalerBank(value, i18n),
+ },
+ receiverName(i18n),
+];
+
+function validateIBAN(
+ iban: string,
+ i18n: ReturnType<typeof useTranslationContext>["i18n"],
+): TranslatedString | undefined {
+ // Check total length
+ if (iban.length < 4)
+ return i18n.str`IBAN numbers usually have more that 4 digits`;
+ if (iban.length > 34)
+ return i18n.str`IBAN numbers usually have less that 34 digits`;
+
+ const A_code = "A".charCodeAt(0);
+ const Z_code = "Z".charCodeAt(0);
+ const IBAN = iban.toUpperCase();
+
+ // check supported country
+ // const code = IBAN.substr(0, 2);
+ // const found = code in COUNTRY_TABLE;
+ // if (!found) return i18n.str`IBAN country code not found`;
+
+ // 2.- Move the four initial characters to the end of the string
+ const step2 = IBAN.substr(4) + iban.substr(0, 4);
+ const step3 = Array.from(step2)
+ .map((letter) => {
+ const code = letter.charCodeAt(0);
+ if (code < A_code || code > Z_code) return letter;
+ return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`;
+ })
+ .join("");
+
+ function calculate_iban_checksum(str: string): number {
+ const numberStr = str.substr(0, 5);
+ const rest = str.substr(5);
+ const number = parseInt(numberStr, 10);
+ const result = number % 97;
+ if (rest.length > 0) {
+ return calculate_iban_checksum(`${result}${rest}`);
+ }
+ return result;
+ }
+
+ const checksum = calculate_iban_checksum(step3);
+ if (checksum !== 1)
+ return i18n.str`IBAN number is invalid, checksum is wrong`;
+ return undefined;
+}
+
+const DOMAIN_REGEX =
+ /^[a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9-_](?:\.[a-zA-Z0-9-_]{2,})+(:[0-9]+)?(\/[a-zA-Z0-9-.]+)*\/?$/;
+
+function validateTalerBank(
+ addr: string,
+ i18n: InternationalizationAPI,
+): TranslatedString | undefined {
+ try {
+ const valid = DOMAIN_REGEX.test(addr);
+ if (valid) return undefined;
+ } catch (e) {
+ console.log(e);
+ }
+ return i18n.str`This is not a valid host.`;
+}
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"