commit 043cdeeddc74f6380c102bab602ac891a878a903
parent 0d96bfd8dec5d07221cc82c8e3364d5cac80538c
Author: Sebastian <sebasjm@gmail.com>
Date: Tue, 17 Jun 2025 10:00:06 -0300
fix #10113 moved files from pages to components, moved common function to web-utils
Diffstat:
42 files changed, 4217 insertions(+), 4750 deletions(-)
diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx
@@ -20,12 +20,10 @@ import {
ToastBanner,
notifyError,
notifyException,
- useNavigationContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { ComponentChildren, VNode, h } from "preact";
import { useEffect, useErrorBoundary } from "preact/hooks";
-import { privatePages } from "./Routing.js";
import { useUiSettingsContext } from "./context/ui-settings.js";
import { OfficerState } from "./hooks/officer.js";
import {
@@ -33,86 +31,10 @@ import {
getLabelForPreferences,
usePreferences,
} from "./hooks/preferences.js";
-import {
- FormIcon,
- HomeIcon,
- PeopleIcon,
- SearchIcon,
- ToInvestigateIcon,
- TransfersIcon,
-} from "./pages/Cases.js";
-
-/**
- * mapping route to view
- * not found (error page)
- * nested, index element, relative routes
- * link interception
- * form POST interception, call action
- * fromData => Object.fromEntries
- * segments in the URL
- * navigationState: idle, submitting, loading
- * form GET interception: does a navigateTo
- * form GET Sync:
- * 1.- back after submit: useEffect to sync URL to form
- * 2.- refresh after submit: input default value
- * useSubmit for form submission onChange, history replace
- *
- * post form without redirect
- *
- *
- * @param param0
- * @returns
- */
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
-/**
- * TO BE FIXED:
- *
- * 1.- when the form change to other form and both form share the same structure
- * the same input component may be rendered in the same place,
- * since input are uncontrolled the are not re-rendered and since they are
- * uncontrolled it will keep the value of the previous form.
- * One solutions could be to remove the form when unloading and when the new
- * form load it will start without previous vdom, preventing the cache
- * to create this behavior.
- * Other solutions could be using IDs in the fields that are constructed
- * with the ID of the form, so two fields of different form will need to re-render
- * cleaning up the state of the previous form.
- *
- * 2.- currently the design prop and the behavior prop of the flexible form
- * are two side of the same coin. From the design point of view, it is important
- * to design the form in a list-of-field manner and there may be additional
- * content that is not directly mapped to the form structure (object)
- * So maybe we want to change the current shape so the computation of the state
- * of the form is in a field level, but this computation required the field value and
- * the whole form values and state (since one field may be disabled/hidden) because
- * of the value of other field.
- *
- * 3.- given the previous requirement, maybe the name of the field of the form could be
- * a function (P: F -> V) where F is the form (or parent object) and V is the type of the
- * property. That will help with the typing of the forms props
- *
- * 4.- tooltip are not placed correctly: the arrow should point the question mark
- * and the text area should be bigger
- *
- */
-
-/**
- * check this fields
- *
- * Signature of Contracting partner, 902_9e
- * Currency and amount of deposited assets, 902_5e
- * Signature on declaration of trust, 902.13e
- * also fundations
- * also life insurance
- *
- * no all state are handled by all the inputs
- * all the input implementation should respect
- * ui props and state
- */
-
function useErrorReport() {
const { i18n } = useTranslationContext();
const [error] = useErrorBoundary();
@@ -128,7 +50,6 @@ function useErrorReport() {
);
}
console.log(error);
- // resetError()
}
}, [error]);
}
@@ -136,9 +57,11 @@ function useErrorReport() {
export function ExchangeAmlFrame({
children,
officer,
+ NavigationBar: Navigation,
}: {
officer?: OfficerState;
children?: ComponentChildren;
+ NavigationBar?: () => VNode;
}): VNode {
const { i18n } = useTranslationContext();
@@ -214,7 +137,7 @@ export function ExchangeAmlFrame({
</div>
<div class="-mt-32 flex grow ">
- {officer?.state !== "ready" ? undefined : <Navigation />}
+ {Navigation === undefined ? undefined : <Navigation />}
<div class="flex mx-auto my-4 min-w-80">
<main class="block rounded-lg bg-white px-5 py-6 shadow min-w-xl">
{children}
@@ -231,70 +154,3 @@ export function ExchangeAmlFrame({
);
}
-function Navigation(): VNode {
- const { i18n } = useTranslationContext();
- const [{ showDebugInfo }] = usePreferences();
- const pageList = [
- {
- route: privatePages.dashboard,
- Icon: PeopleIcon,
- label: i18n.str`Dashboard`,
- },
- { route: privatePages.accounts, Icon: HomeIcon, label: i18n.str`Accounts` },
- {
- route: privatePages.transfers,
- Icon: TransfersIcon,
- label: i18n.str`Transfers`,
- },
- {
- route: privatePages.search,
- Icon: SearchIcon,
- label: i18n.str`Search`,
- },
- { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` },
- ];
- const { path } = useNavigationContext();
- return (
- <div class="hidden sm:block min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip">
- <nav class="flex flex-1 flex-col mx-4 mt-4 mb-2">
- <ul role="list" class="flex flex-1 flex-col gap-y-7">
- <li>
- <ul role="list" class="-mx-2 space-y-1">
- {pageList.map((p, idx) => {
- if (!p) return undefined;
- return (
- <li key={idx}>
- <a
- href={p.route.url({})}
- data-selected={path == p.route.url({})}
- class="data-[selected=true]:bg-indigo-700 pr-4 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
- >
- {p.Icon && <p.Icon />}
- <span class="hidden md:inline">{p.label}</span>
- </a>
- </li>
- );
- })}
- {/* <li>
- <a href="#" class="text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold">
-
- <i18n.Translate>Officer</i18n.Translate>
- </a>
- </li> */}
- </ul>
- </li>
-
- {/* <li class="mt-auto ">
- <a href="#" class="group -mx-2 flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-indigo-200 hover:bg-indigo-700 hover:text-white">
- <svg class="h-6 w-6 shrink-0 text-indigo-200 group-hover:text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
- <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
- <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
- </svg>
- Settings
- </a>
- </li> */}
- </ul>
- </nav>
- </div>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/Routing.tsx b/packages/aml-backoffice-ui/src/Routing.tsx
@@ -28,32 +28,37 @@ import { useEffect } from "preact/hooks";
import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js";
import { useCurrentDecisionRequest } from "./hooks/decision-request.js";
import { useOfficer } from "./hooks/officer.js";
-import { CaseDetails } from "./pages/CaseDetails.js";
-import { CaseUpdate } from "./pages/CaseUpdate.js";
-import { Accounts } from "./pages/Cases.js";
+import { AccountDetails } from "./pages/AccountDetails.js";
+import { AccountList } from "./pages/AccountList.js";
import { Dashboard } from "./pages/Dashboard.js";
-import { HandleAccountNotReady } from "./pages/HandleAccountNotReady.js";
-import { Officer } from "./pages/Officer.js";
+import {
+ DecisionWizard,
+ WizardSteps,
+} from "./pages/DecisionWizard.js";
+import { HandleAccountNotReady } from "./components/HandleAccountNotReady.js";
+import { Profile } from "./pages/Profile.js";
import { Search } from "./pages/Search.js";
import { ShowCollectedInfo } from "./pages/ShowCollectedInfo.js";
import { Transfers } from "./pages/Transfers.js";
import {
- AmlDecisionRequestWizard,
- WizardSteps,
-} from "./pages/decision/AmlDecisionRequestWizard.js";
+ HomeIcon,
+ PeopleIcon,
+ SearchIcon,
+ TransfersIcon
+} from "./pages/AccountList.js";
export function Routing(): VNode {
const session = useOfficer();
if (session.state === "ready") {
return (
- <ExchangeAmlFrame officer={session}>
+ <ExchangeAmlFrame officer={session} NavigationBar={Navigation}>
<PrivateRouting />
</ExchangeAmlFrame>
);
}
return (
- <ExchangeAmlFrame>
+ <ExchangeAmlFrame NavigationBar={Navigation}>
<PublicRounting />
</ExchangeAmlFrame>
);
@@ -67,9 +72,6 @@ const publicPages = {
function PublicRounting(): VNode {
const { i18n } = useTranslationContext();
const location = useCurrentLocation(publicPages);
- // const { navigateTo } = useNavigationContext();
- // const { config, lib } = useExchangeApiContext();
- // const [notification, notify, handleError] = useLocalNotification();
const session = useOfficer();
switch (location.name) {
@@ -103,7 +105,7 @@ function PublicRounting(): VNode {
}
}
-export const privatePages = {
+const privatePages = {
profile: urlPattern(/\/profile/, () => "#/profile"),
dashboard: urlPattern(/\/dashboard/, () => "#/dashboard"),
statsDownload: urlPattern(/\/download-stats/, () => "#/download-stats"),
@@ -130,8 +132,6 @@ export const privatePages = {
/\/show-collected\/(?<cid>[a-zA-Z0-9]+)\/(?<rowId>[0-9]+)/,
({ cid, rowId }) => `#/show-collected/${cid}/${rowId}`,
),
- // measuresNew: urlPattern(/\/measures\/new/, () => "#/measures/new"),
- // measures: urlPattern(/\/measures/, () => "#/measures"),
search: urlPattern(/\/search/, () => "#/search"),
transfersForAccount: urlPattern<{ cid: string }>(
/\/transfers\/(?<cid>[a-zA-Z0-9]+)/,
@@ -139,13 +139,9 @@ export const privatePages = {
),
transfers: urlPattern(/\/transfers/, () => "#/transfers"),
accounts: urlPattern(/\/accounts/, () => "#/accounts"),
- caseUpdate: urlPattern<{ cid: string; type: string }>(
- /\/case\/(?<cid>[a-zA-Z0-9]+)\/new\/(?<type>[a-zA-Z0-9_\-.]+)/,
- ({ cid, type }) => `#/case/${cid}/new/${type}`,
- ),
- caseDetails: urlPattern<{ cid: string }>(
- /\/case\/(?<cid>[a-zA-Z0-9]+)/,
- ({ cid }) => `#/case/${cid}`,
+ account: urlPattern<{ cid: string }>(
+ /\/account\/(?<cid>[a-zA-Z0-9]+)/,
+ ({ cid }) => `#/account/${cid}`,
),
};
@@ -164,17 +160,11 @@ function PrivateRouting(): VNode {
return <Fragment />;
}
case "profile": {
- return <Officer />;
+ return <Profile />;
}
- // case "measures": {
- // return <MeasureList routeToNew={privatePages.measuresNew} />;
- // }
- // case "measuresNew": {
- // return <NewMeasure />;
- // }
case "decide": {
return (
- <AmlDecisionRequestWizard
+ <DecisionWizard
account={location.values.cid}
formId={
location.params.formId ? location.params.formId[0] : undefined
@@ -183,7 +173,7 @@ function PrivateRouting(): VNode {
if (!step) {
if (location.values.cid) {
navigateTo(
- privatePages.caseDetails.url({ cid: location.values.cid }),
+ privatePages.account.url({ cid: location.values.cid }),
);
} else {
navigateTo(privatePages.dashboard.url({}));
@@ -202,7 +192,7 @@ function PrivateRouting(): VNode {
}
case "decideWithStep": {
return (
- <AmlDecisionRequestWizard
+ <DecisionWizard
account={location.values.cid}
formId={
location.params.formId ? location.params.formId[0] : undefined
@@ -212,7 +202,7 @@ function PrivateRouting(): VNode {
if (!step) {
if (location.values.cid) {
navigateTo(
- privatePages.caseDetails.url({ cid: location.values.cid }),
+ privatePages.account.url({ cid: location.values.cid }),
);
} else {
navigateTo(privatePages.dashboard.url({}));
@@ -231,7 +221,7 @@ function PrivateRouting(): VNode {
}
case "decideNew": {
return (
- <AmlDecisionRequestWizard
+ <DecisionWizard
account={location.values.cid}
newPayto={decodeCrockFromURI(location.values.payto)}
formId={
@@ -241,7 +231,7 @@ function PrivateRouting(): VNode {
if (!step) {
if (location.values.cid) {
navigateTo(
- privatePages.caseDetails.url({ cid: location.values.cid }),
+ privatePages.account.url({ cid: location.values.cid }),
);
} else {
navigateTo(privatePages.dashboard.url({}));
@@ -261,7 +251,7 @@ function PrivateRouting(): VNode {
}
case "decideNewWithStep": {
return (
- <AmlDecisionRequestWizard
+ <DecisionWizard
account={location.values.cid}
newPayto={decodeCrockFromURI(location.values.payto)}
formId={
@@ -272,7 +262,7 @@ function PrivateRouting(): VNode {
if (!step) {
if (location.values.cid) {
navigateTo(
- privatePages.caseDetails.url({ cid: location.values.cid }),
+ privatePages.account.url({ cid: location.values.cid }),
);
} else {
navigateTo(privatePages.dashboard.url({}));
@@ -290,14 +280,9 @@ function PrivateRouting(): VNode {
/>
);
}
- case "caseUpdate": {
+ case "account": {
return (
- <CaseUpdate account={location.values.cid} type={location.values.type} />
- );
- }
- case "caseDetails": {
- return (
- <CaseDetails
+ <AccountDetails
account={location.values.cid}
routeToShowTransfers={privatePages.transfersForAccount}
routeToShowCollectedInfo={privatePages.showCollectedInfo}
@@ -313,11 +298,12 @@ function PrivateRouting(): VNode {
);
}
case "accounts": {
- return <Accounts routeToCaseById={privatePages.caseDetails} />;
+ return <AccountList routeToAccountById={privatePages.account} />;
}
case "search": {
return (
<Search
+ routeToAccountById={privatePages.account}
onNewDecision={(account, payto) => {
startNewRequest();
navigateTo(
@@ -337,12 +323,12 @@ function PrivateRouting(): VNode {
return <Dashboard routeToDownloadStats={privatePages.statsDownload} />;
}
case "transfers": {
- return <Transfers routeToCaseById={privatePages.caseDetails} />;
+ return <Transfers routeToAccountById={privatePages.account} />;
}
case "transfersForAccount": {
return (
<Transfers
- routeToCaseById={privatePages.caseDetails}
+ routeToAccountById={privatePages.account}
account={location.values.cid}
/>
);
@@ -350,7 +336,7 @@ function PrivateRouting(): VNode {
case "showCollectedInfo": {
return (
<ShowCollectedInfo
- routeToCaseById={privatePages.caseDetails}
+ routeToAccountById={privatePages.account}
routeToShowCollectedInfo={privatePages.showCollectedInfo}
account={location.values.cid}
rowId={Number.parseInt(location.values.rowId, 10)}
@@ -362,3 +348,56 @@ function PrivateRouting(): VNode {
assertUnreachable(location);
}
}
+
+
+function Navigation(): VNode {
+ const { i18n } = useTranslationContext();
+ const pageList = [
+ {
+ route: privatePages.dashboard,
+ Icon: PeopleIcon,
+ label: i18n.str`Dashboard`,
+ },
+ { route: privatePages.accounts, Icon: HomeIcon, label: i18n.str`Accounts` },
+ {
+ route: privatePages.transfers,
+ Icon: TransfersIcon,
+ label: i18n.str`Transfers`,
+ },
+ {
+ route: privatePages.search,
+ Icon: SearchIcon,
+ label: i18n.str`Search`,
+ },
+ { route: privatePages.profile, Icon: PeopleIcon, label: i18n.str`Profile` },
+ ];
+ const { path } = useNavigationContext();
+ return (
+ <div class="hidden sm:block min-w-min bg-indigo-600 divide-y rounded-r-lg divide-cyan-800 overflow-y-auto overflow-x-clip">
+ <nav class="flex flex-1 flex-col mx-4 mt-4 mb-2">
+ <ul role="list" class="flex flex-1 flex-col gap-y-7">
+ <li>
+ <ul role="list" class="-mx-2 space-y-1">
+ {pageList.map((p, idx) => {
+ if (!p) return undefined;
+ return (
+ <li key={idx}>
+ <a
+ href={p.route.url({})}
+ data-selected={path == p.route.url({})}
+ class="data-[selected=true]:bg-indigo-700 pr-4 data-[selected=true]:text-white text-indigo-200 hover:text-white hover:bg-indigo-700 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
+ >
+ {p.Icon && <p.Icon />}
+ <span class="hidden md:inline">{p.label}</span>
+ </a>
+ </li>
+ );
+ })}
+ </ul>
+ </li>
+
+ </ul>
+ </nav>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/components/CreateAccount.tsx b/packages/aml-backoffice-ui/src/components/CreateAccount.tsx
@@ -0,0 +1,129 @@
+/*
+ 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 {
+ Button,
+ FormDesign,
+ FormUI,
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ useForm,
+ useLocalNotificationHandler,
+ useTranslationContext
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useOfficer } from "../hooks/officer.js";
+import { usePreferences } from "../hooks/preferences.js";
+
+type FormType = {
+ password: string;
+ repeat: string;
+};
+
+const createAccountForm = (
+ i18n: InternationalizationAPI,
+ allowInsecurePassword: boolean,
+): FormDesign => ({
+ type: "single-column",
+ fields: [
+ {
+ id: "password",
+ type: "secret",
+ label: i18n.str`Password`,
+ required: true,
+ validator(value) {
+ return !value
+ ? i18n.str`required`
+ : allowInsecurePassword
+ ? undefined
+ : value.length < 12
+ ? i18n.str`should have at least 12 characters`
+ : !value.match(/[a-z]/) && value.match(/[A-Z]/)
+ ? i18n.str`should have lowercase and uppercase characters`
+ : !value.match(/\d/)
+ ? i18n.str`should have numbers`
+ : !value.match(/[^a-zA-Z\d]/)
+ ? i18n.str`should have at least one character which is not a number or letter`
+ : undefined;
+ },
+ },
+ {
+ id: "repeat",
+ type: "secret",
+ label: i18n.str`Repeat password`,
+ required: true,
+ validator(value) {
+ return !value
+ ? i18n.str`required`
+ : // : state.password !== value
+ // ? i18n.str`doesn't match`
+ undefined;
+ },
+ },
+ ],
+});
+
+export function CreateAccount(): VNode {
+ const { i18n } = useTranslationContext();
+ const [settings] = usePreferences();
+ const officer = useOfficer();
+
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+
+ const design = createAccountForm(i18n, settings.allowInsecurePassword);
+
+ const { model: handler, status } = useForm<FormType>(
+ design,
+ {
+ password: undefined,
+ repeat: undefined,
+ },
+ // createFormValidator(i18n, settings.allowInsecurePassword),
+ );
+
+ const createAccountHandler =
+ status.status === "fail" || officer.state !== "not-found"
+ ? undefined
+ : withErrorHandler(
+ async () => officer.create(status.result.password),
+ () => {},
+ );
+ return (
+ <div class="flex min-h-full flex-col ">
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
+ <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ <i18n.Translate>Create account</i18n.Translate>
+ </h2>
+ </div>
+
+ <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
+ <FormUI design={design} model={handler} />
+ <div class="mt-8">
+ <Button
+ type="submit"
+ disabled={!createAccountHandler}
+ class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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"
+ handler={createAccountHandler}
+ >
+ <i18n.Translate>Create</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
diff --git a/packages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx b/packages/aml-backoffice-ui/src/components/HandleAccountNotReady.tsx
diff --git a/packages/aml-backoffice-ui/src/components/MeasureList.tsx b/packages/aml-backoffice-ui/src/components/MeasureList.tsx
@@ -0,0 +1,123 @@
+/*
+ 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 {
+ assertUnreachable,
+ HttpStatusCode,
+ TalerError,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Loading,
+ RouteDefinition,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h } from "preact";
+import { ErrorLoadingWithDebug } from "./ErrorLoadingWithDebug.js";
+import { useServerMeasures } from "../hooks/server-info.js";
+import { computeAvailableMesaures } from "../utils/computeAvailableMesaures.js";
+import { CurrentMeasureTable } from "./MeasuresTable.js";
+import { Profile } from "../pages/Profile.js";
+
+export function MeasureList({ routeToNew }: { routeToNew: RouteDefinition }) {
+ const { i18n } = useTranslationContext();
+
+ const measures = useServerMeasures();
+ // const [custom] = useCustomMeasures();
+
+ if (!measures) {
+ return <Loading />;
+ }
+ if (measures instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={measures} />;
+ }
+
+ if (measures.type === "fail") {
+ switch (measures.case) {
+ case HttpStatusCode.Forbidden:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account signature is invalid, contact administrator or
+ create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not known, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not enabled, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ default:
+ assertUnreachable(measures);
+ }
+ }
+
+ const ms = computeAvailableMesaures(
+ measures.body,
+ // , custom
+ );
+
+ return (
+ <div>
+ <div class="px-4 sm:px-6 lg:px-8">
+ <div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-base font-semibold text-gray-900">
+ <i18n.Translate>Measures</i18n.Translate>
+ </h1>
+ <p class="mt-2 text-sm text-gray-700">
+ <i18n.Translate>
+ A list of all the pre-define measures in your that can used.
+ </i18n.Translate>
+ </p>
+ </div>
+ <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
+ <a
+ href={routeToNew.url({})}
+ class="block rounded-md bg-indigo-600 px-3 py-2 text-center 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>Add custom measure</i18n.Translate>
+ </a>
+ </div>
+ </div>
+
+ <CurrentMeasureTable measures={ms} />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/MeasuresTable.tsx b/packages/aml-backoffice-ui/src/components/MeasuresTable.tsx
diff --git a/packages/aml-backoffice-ui/src/components/NewMeasure.tsx b/packages/aml-backoffice-ui/src/components/NewMeasure.tsx
@@ -0,0 +1,994 @@
+import {
+ AmlProgramRequirement,
+ assertUnreachable,
+ AvailableMeasureSummary,
+ KycCheckInformation,
+ KycRule,
+ TalerError,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ design_challenger_email,
+ design_challenger_phone,
+ design_challenger_postal,
+ ErrorsSummary,
+ form_challenger_email,
+ FormDesign,
+ FormUI,
+ InputToggle,
+ InternationalizationAPI,
+ RecursivePartial,
+ useForm,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import { useCurrentDecisionRequest } from "../hooks/decision-request.js";
+import { useServerMeasures } from "../hooks/server-info.js";
+import { useState } from "preact/hooks";
+
+export type MeasureDefinition = {
+ name: string;
+ program: string;
+ check: string;
+ context: {
+ key: string;
+ type: "string" | "number" | "boolean" | "json";
+ value: string;
+ }[];
+};
+
+type VerificationMeasureDefinition = {
+ name: string;
+ readOnly: boolean;
+ address: any;
+};
+
+/**
+ * Defined new limits for the account
+ * @param param0
+ * @returns
+ */
+export function NewMeasure({
+ initial,
+ isNew,
+ onCancel,
+ onAdded,
+ onChanged,
+ onRemoved,
+}: {
+ initial?: Partial<MeasureDefinition>;
+ isNew?: boolean;
+ onCancel: () => void;
+ onAdded: (name: string) => void;
+ onChanged: (name: string) => void;
+ onRemoved: (name: string) => void;
+}): VNode {
+ const measures = useServerMeasures();
+ const { i18n } = useTranslationContext();
+
+ const summary =
+ !measures || measures instanceof TalerError || measures.type === "fail"
+ ? undefined
+ : measures.body;
+
+ if (!summary) {
+ return (
+ <div>
+ <i18n.Translate>loading...</i18n.Translate>
+ </div>
+ );
+ }
+
+ return (
+ <MeasureForm
+ summary={summary}
+ initial={initial}
+ onCancel={onCancel}
+ onAdded={onAdded}
+ onChanged={onChanged}
+ onRemoved={onRemoved}
+ addingNew={isNew}
+ />
+ );
+}
+
+function NormalMeasureForm({
+ summary,
+ onCancel,
+ onAdded,
+ onChanged,
+ onRemoved,
+ initial,
+ addingNew,
+}: {
+ initial?: Partial<MeasureDefinition>;
+ addingNew?: boolean;
+ summary: AvailableMeasureSummary;
+ onCancel: () => void;
+ onAdded: (name: string) => void;
+ onChanged: (name: string) => void;
+ onRemoved: (name: string) => void;
+}): VNode {
+ const [request, updateRequest] = useCurrentDecisionRequest();
+ const { i18n } = useTranslationContext();
+
+ const names = {
+ measures: Object.entries(summary.roots).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ programs: Object.entries(summary.programs).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ checks: Object.entries(summary.checks).map(([key, value]) => ({
+ key,
+ value,
+ })),
+ };
+
+ const design = formDesign(
+ i18n,
+ names.programs,
+ names.checks,
+ summary,
+ !addingNew,
+ );
+
+ const form = useForm<MeasureDefinition>(design, initial ?? {});
+
+ const name = !form.status.result ? undefined : form.status.result.name;
+
+ function addNewCustomMeasure() {
+ const newMeasure = form.status.result as MeasureDefinition;
+ const currentMeasures = { ...request.custom_measures };
+ currentMeasures[newMeasure.name] = {
+ check_name: newMeasure.check,
+ prog_name: newMeasure.program,
+ context: (newMeasure.context ?? []).reduce(
+ (prev, cur) => {
+ prev[cur.key] = getContextValueByType(cur.type, cur.value);
+ return prev;
+ },
+ {} as Record<string, any>,
+ ),
+ };
+ updateRequest("add new measure", {
+ custom_measures: currentMeasures,
+ });
+ if (onAdded) {
+ onAdded(newMeasure.name);
+ }
+ }
+
+ function updateCurrentCustomMeasure() {
+ const newMeasure = form.status.result as MeasureDefinition;
+
+ const CURRENT_MEASURES = { ...request.custom_measures };
+ CURRENT_MEASURES[newMeasure.name] = {
+ check_name: newMeasure.check,
+ prog_name: newMeasure.program,
+ context: (newMeasure.context ?? []).reduce(
+ (prev, cur) => {
+ prev[cur.key] = getContextValueByType(cur.type, cur.value);
+ return prev;
+ },
+ {} as Record<string, any>,
+ ),
+ };
+ updateRequest("update measure", {
+ custom_measures: CURRENT_MEASURES,
+ });
+ if (onChanged) {
+ onChanged(newMeasure.name);
+ }
+ }
+
+ function removeCustomMeasure() {
+ const currentMeasures = { ...request.custom_measures };
+ delete currentMeasures[name!];
+ updateRequest("remove measure", {
+ custom_measures: currentMeasures,
+ });
+ if (onRemoved) {
+ onRemoved(name!);
+ }
+ }
+
+ return (
+ <Fragment>
+ <FormUI design={design} model={form.model} />
+
+ <button
+ onClick={() => {
+ onCancel();
+ }}
+ class="m-4 rounded-md w-fit border-1 px-3 py-2 text-center text-sm shadow-sm "
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ {addingNew ? (
+ <button
+ disabled={form.status.status === "fail"}
+ onClick={addNewCustomMeasure}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ ) : (
+ <Fragment>
+ <button
+ disabled={form.status.status === "fail"}
+ onClick={updateCurrentCustomMeasure}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+
+ <button
+ onClick={removeCustomMeasure}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Remove</i18n.Translate>
+ </button>
+ </Fragment>
+ )}
+
+ <DescribeMeasure measure={form.status.result} summary={summary} />
+ </Fragment>
+ );
+}
+function VerificationMeasureForm({
+ summary,
+ onCancel,
+ onAdded,
+ onChanged,
+ onRemoved,
+ initial,
+ addingNew,
+ challengeType,
+}: {
+ initial?: Partial<MeasureDefinition>;
+ addingNew?: boolean;
+ summary: AvailableMeasureSummary;
+ onCancel: () => void;
+ onAdded: (name: string) => void;
+ onChanged: (name: string) => void;
+ onRemoved: (name: string) => void;
+ challengeType: "email" | "phone" | "postal";
+}): VNode {
+ const [request, updateRequest] = useCurrentDecisionRequest();
+ const { i18n } = useTranslationContext();
+
+ const design = verificationFormDesign(
+ i18n,
+ summary,
+ !addingNew,
+ challengeType,
+ );
+
+ const initAddr = (initial?.context ?? []).find(
+ (d) => d.key === "initial_address",
+ );
+
+ let readOnly: boolean | undefined;
+ let rest = {};
+ if (initAddr && initAddr.value) {
+ const va = JSON.parse(initAddr.value);
+ readOnly = va.read_only;
+ delete va.read_only;
+ rest = { ...va };
+ }
+
+ const template: Partial<VerificationMeasureDefinition> = {
+ name: initial?.name,
+ readOnly,
+ address: rest,
+ };
+
+ const form = useForm<VerificationMeasureDefinition>(design, template ?? {});
+
+ // const name = !form.status.result ? undefined : form.status.result.name;
+
+ if (!initial) {
+ throw Error("verification doesn't have initial value");
+ }
+ if (!initial.check) {
+ throw Error("verification doesn't have check");
+ }
+ if (!initial.program) {
+ throw Error("verification doesn't have program");
+ }
+ if (!initial.context) {
+ throw Error("verification doesn't have program");
+ }
+ if (!initial.name) {
+ throw Error("verification doesn't have name");
+ }
+
+ const check_name = initial.check;
+ const measure_name = initial.name;
+ const prog_name = initial.program;
+ const context = initial.context.reduce(
+ (prev, cur) => {
+ prev[cur.key] = getContextValueByType(cur.type, cur.value);
+ return prev;
+ },
+ {} as Record<string, any>,
+ );
+
+ function addNewCustomMeasure() {
+ const newMeasure = form.status.result as VerificationMeasureDefinition;
+ const currentMeasures = { ...request.custom_measures };
+ delete currentMeasures[measure_name];
+
+ currentMeasures[newMeasure.name] = {
+ check_name,
+ prog_name,
+ context: {
+ ...context,
+ initial_address: {
+ read_only: newMeasure.readOnly,
+ ...newMeasure.address,
+ },
+ },
+ };
+ updateRequest("add new measure", {
+ custom_measures: currentMeasures,
+ });
+ if (onAdded) {
+ onAdded(newMeasure.name);
+ }
+ }
+
+ function updateCurrentCustomMeasure() {
+ const newMeasure = form.status.result as VerificationMeasureDefinition;
+
+ const CURRENT_MEASURES = { ...request.custom_measures };
+ CURRENT_MEASURES[newMeasure.name] = {
+ check_name,
+ prog_name,
+ context: {
+ ...context,
+ initial_address: {
+ read_only: newMeasure.readOnly,
+ ...newMeasure.address,
+ },
+ },
+ };
+ updateRequest("update measure", {
+ custom_measures: CURRENT_MEASURES,
+ });
+ if (onChanged) {
+ onChanged(newMeasure.name);
+ }
+ }
+
+ function removeCustomMeasure() {
+ const newMeasure = form.status.result as VerificationMeasureDefinition;
+ const currentMeasures = { ...request.custom_measures };
+ delete currentMeasures[newMeasure.name];
+ updateRequest("remove measure", {
+ custom_measures: currentMeasures,
+ });
+ if (onRemoved) {
+ onRemoved(name!);
+ }
+ }
+
+ return (
+ <Fragment>
+ <FormUI design={design} model={form.model} />
+
+ <button
+ onClick={() => {
+ onCancel();
+ }}
+ class="m-4 rounded-md w-fit border-1 px-3 py-2 text-center text-sm shadow-sm "
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+
+ {addingNew ? (
+ <button
+ disabled={form.status.status === "fail"}
+ onClick={addNewCustomMeasure}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Add</i18n.Translate>
+ </button>
+ ) : (
+ <Fragment>
+ <button
+ disabled={form.status.status === "fail"}
+ onClick={updateCurrentCustomMeasure}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Update</i18n.Translate>
+ </button>
+
+ <button
+ onClick={removeCustomMeasure}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Remove</i18n.Translate>
+ </button>
+ </Fragment>
+ )}
+
+ <DescribeMeasure measure={form.status.result} summary={summary} />
+ </Fragment>
+ );
+}
+
+function MeasureForm({
+ summary,
+ onCancel,
+ onAdded,
+ onChanged,
+ onRemoved,
+ initial,
+ addingNew,
+}: {
+ initial?: Partial<MeasureDefinition>;
+ addingNew?: boolean;
+ summary: AvailableMeasureSummary;
+ onCancel: () => void;
+ onAdded: (name: string) => void;
+ onChanged: (name: string) => void;
+ onRemoved: (name: string) => void;
+}) {
+ const challengeType = (initial?.context ?? []).find(
+ (c) => c.key === "challenge-type",
+ );
+ const measureIsVerificationType = challengeType !== undefined;
+ const [formType, setFormType] = useState<"verification" | "normal">(
+ measureIsVerificationType ? "verification" : "normal",
+ );
+
+ const { i18n } = useTranslationContext();
+
+ switch (formType) {
+ case "verification": {
+ const cType = JSON.parse(challengeType?.value as any)
+ return (
+ <div>
+ <h2 class="mt-4 mb-2">
+ <i18n.Translate>Configure verification type: {cType}</i18n.Translate>
+ </h2>
+ <div>
+ <button
+ onClick={async () => {
+ setFormType("normal");
+ }}
+ 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>Show complete form</i18n.Translate>
+ </button>
+ </div>
+
+ <VerificationMeasureForm
+ onAdded={onAdded}
+ onCancel={onCancel}
+ onChanged={onChanged}
+ onRemoved={onRemoved}
+ summary={summary}
+ addingNew={addingNew}
+ initial={initial}
+ challengeType={cType}
+ />
+ </div>
+ );
+ }
+ case "normal": {
+ return (
+ <div>
+ <h2 class="mt-4 mb-2">
+ <i18n.Translate>Configure measure</i18n.Translate>
+ </h2>
+ {measureIsVerificationType ? (
+ <div>
+ <button
+ onClick={async () => {
+ setFormType("verification");
+ }}
+ 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>Show as verification</i18n.Translate>
+ </button>
+ </div>
+ ) : undefined}
+
+ <NormalMeasureForm
+ onAdded={onAdded}
+ onCancel={onCancel}
+ onChanged={onChanged}
+ onRemoved={onRemoved}
+ summary={summary}
+ addingNew={addingNew}
+ initial={initial}
+ />
+ </div>
+ );
+ }
+ default: {
+ assertUnreachable(formType);
+ }
+ }
+}
+
+const formDesign = (
+ i18n: InternationalizationAPI,
+ programs: { key: string; value: AmlProgramRequirement }[],
+ checks: { key: string; value: KycCheckInformation }[],
+ summary: AvailableMeasureSummary,
+ cantChangeName: boolean,
+): FormDesign => ({
+ type: "single-column",
+ fields: [
+ {
+ id: "name",
+ type: "text",
+ required: true,
+ disabled: cantChangeName,
+ label: i18n.str`Name`,
+ validator(value) {
+ return !value
+ ? i18n.str`required`
+ : summary.roots[value]
+ ? i18n.str`There is already a measure with that name`
+ : undefined;
+ },
+ },
+ {
+ type: "selectOne",
+ id: "program",
+ label: i18n.str`Program`,
+ choices: programs.map((m) => {
+ return {
+ value: m.key,
+ label: m.key,
+ };
+ }),
+ help: i18n.str`Only required when no check is specified`,
+ validator(value, form) {
+ return !value
+ ? !form.check
+ ? i18n.str`Missing check or program`
+ : undefined
+ : programAndCheckMatch(i18n, summary, value, form.check) ??
+ programAndContextMatch(i18n, summary, value, form.context);
+ },
+ },
+ {
+ type: "selectOne",
+ id: "check",
+ label: i18n.str`Check`,
+ help: i18n.str`Without a check the program will run automatically`,
+ choices: checks.map((m) => {
+ return {
+ value: m.key,
+ label: m.key,
+ };
+ }),
+ validator(value, form) {
+ return checkAndcontextMatch(
+ i18n,
+ summary,
+ value,
+ (form.context ?? []) as {
+ key: string;
+ value: string;
+ }[],
+ );
+ },
+ },
+ {
+ type: "array",
+ id: "context",
+ label: i18n.str`Context`,
+ labelFieldId: "key",
+ fields: [
+ {
+ type: "text",
+ id: "key",
+ required: true,
+ label: i18n.str`Field name`,
+ },
+ {
+ type: "choiceHorizontal",
+ id: "type",
+ label: i18n.str`Type`,
+ required: true,
+ choices: [
+ {
+ label: i18n.str`string`,
+ value: "string",
+ },
+ {
+ label: i18n.str`number`,
+ value: "number",
+ },
+ {
+ label: i18n.str`boolean`,
+ value: "boolean",
+ },
+ {
+ label: i18n.str`json`,
+ value: "json",
+ },
+ ],
+ },
+ {
+ type: "textArea",
+ id: "value",
+ required: true,
+ label: i18n.str`Value`,
+ validator(value, form) {
+ return validateContextValueByType(i18n, form["type"], value);
+ },
+ },
+ ],
+ },
+ ],
+});
+
+function programAndCheckMatch(
+ i18n: InternationalizationAPI,
+ summary: AvailableMeasureSummary,
+ progName: string,
+ checkName: string | undefined,
+): TranslatedString | undefined {
+ const program = summary.programs[progName];
+ if (checkName === undefined) {
+ if (program.inputs.length > 0) {
+ return i18n.str`There are unsatisfied inputs: ${program.inputs.join(
+ ", ",
+ )}`;
+ }
+ return undefined;
+ }
+ const check = summary.checks[checkName];
+ const missing = program.inputs.filter((d) => {
+ return check.outputs.indexOf(d) === -1;
+ });
+ if (missing.length > 0) {
+ return i18n.str`There are missing inputs: ${missing.join(", ")}`;
+ }
+ return;
+}
+
+function checkAndcontextMatch(
+ i18n: InternationalizationAPI,
+ summary: AvailableMeasureSummary,
+ checkName: string | undefined,
+ context: { key: string; value: string }[] | undefined,
+): TranslatedString | undefined {
+ if (checkName === undefined) {
+ return undefined;
+ }
+ const check = summary.checks[checkName];
+ const output = !context ? [] : context.map((d) => d.key);
+ const missing = check.requires.filter((d) => {
+ return output.indexOf(d) === -1;
+ });
+ if (missing.length > 0) {
+ return i18n.str`There are missing requirements: ${missing.join(", ")}`;
+ }
+ return;
+}
+
+function programAndContextMatch(
+ i18n: InternationalizationAPI,
+ summary: AvailableMeasureSummary,
+ program: string,
+ context: { key: string; value: string }[] | undefined,
+): TranslatedString | undefined {
+ const check = summary.programs[program];
+ const output = !context ? [] : context.map((d) => d.key);
+ const missing = check.context.filter((d) => {
+ return output.indexOf(d) === -1;
+ });
+ if (missing.length > 0) {
+ return i18n.str`There are missing requirements: ${missing.join(", ")}`;
+ }
+ return;
+}
+
+function getJsonError(str: string) {
+ try {
+ JSON.parse(str);
+ return undefined;
+ } catch (e) {
+ if (e instanceof SyntaxError) {
+ return e.message;
+ }
+ return String(e);
+ }
+}
+
+// convert the string value of the form into the corresponding type
+// based on the user choice
+// check the function validateContextValueByType
+function getContextValueByType(type: string, value: string) {
+ if (type === "number") {
+ return Number.parseInt(value, 10);
+ }
+ if (type === "boolean") {
+ return value === "true" ? true : value === "false" ? false : undefined;
+ }
+ if (type === "json") {
+ return JSON.parse(value);
+ }
+ return value;
+}
+
+const REGEX_NUMER = /^[0-9]*$/;
+
+function validateContextValueByType(
+ i18n: InternationalizationAPI,
+ type: string,
+ value: string,
+) {
+ if (!value) return i18n.str`Can't be empty`;
+ if (type === "number") {
+ const num = Number.parseInt(value, 10);
+ return !REGEX_NUMER.test(value)
+ ? i18n.str`It should be a number`
+ : Number.isNaN(num)
+ ? i18n.str`Not a number`
+ : !Number.isFinite(num)
+ ? i18n.str`It should be finite`
+ : !Number.isSafeInteger(num)
+ ? i18n.str`It should be a safe integer`
+ : undefined;
+ }
+ if (type === "boolean") {
+ if (value === "true" || value === "false") return undefined;
+ return i18n.str`It should be either "true" or "false"`;
+ }
+ if (type === "json") {
+ const error = getJsonError(value);
+ if (error) {
+ return i18n.str`Couldn't parse as json string: ${error}`;
+ }
+ return undefined;
+ }
+ return undefined;
+}
+
+function DescribeProgram({
+ name,
+ program,
+}: {
+ name: string;
+ program: AmlProgramRequirement;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 ">
+ <dl class="flex flex-wrap">
+ <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg">
+ <dt class="text-sm/6 text-white">
+ <i18n.Translate>Program</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-base font-semibold text-white">{name}</dd>
+ </div>
+ <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
+ <dt class="flex-none text-gray-500">
+ <i18n.Translate>Description</i18n.Translate>
+ </dt>
+ <dd class="text-sm/6 ">
+ <i18n.Translate>{program.description}</i18n.Translate>
+ </dd>
+ </div>
+ <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2">
+ <dt class="flex-none text-gray-500">
+ <i18n.Translate>Context</i18n.Translate>
+ </dt>
+ <dd class="text-sm/6 font-medium text-gray-900">
+ <pre>{program.context.join(",")}</pre>
+ </dd>
+ </div>
+ <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
+ <dt class="flex-none text-gray-500">
+ <i18n.Translate>Inputs</i18n.Translate>
+ </dt>
+ <dd class="text-sm/6 ">
+ <pre class="whitespace-pre-wrap">{program.inputs.join(",")}</pre>
+ </dd>
+ </div>
+ </dl>
+ <div class="px-4 pb-2"></div>
+ </div>
+ );
+}
+function DescribeCheck({
+ name,
+ check,
+}: {
+ name: string;
+ check: KycCheckInformation;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 ">
+ <dl class="flex flex-wrap">
+ <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg">
+ <dt class="text-sm/6 text-white">
+ <i18n.Translate>Check</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-base font-semibold text-white">{name}</dd>
+ </div>
+ <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
+ <dt class="flex-none text-gray-500">Description</dt>
+ <dd class="text-sm/6 ">
+ <i18n.Translate>{check.description}</i18n.Translate>
+ </dd>
+ </div>
+ <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2">
+ <dt class="flex-none text-gray-500">
+ <i18n.Translate>Output</i18n.Translate>
+ </dt>
+ <dd class="text-sm/6 font-medium ">
+ <pre class="whitespace-break-spaces">
+ {check.outputs.join(", ")}
+ </pre>
+ </dd>
+ </div>
+ <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
+ <dt class="flex-none text-gray-500">
+ <i18n.Translate>Requires</i18n.Translate>
+ </dt>
+ <dd class="text-sm/6 ">
+ <pre>{check.requires.join(",")}</pre>
+ </dd>
+ </div>
+ <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
+ <dt class="flex-none text-gray-500">
+ <i18n.Translate>Fallback</i18n.Translate>
+ </dt>
+ <dd class="text-sm/6 ">
+ <pre>{check.fallback}</pre>
+ </dd>
+ </div>
+ </dl>
+ <div class="px-4 pb-2"></div>
+ </div>
+ );
+}
+function DescribeContext({
+ context,
+}: {
+ context: {
+ key: string;
+ type: "string" | "number" | "boolean" | "json";
+ value: string;
+ }[];
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 ">
+ <dl class="flex flex-wrap">
+ <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg">
+ <dt class="text-sm/6 text-white">
+ <i18n.Translate>Context</i18n.Translate>
+ </dt>
+ <dd class="mt-1 text-base font-semibold text-white"></dd>
+ </div>
+ {context.map(({ key, value }) => {
+ return (
+ <div key={key} class="mt-4 flex w-full flex-none gap-x-4 px-6">
+ <dt class="flex-none text-gray-500">{key}</dt>
+ <dd class="text-sm/6 ">
+ <i18n.Translate>{value}</i18n.Translate>
+ </dd>
+ </div>
+ );
+ })}
+ </dl>
+ <div class="px-4 pb-2"></div>
+ </div>
+ );
+}
+function DescribeMeasure({
+ measure,
+ summary,
+}: {
+ measure: RecursivePartial<MeasureDefinition>;
+ summary: AvailableMeasureSummary;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const programName: string | undefined = measure.program;
+ const program: AmlProgramRequirement | undefined =
+ !programName || !summary.programs[programName]
+ ? undefined
+ : summary.programs[programName];
+
+ const checkName: string | undefined = measure.check;
+ const check =
+ !checkName || !summary.checks[checkName]
+ ? undefined
+ : summary.checks[checkName];
+
+ const context =
+ !measure || !measure.context
+ ? []
+ : (measure.context as MeasureDefinition["context"]);
+
+ return (
+ <Fragment>
+ <h2 class="mt-4 mb-2">
+ <i18n.Translate>Description</i18n.Translate>
+ </h2>
+
+ {!program || !programName ? undefined : (
+ <DescribeProgram name={programName} program={program} />
+ )}
+ {!check || !checkName ? undefined : (
+ <DescribeCheck name={checkName} check={check} />
+ )}
+ {!context || !context.length ? undefined : (
+ <DescribeContext context={context} />
+ )}
+ </Fragment>
+ );
+}
+
+const verificationFormDesign = (
+ i18n: InternationalizationAPI,
+ summary: AvailableMeasureSummary,
+ cantChangeName: boolean,
+ challengeType: "email" | "phone" | "postal",
+): FormDesign => {
+ const em =
+ challengeType === "email"
+ ? design_challenger_email(i18n)
+ : challengeType === "phone"
+ ? design_challenger_phone(i18n)
+ : challengeType === "postal"
+ ? design_challenger_postal(i18n)
+ : undefined;
+
+ if (!em) {
+ throw Error(`unkown challenge type ${challengeType} `);
+ }
+
+ const fields = em.fields.map((f) => {
+ f.disabled = false;
+ f.required = false;
+ if ("id" in f) {
+ f.id = `address.${f.id}`;
+ }
+ return f;
+ });
+
+ return {
+ type: "single-column",
+ fields: [
+ {
+ id: "name",
+ type: "text",
+ required: true,
+ disabled: cantChangeName,
+ label: i18n.str`Name`,
+ help: i18n.str`Name of the verfication measure`,
+ validator(value) {
+ return !value
+ ? i18n.str`required`
+ : summary.roots[value]
+ ? i18n.str`There is already a measure with that name`
+ : undefined;
+ },
+ },
+ {
+ type: "toggle",
+ id: "readOnly",
+ label: i18n.str`Read only`,
+ help: i18n.str`Prevent the customer of changing the address`,
+ },
+ ...fields,
+ ],
+ };
+};
diff --git a/packages/aml-backoffice-ui/src/components/RulesInfo.tsx b/packages/aml-backoffice-ui/src/components/RulesInfo.tsx
@@ -0,0 +1,299 @@
+import {
+ amountFractionalBase,
+ AmountJson,
+ Amounts,
+ KycRule,
+ LimitOperationType
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ RenderAmount,
+ useExchangeApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { formatDuration, intervalToDuration } from "date-fns";
+import { Fragment, h, VNode } from "preact";
+
+type KycRuleWithIdx = KycRule & {
+ idx: number;
+};
+
+export function RulesInfo({
+ rules,
+ onEdit,
+ onRemove,
+ onNew,
+}: {
+ rules: KycRule[];
+ onNew?: () => void;
+ onEdit?: (k: KycRule, idx: number) => void;
+ onRemove?: (k: KycRule, idx: number) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const { config } = useExchangeApiContext();
+
+ if (!rules.length) {
+ return (
+ <div>
+ <Attention
+ title={i18n.str`There are no rules for operations`}
+ type="warning"
+ >
+ <i18n.Translate>
+ This mean that all operation have no limit.
+ </i18n.Translate>
+ </Attention>
+ {!onNew ? undefined : (
+ <button
+ onClick={() => {
+ onNew();
+ }}
+ 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 disabled:bg-gray-600"
+ >
+ <i18n.Translate>Add custom rule</i18n.Translate>
+ </button>
+ )}
+ </div>
+ );
+ }
+
+ const theRules = rules.map((r, idx): KycRuleWithIdx => ({ ...r, idx }));
+
+ const sorted = theRules.sort((a, b) => {
+ return sortKycRules(a, b);
+ });
+
+ const hasActions = !!onEdit || !!onRemove;
+
+ return (
+ <Fragment>
+ <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="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
+ >
+ <i18n.Translate>Threshold</i18n.Translate>
+ </th>
+ <th
+ scope="col"
+ class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
+ >
+ <i18n.Translate>Escalation</i18n.Translate>
+ </th>
+ {!hasActions ? undefined : (
+ <th
+ scope="col"
+ class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
+ >
+ {!onNew ? undefined : (
+ <button
+ onClick={() => {
+ onNew();
+ }}
+ class="rounded-md w-fit border-0 p-1 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700 disabled:bg-gray-600"
+ >
+ <i18n.Translate>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 4.5v15m7.5-7.5h-15"
+ />
+ </svg>
+ </i18n.Translate>
+ </button>
+ )}
+ </th>
+ )}
+ </tr>
+ </thead>
+
+ <tbody id="thetable" class="divide-y divide-gray-200 bg-white ">
+ {sorted.map((r) => {
+ return (
+ <tr class="even:bg-gray-200 ">
+ <td class="flex whitespace-nowrap py-2 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left">
+ <span class="mx-2">
+ {r.exposed ? (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
+ />
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
+ />
+ </svg>
+ ) : (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6 text-gray-500"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
+ />
+ </svg>
+ )}
+ </span>
+ <span>{r.operation_type}</span>
+ </td>
+ <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
+ {r.timeframe.d_us === "forever" ? (
+ <RenderAmount
+ value={Amounts.parseOrThrow(r.threshold)}
+ spec={config.config.currency_specification}
+ />
+ ) : (
+ <i18n.Translate context="threshold">
+ <RenderAmount
+ value={Amounts.parseOrThrow(r.threshold)}
+ spec={config.config.currency_specification}
+ />
+ every{" "}
+ {formatDuration(
+ intervalToDuration({
+ start: 0,
+ end: r.timeframe.d_us / 1000,
+ }),
+ )}
+ </i18n.Translate>
+ )}
+ </td>
+ <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
+ {r.is_and_combinator ? (
+ <span class="text-gray-500">
+ <i18n.Translate>(all)</i18n.Translate>
+ </span>
+ ) : (
+ <Fragment />
+ )}
+ {r.measures}
+ </td>
+ {!hasActions ? undefined : (
+ <td class="relative flex justify-end whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6">
+ {!onEdit ? undefined : (
+ <button onClick={() => onEdit(r, r.idx)}>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6 text-green-700"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
+ />
+ </svg>
+ </button>
+ )}
+ {!onRemove ? undefined : (
+ <button onClick={() => onRemove(r, r.idx)}>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6 text-red-700"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
+ />
+ </svg>
+ </button>
+ )}
+ </td>
+ )}
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ </Fragment>
+ );
+}
+
+export function rate(a: AmountJson, b: number): number {
+ const af = toFloat(a);
+ const bf = b;
+ if (bf === 0) return 0;
+ return af / bf;
+}
+
+function toFloat(amount: AmountJson): number {
+ return amount.value + amount.fraction / amountFractionalBase;
+}
+
+const OPERATION_TYPE_ORDER = {
+ [LimitOperationType.balance]: 1,
+ [LimitOperationType.transaction]: 2,
+ [LimitOperationType.withdraw]: 3,
+ [LimitOperationType.deposit]: 4,
+ [LimitOperationType.aggregate]: 5,
+ [LimitOperationType.close]: 6,
+ [LimitOperationType.refund]: 7,
+ [LimitOperationType.merge]: 8,
+} as const;
+
+/**
+ * Operation follows OPERATION_TYPE_ORDER.
+ * Then operations with timeframe "forever" means they are not reset, like balance. Go first.
+ * Then operations with high throughput first.
+ * @param a
+ * @param b
+ * @returns
+ */
+function sortKycRules(a: KycRule, b: KycRule): number {
+ const op =
+ OPERATION_TYPE_ORDER[a.operation_type] -
+ OPERATION_TYPE_ORDER[b.operation_type];
+ if (op !== 0) return op;
+ const at = a.timeframe;
+ const bt = b.timeframe;
+ if (at.d_us === "forever" || bt.d_us === "forever") {
+ if (at.d_us === "forever") return -1;
+ if (bt.d_us === "forever") return 1;
+ return Amounts.cmp(a.threshold, b.threshold);
+ }
+ const as = rate(Amounts.parseOrThrow(a.threshold), at.d_us);
+ const bs = rate(Amounts.parseOrThrow(a.threshold), bt.d_us);
+ return bs - as;
+}
diff --git a/packages/aml-backoffice-ui/src/components/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/components/ShowConsolidated.tsx
@@ -0,0 +1,142 @@
+/*
+ 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,
+ TalerExchangeApi,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import {
+ FormDesign,
+ FormUI,
+ UIFormElementConfig,
+ useForm,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { VNode, h } from "preact";
+import { useEffect } from "preact/hooks";
+// 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,
+}: {
+ history: TalerExchangeApi.KycAttributeCollectionEvent[];
+ until: AbsoluteTime;
+}): VNode {
+ const { i18n } = useTranslationContext();
+
+ const cons = getConsolidated(history, until);
+
+ const fixed = fixProvidedInfo(cons.kyc);
+
+ const design: FormDesign = {
+ type: "double-column",
+ sections:
+ Object.entries(fixed).length > 0
+ ? [
+ {
+ title: i18n.str`Collected information`,
+ description:
+ until.t_ms === "never"
+ ? undefined
+ : i18n.str`All information known until ${format(
+ until.t_ms,
+ "dd/MM/yyyy HH:mm:ss",
+ )}`,
+ fields: Object.entries(fixed).map(([key, field]) => {
+ const result: UIFormElementConfig = {
+ type: "text",
+ label: key as TranslatedString,
+ id: `${key}.value`,
+ 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 { model: handler, update } = useForm(design, fixed);
+
+ useEffect(() => {
+ update(fixed);
+ }, [until.t_ms]);
+
+ return <FormUI design={design} model={handler} />;
+}
+
+interface Consolidated {
+ kyc: {
+ [field: string]: {
+ value: unknown;
+ provider?: string;
+ since: AbsoluteTime;
+ };
+ };
+}
+
+function getConsolidated(
+ history: TalerExchangeApi.KycAttributeCollectionEvent[],
+ when: AbsoluteTime,
+): Consolidated {
+ const initial: Consolidated = {
+ kyc: {},
+ };
+ return history.reduce((prev, cur) => {
+ const collectionTime = AbsoluteTime.fromProtocolTimestamp(cur.collection_time);
+ if (AbsoluteTime.cmp(when, collectionTime) <= 0) {
+ return prev;
+ }
+
+ const formValues = cur.attributes ?? {}
+ Object.keys(formValues).forEach((field) => {
+ const value = (formValues as Record<string, unknown>)[field];
+ prev.kyc[field] = {
+ value,
+ provider: cur.provider_name,
+ since: collectionTime,
+ };
+ });
+ return prev;
+ }, initial);
+}
diff --git a/packages/aml-backoffice-ui/src/components/ShowDecisionLimitInfo.tsx b/packages/aml-backoffice-ui/src/components/ShowDecisionLimitInfo.tsx
@@ -0,0 +1,135 @@
+import { AbsoluteTime, KycRule } from "@gnu-taler/taler-util";
+import { useTranslationContext, Time } from "@gnu-taler/web-util/browser";
+import { h, VNode, Fragment } from "preact";
+import { useState } from "preact/hooks";
+import { RulesInfo } from "./RulesInfo.js";
+
+
+export function ShowDecisionLimitInfo({
+ rules, since, until, startOpen, justification, fixed, measure,
+}: {
+ since: AbsoluteTime;
+ until: AbsoluteTime;
+ justification?: string;
+ rules: KycRule[];
+ startOpen?: boolean;
+ fixed?: boolean;
+ measure: string;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [opened, setOpened] = useState(startOpen ?? false);
+
+ function Header() {
+ return (
+ <div
+ data-fixed={!!fixed}
+ class="p-4 relative bg-gray-200 flex justify-between data-[fixed=false]:cursor-pointer"
+ onClick={() => {
+ if (!fixed) {
+ 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-300 inset-y-0 flex items-center px-3">
+ <i18n.Translate>Since</i18n.Translate>
+ </div>
+ <div class="p-2 bg-gray-50 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-50 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-300 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 bg-gray-50 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-50 sm:text-sm sm:leading-6">
+ <Time format="dd/MM/yyyy HH:mm:ss" timestamp={until} />
+ </div>
+ </div>
+ {AbsoluteTime.isNever(until) ? undefined : (
+ <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 p-2 bg-gray-300 inset-y-0 flex items-center ">
+ <i18n.Translate>Successor measure</i18n.Translate>
+ </div>
+ <div class="p-2 bg-gray-50 rounded-md rounded-l-none data-[left=true]:text-left text-gray-900 placeholder:text-gray-50 sm:text-sm sm:leading-6">
+ {measure}
+ </div>
+ </div>
+ )}
+ {fixed ? (
+ <Fragment />
+ ) : (
+ <div class="rounded-full bg-gray-50 p-2">
+ <svg
+ 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 h-6"
+ >
+ {opened ? (
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m19.5 8.25-7.5 7.5-7.5-7.5" />
+ ) : (
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m4.5 15.75 7.5-7.5 7.5 7.5" />
+ )}
+ </svg>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ }
+
+ if (!opened) {
+ return (
+ <div class="overflow-hidden border border-gray-800 rounded-xl">
+ <Header />
+ </div>
+ );
+ }
+
+ return (
+ <div class="overflow-hidden border border-gray-800 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>
+ )}
+
+ <RulesInfo rules={rules} />
+ </div>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/components/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/components/UnlockAccount.tsx
@@ -0,0 +1,126 @@
+/*
+ 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 {
+ Button,
+ FormDesign,
+ InputLine,
+ InternationalizationAPI,
+ LocalNotificationBanner,
+ useForm,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { VNode, h } from "preact";
+import { useOfficer } from "../hooks/officer.js";
+
+type FormType = {
+ password: string;
+};
+
+const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({
+ type: "single-column",
+ fields: [
+ {
+ id: "password",
+ type: "text",
+ label: i18n.str`Password`,
+ required: true,
+ },
+ ],
+});
+
+export function UnlockAccount(): VNode {
+ const { i18n } = useTranslationContext();
+
+ const officer = useOfficer();
+ const [notification, withErrorHandler] = useLocalNotificationHandler();
+
+ const design = unlockAccountForm(i18n);
+
+ const { model: handler, status } = useForm<FormType>(
+ design,
+ {
+ password: undefined,
+ },
+ );
+
+ const unlockHandler =
+ status.status === "fail" || officer.state !== "locked"
+ ? undefined
+ : withErrorHandler(
+ async () => officer.tryUnlock(status.result.password),
+ () => {},
+ );
+
+ const forgetHandler =
+ officer.state === "not-found"
+ ? undefined
+ : withErrorHandler(
+ async () => officer.forget(),
+ () => {},
+ );
+
+ return (
+ <div class="flex min-h-full flex-col ">
+ <LocalNotificationBanner notification={notification} />
+
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
+ <h1 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
+ <i18n.Translate>Account locked</i18n.Translate>
+ </h1>
+ <p class="mt-6 text-lg leading-8 text-gray-600">
+ <i18n.Translate>
+ Your account is normally locked anytime you reload. To unlock type
+ your password again.
+ </i18n.Translate>
+ </p>
+ </div>
+
+ <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
+ label={i18n.str`Password`}
+ name="password"
+ type="password"
+ required
+ handler={handler.getHandlerForAttributeKey("password")}
+ />
+ </div>
+
+ <div class="mt-8">
+ <Button
+ type="submit"
+ handler={unlockHandler}
+ disabled={!unlockHandler}
+ class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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>Unlock</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ <Button
+ type="button"
+ handler={forgetHandler}
+ disabled={!forgetHandler}
+ class="disabled:opacity-50 disabled:cursor-default m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ >
+ <i18n.Translate>Forget account</i18n.Translate>
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/AccountDetails.tsx b/packages/aml-backoffice-ui/src/pages/AccountDetails.tsx
@@ -0,0 +1,992 @@
+/*
+ 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,
+ AmlDecisionRequest,
+ assertUnreachable,
+ buildCodecForObject,
+ Codec,
+ codecForNumber,
+ codecForString,
+ codecOptional,
+ HttpStatusCode,
+ LimitOperationType,
+ OperationFail,
+ OperationOk,
+ opFixedSuccess,
+ TalerError,
+ TalerErrorDetail,
+ TalerExchangeApi,
+ TalerFormAttributes,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ Button,
+ CopyButton,
+ FormDesign,
+ FormMetadata,
+ FormUI,
+ Loading,
+ LocalNotificationBanner,
+ RouteDefinition,
+ useExchangeApiContext,
+ useForm,
+ useLocalNotificationHandler,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { format } from "date-fns";
+import { Fragment, h, Ref, VNode } from "preact";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { CurrentMeasureTable, MeasureInfo } from "../components/MeasuresTable.js";
+import { useAccountInformation } from "../hooks/account.js";
+import { DecisionRequest } from "../hooks/decision-request.js";
+import { useAccountDecisions } from "../hooks/decisions.js";
+import { useOfficer } from "../hooks/officer.js";
+import { useServerMeasures } from "../hooks/server-info.js";
+import { Profile } from "./Profile.js";
+import { ShowDecisionLimitInfo } from "../components/ShowDecisionLimitInfo.js";
+import { computeAvailableMesaures } from "../utils/computeAvailableMesaures.js";
+
+type NewDecision = {
+ request: Omit<Omit<AmlDecisionRequest, "justification">, "officer_sig">;
+ askInformation: boolean;
+};
+
+export function AccountDetails({
+ account,
+ routeToShowCollectedInfo,
+ onNewDecision,
+ routeToShowTransfers,
+}: {
+ onNewDecision: (d: Partial<DecisionRequest>) => void;
+ routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
+ account: string;
+ routeToShowTransfers: RouteDefinition<{ cid: string }>;
+}) {
+ const { i18n } = useTranslationContext();
+ const details = useAccountInformation(account);
+ const history = useAccountDecisions(account);
+
+ if (!details || !history) {
+ return <Loading />;
+ }
+ if (details instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={details} />;
+ }
+ if (details.type === "fail") {
+ switch (details.case) {
+ // case HttpStatusCode.Unauthorized:
+ case HttpStatusCode.Forbidden:
+ case HttpStatusCode.NotFound:
+ case HttpStatusCode.Conflict:
+ return <div />;
+ default:
+ assertUnreachable(details);
+ }
+ }
+ 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: collectionEvents } = 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(accountDetails, i18n);
+
+ function ShortcutActionButtons(): VNode {
+ return (
+ <div>
+ <button
+ onClick={async () => {
+ // the wizard should not require checking the account state
+ // instead here all the values from the current decision should be
+ // loaded into the new decision request, like we are doing with e
+ // custom measures
+ // FIXME-do-this add properties, limits, investigation state
+ onNewDecision({
+ original: activeDecision,
+ custom_measures: activeDecision?.limits.custom_measures,
+ });
+ }}
+ 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>New decision</i18n.Translate>
+ </button>
+ <a
+ href={routeToShowTransfers.url({
+ cid: account,
+ })}
+ 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>Show transfers</i18n.Translate>
+ </a>
+ </div>
+ );
+ }
+
+ 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 gap-2">
+ <h1 class="text-base font-semibold leading-7 text-black">
+ <i18n.Translate>Case history for account:</i18n.Translate>
+ </h1>
+ <div>{account}</div>
+ <CopyButton class="" getContent={() => account} />
+ </header>
+
+ {!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>
+ )}
+
+ <ShortcutActionButtons />
+
+ {/* {selected && (
+ <ShowConsolidated history={collectionEvents} until={selected} />
+ )} */}
+ <div class="p-4">
+ <h1 class="text-base font-semibold leading-6 text-black">
+ <i18n.Translate>Collected information</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>
+ {collectionEvents.length === 0 ? (
+ <Attention title={i18n.str`The event list is empty`} type="warning" />
+ ) : (
+ <ShowTimeline
+ account={account}
+ history={collectionEvents}
+ routeToShowCollectedInfo={routeToShowCollectedInfo}
+ />
+ )}
+
+ {!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}
+ rules={activeDecision.limits.rules}
+ startOpen
+ measure={activeDecision.limits.successor_measure ?? ""}
+ />
+ </div>
+ )}
+ {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}
+ rules={d.limits.rules}
+ measure={d.limits.successor_measure ?? ""}
+ />
+ );
+ })}
+ </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: FormDesign = {
+ type: "single-column",
+ fields: [
+ {
+ id: "justification",
+ type: "textArea",
+ required: true,
+ label: i18n.str`Justification`,
+ },
+ ],
+ };
+
+ if (decision.askInformation) {
+ formDesign.fields.push({
+ type: "caption",
+ label: i18n.str`Form definition`,
+ help: i18n.str`The user will need to complete this form.`,
+ });
+ formDesign.fields.push({
+ id: "fields",
+ type: "array",
+ required: true,
+ label: i18n.str`Fields`,
+ fields: [
+ {
+ id: "name",
+ type: "text",
+ required: true,
+ label: i18n.str`Name`,
+ help: i18n.str`Name of the field in the form`,
+ },
+ {
+ id: "type",
+ 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",
+ });
+ }
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+ const decisionForm = useForm<{ justification: string; fields: object }>(
+ formDesign,
+ { justification: "" },
+ );
+
+ const customFields = decisionForm.status.result.fields as [
+ { name: string; type: string },
+ ];
+
+ // const customForm: FormDesign | undefined = !decisionForm.status.result.fields
+ // ? undefined
+ // : {
+ // type: "double-column",
+ // sections: [
+ // {
+ // fields: customFields.map((f) => {
+ // return {
+ // id: f.name,
+ // label: f.name,
+ // type: f.type,
+ // } as UIFormElementConfig;
+ // }),
+ // title: "Required information",
+ // },
+ // ],
+ // };
+
+ const submitHandler =
+ decisionForm === undefined || !session || decision.askInformation //&& 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: "preserve-investigate",
+ },
+ },
+ },
+ };
+ 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);
+ }
+ },
+ );
+
+ 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"
+ >
+ <FormUI design={formDesign} model={decisionForm.model} />
+
+ <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>
+
+ <div class="my-2">
+ <ShowMesaureInfo
+ nextMeasures={decision.request.new_measures?.split(" ") ?? []}
+ />
+ </div>
+
+ <ShowDecisionLimitInfo
+ fixed
+ since={AbsoluteTime.fromProtocolTimestamp(
+ decision.request.decision_time,
+ )}
+ until={AbsoluteTime.fromProtocolTimestamp(
+ decision.request.new_rules.expiration_time,
+ )}
+ rules={decision.request.new_rules.rules}
+ startOpen
+ measure={decision.request.new_rules.successor_measure ?? ""}
+ />
+ </div>
+ );
+}
+
+function ShowMesaureInfo({ nextMeasures }: { nextMeasures: string[] }): VNode {
+ const { i18n } = useTranslationContext();
+ const measures = useServerMeasures();
+ if (!measures) {
+ return <Loading />;
+ }
+ if (measures instanceof TalerError) {
+ return <ErrorLoadingWithDebug error={measures} />;
+ }
+ if (measures.type === "fail") {
+ switch (measures.case) {
+ case HttpStatusCode.Forbidden:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ This account signature is invalid, contact administrator or
+ create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not known, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not enabled, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ default:
+ assertUnreachable(measures);
+ }
+ }
+ const filteredMeasures = nextMeasures.filter((n) => !!n && !!n.trim());
+ const allMeasures = computeAvailableMesaures(
+ measures.body,
+ // cm,
+ (m) => filteredMeasures.indexOf(m.name) === -1,
+ );
+
+ if (!filteredMeasures.length) {
+ return <Fragment />;
+ }
+ if (filteredMeasures.length === 1) {
+ return (
+ <div>
+ <i18n.Translate>
+ The customer needs to complete this measure
+ </i18n.Translate>
+ <CurrentMeasureTable measures={allMeasures} />
+ </div>
+ );
+ }
+ return (
+ <div>
+ <i18n.Translate>
+ The customer needs to complete all of these measures
+ </i18n.Translate>
+ <CurrentMeasureTable measures={allMeasures} />
+ </div>
+ );
+}
+
+function ShowTimeline({
+ history,
+ account,
+ routeToShowCollectedInfo,
+}: {
+ account: string;
+ routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
+ history: TalerExchangeApi.KycAttributeCollectionEvent[];
+}): VNode {
+ const { i18n } = useTranslationContext();
+ return (
+ <div class="flow-root">
+ <ul role="list">
+ {history.map((e, idx) => {
+ const values = e.attributes ?? {};
+ const formId = values[TalerFormAttributes.FORM_ID] as
+ | string
+ | undefined;
+
+ return (
+ <a
+ href={routeToShowCollectedInfo.url({
+ cid: account,
+ rowId: String(e.rowid),
+ })}
+ >
+ <li key={idx} class="hover:bg-gray-200 p-2 rounded">
+ <div class="relative pb-3">
+ <span class="absolute left-3 top-5 -ml-px h-full w-1 bg-gray-200"></span>
+ <div class="relative flex space-x-3">
+ {/* <ArrowDownCircleIcon class="h-8 w-8 text-green-700" /> */}
+ <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="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ {!formId ? undefined : (
+ <div>
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"
+ />
+ </svg>
+ <span>{formId}</span>
+ </div>
+ )}
+ <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
+ <div class="whitespace-nowrap text-right text-sm text-gray-500">
+ {e.collection_time.t_s === "never" ? (
+ "never"
+ ) : (
+ <time
+ dateTime={format(
+ e.collection_time.t_s * 1000,
+ "dd MMM yyyy",
+ )}
+ >
+ {format(
+ e.collection_time.t_s * 1000,
+ "dd MMM yyyy HH:mm:ss",
+ )}
+ </time>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ </li>
+ </a>
+ );
+ })}
+ <li class="hover:bg-gray-200 p-2 rounded">
+ <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
+ <svg
+ 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 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
+ />
+ </svg>
+ <p class="text-sm text-gray-900">
+ <i18n.Translate>Now</i18n.Translate>
+ </p>
+ </div>
+ </li>
+ </ul>
+ </div>
+ );
+}
+
+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.config.currency_specification.num_fractional_input_digits
+ ) {
+ e.currentTarget.value = e.currentTarget.value.substring(
+ 0,
+ sep_pos +
+ config.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;
+} & Omit<Omit<FormMetadata, "icon">, "config">;
+
+type SimpleFormMetadata = {
+ version?: number;
+ id?: string;
+};
+
+const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> =>
+ buildCodecForObject<SimpleFormMetadata>()
+ .property("id", codecOptional(codecForString()))
+ .property("version", codecOptional(codecForNumber()))
+ .build("SimpleFormMetadata");
+
+type ParseJustificationFail =
+ | "not-json"
+ | "id-not-found"
+ | "form-not-found"
+ | "version-not-found";
+
+function parseJustification(
+ s: string,
+ listOfAllKnownForms: FormMetadata[],
+):
+ | OperationOk<{
+ justification: Justification;
+ metadata: FormMetadata;
+ }>
+ | OperationFail<ParseJustificationFail> {
+ try {
+ const justification = JSON.parse(s);
+ const info = codecForSimpleFormMetadata().decode(justification);
+ if (!info.id) {
+ return {
+ type: "fail",
+ case: "id-not-found",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+ if (!info.version) {
+ return {
+ type: "fail",
+ case: "version-not-found",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+ const found = listOfAllKnownForms.find((f) => {
+ return f.id === info.id && f.version === info.version;
+ });
+ if (!found) {
+ return {
+ type: "fail",
+ case: "form-not-found",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+ return opFixedSuccess({
+ justification,
+ metadata: found,
+ });
+ } catch (e) {
+ return {
+ type: "fail",
+ case: "not-json",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+}
+
+const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = (
+ currency,
+) => [
+ {
+ operation_type: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.withdraw,
+ threshold: `${currency}:0`,
+ timeframe: {
+ d_us: "forever",
+ },
+ measures: ["verboten"],
+ display_priority: 1,
+ exposed: true,
+ is_and_combinator: true,
+ },
+ {
+ operation_type: LimitOperationType.deposit,
+ threshold: `${currency}:0`,
+ timeframe: {
+ d_us: "forever",
+ },
+ measures: ["verboten"],
+ display_priority: 1,
+ exposed: true,
+ is_and_combinator: true,
+ },
+ {
+ operation_type: LimitOperationType.aggregate,
+ threshold: `${currency}:0`,
+ timeframe: {
+ d_us: "forever",
+ },
+ measures: ["verboten"],
+ display_priority: 1,
+ exposed: true,
+ is_and_combinator: true,
+ },
+ {
+ operation_type: LimitOperationType.merge,
+ threshold: `${currency}:0`,
+ timeframe: {
+ d_us: "forever",
+ },
+ measures: ["verboten"],
+ display_priority: 1,
+ exposed: true,
+ is_and_combinator: true,
+ },
+ {
+ operation_type: LimitOperationType.balance,
+ threshold: `${currency}:0`,
+ timeframe: {
+ d_us: "forever",
+ },
+ measures: ["verboten"],
+ display_priority: 1,
+ exposed: true,
+ is_and_combinator: true,
+ },
+ {
+ operation_type: LimitOperationType.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/AccountList.tsx b/packages/aml-backoffice-ui/src/pages/AccountList.tsx
@@ -0,0 +1,406 @@
+/*
+ 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 {
+ HttpStatusCode,
+ TalerError,
+ assertUnreachable,
+} from "@gnu-taler/taler-util";
+import {
+ Attention,
+ InputToggle,
+ Loading,
+ Pagination,
+ RouteDefinition,
+ useExchangeApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { Fragment, VNode, h } from "preact";
+import { useCurrentDecisions } from "../hooks/decisions.js";
+
+import { useEffect, useState } from "preact/hooks";
+import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
+import { useOfficer } from "../hooks/officer.js";
+import { Profile } from "./Profile.js";
+
+type FormType = {
+ // state: TalerExchangeApi.AmlState;
+};
+
+export function AccountList({
+ routeToAccountById: caseByIdRoute,
+}: {
+ routeToAccountById: RouteDefinition<{ cid: string }>;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [filtered, setFiltered] = useState<boolean>();
+ const list = useCurrentDecisions({ investigated: filtered });
+
+ 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 signature is invalid, contact administrator or
+ create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ case HttpStatusCode.NotFound:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not known, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <Fragment>
+ <Attention type="danger" title={i18n.str`Operation denied`}>
+ <i18n.Translate>
+ The designated AML account is not enabled, contact administrator
+ or create a new one.
+ </i18n.Translate>
+ </Attention>
+ <Profile />
+ </Fragment>
+ );
+ default:
+ assertUnreachable(list);
+ }
+ }
+
+ const records = list.body;
+ const onFirstPage = list.isFirstPage ? undefined : list.loadFirst;
+ const onNext = list.isLastPage ? undefined : list.loadNext;
+
+ return (
+ <div>
+ <div class="sm:flex sm:items-center">
+ {filtered === true ? (
+ <div class="px-2 sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Accounts 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>
+ ) : filtered === false ? (
+ <div class="px-2 sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Accounts without 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 active.
+ </i18n.Translate>
+ </p>
+ </div>
+ ) : (
+ <div class="px-2 sm:flex-auto">
+ <h1 class="text-base font-semibold leading-6 text-gray-900">
+ <i18n.Translate>Accounts</i18n.Translate>
+ </h1>
+ <p class="mt-2 text-sm text-gray-700 w-80">
+ <i18n.Translate>
+ A list of all the accounts known to the exchange.
+ </i18n.Translate>
+ </p>
+ </div>
+ )}
+
+ <JumpByIdForm
+ caseByIdRoute={caseByIdRoute}
+ filtered={filtered}
+ onTog={setFiltered}
+ />
+ </div>
+ <div class="mt-8 flow-root">
+ <div class="overflow-x-auto">
+ {!records.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>Account Id</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">
+ {records.map((r) => {
+ return (
+ <tr key={r.h_payto} class="hover:bg-gray-100 ">
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
+ <div class="text-gray-900">
+ <a
+ href={caseByIdRoute.url({
+ cid: r.h_payto,
+ })}
+ class="text-indigo-600 hover:text-indigo-900 font-mono"
+ >
+ {r.h_payto}
+ </a>
+ </div>
+ </td>
+ <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900">
+ {r.to_investigate ? (
+ <span title="require investigation">
+ <ToInvestigateIcon />
+ </span>
+ ) : undefined}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ <Pagination onFirstPage={onFirstPage} onNext={onNext} />
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+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 TransfersIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
+ />
+ </svg>
+);
+
+export const PeopleIcon = () => (
+ <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="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
+ />
+ </svg>
+);
+
+export const HomeIcon = () => (
+ <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="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
+ />
+ </svg>
+);
+export const FormIcon = () => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ class="w-6 h-6"
+ >
+ <path
+ fillRule="evenodd"
+ d="M1.5 5.625c0-1.036.84-1.875 1.875-1.875h17.25c1.035 0 1.875.84 1.875 1.875v12.75c0 1.035-.84 1.875-1.875 1.875H3.375A1.875 1.875 0 0 1 1.5 18.375V5.625ZM21 9.375A.375.375 0 0 0 20.625 9h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5Zm0 3.75a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5Zm0 3.75a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5ZM10.875 18.75a.375.375 0 0 0 .375-.375v-1.5a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5ZM3.375 15h7.5a.375.375 0 0 0 .375-.375v-1.5a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375Zm0-3.75h7.5a.375.375 0 0 0 .375-.375v-1.5A.375.375 0 0 0 10.875 9h-7.5A.375.375 0 0 0 3 9.375v1.5c0 .207.168.375.375.375Z"
+ clipRule="evenodd"
+ />
+ </svg>
+);
+
+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>
+);
+
+
+let latestTimeout: undefined | ReturnType<typeof setTimeout> = undefined;
+
+function JumpByIdForm({
+ caseByIdRoute,
+ filtered,
+ onTog,
+}: {
+ caseByIdRoute: RouteDefinition<{ cid: string }>;
+ filtered?: boolean;
+ onTog: (d: boolean | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const [account, setAccount] = useState<string>("");
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.account : undefined;
+ const { lib } = useExchangeApiContext();
+ const [valid, setValid] = useState(false);
+ const [error, setError] = useState<string>();
+ useEffect(() => {
+ if (!session || !account) return;
+ const activeSession = session;
+ if (latestTimeout) {
+ clearTimeout(latestTimeout);
+ }
+ setError(undefined)
+ setValid(false)
+ latestTimeout = setTimeout(async function checkAccouunt() {
+ let found = false
+ try {
+ const result = await lib.exchange.getAmlAttributesForAccount(
+ activeSession,
+ account,
+ { limit: 1 },
+ );
+ found = (result.type === "ok");
+ } catch (e) {
+ console.log(e)
+ }
+ setValid(found)
+ if (!found) {
+ setError(i18n.str`account not found`);
+ }
+ }, 500);
+ }, [account, session]);
+ return (
+ <form class="mt-5 grid grid-cols-1">
+ <div>
+ <div class="flex flex-row">
+ <div class="w-full sm:max-w-xs">
+ <input
+ name="account"
+ onChange={(e) => {
+ setAccount(e.currentTarget.value);
+ }}
+ class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6"
+ placeholder={i18n.str`Search by ID`}
+ />
+ </div>
+ <a
+ href={!valid ? undefined : caseByIdRoute.url({ cid: account })}
+ data-disabled={!valid}
+ class="data-[disabled=true]:bg-gray-400 mt-3 inline-flex w-full items-center justify-center 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 sm:ml-3 sm:mt-0 sm:w-auto"
+ >
+ <svg
+ 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 h-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
+ />
+ </svg>
+ </a>
+ </div>
+ {!error ? undefined : <p class="mt-2 text-sm text-red-600">{error}</p>}
+ </div>
+ <div class="mt-2 cursor-default">
+ <InputToggle
+ threeState
+ name="inv"
+ label={i18n.str`Only investigated`}
+ handler={{
+ name: "inv",
+ onChange: (x) => onTog(x),
+ value: filtered,
+ }}
+ />
+ </div>
+ </form>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -1,1285 +0,0 @@
-/*
- 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,
- AmlDecisionRequest,
- assertUnreachable,
- buildCodecForObject,
- Codec,
- codecForNumber,
- codecForString,
- codecOptional,
- HttpStatusCode,
- KycRule,
- LimitOperationType,
- OperationFail,
- OperationOk,
- opFixedSuccess,
- TalerError,
- TalerErrorDetail,
- TalerExchangeApi,
- TalerFormAttributes,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- Button,
- CopyButton,
- FormDesign,
- FormMetadata,
- FormUI,
- Loading,
- LocalNotificationBanner,
- RouteDefinition,
- Time,
- useExchangeApiContext,
- useForm,
- useLocalNotificationHandler,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { format } from "date-fns";
-import { Fragment, h, Ref, VNode } from "preact";
-import { useState } from "preact/hooks";
-import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
-import { useAccountInformation } from "../hooks/account.js";
-import { DecisionRequest } from "../hooks/decision-request.js";
-import { useAccountDecisions } from "../hooks/decisions.js";
-import { useOfficer } from "../hooks/officer.js";
-import { useServerMeasures } from "../hooks/server-info.js";
-import { CurrentMeasureTable, MeasureInfo, Mesaures } from "./MeasuresTable.js";
-import { Officer } from "./Officer.js";
-import { RulesInfo } from "./RulesInfo.js";
-
-type NewDecision = {
- request: Omit<Omit<AmlDecisionRequest, "justification">, "officer_sig">;
- askInformation: boolean;
-};
-
-export function CaseDetails({
- account,
- routeToShowCollectedInfo,
- onNewDecision,
- routeToShowTransfers,
-}: {
- onNewDecision: (d: Partial<DecisionRequest>) => void;
- routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
- account: string;
- routeToShowTransfers: RouteDefinition<{ cid: string }>;
-}) {
- const { i18n } = useTranslationContext();
- const details = useAccountInformation(account);
- const history = useAccountDecisions(account);
-
- if (!details || !history) {
- return <Loading />;
- }
- if (details instanceof TalerError) {
- return <ErrorLoadingWithDebug error={details} />;
- }
- if (details.type === "fail") {
- switch (details.case) {
- // case HttpStatusCode.Unauthorized:
- case HttpStatusCode.Forbidden:
- case HttpStatusCode.NotFound:
- case HttpStatusCode.Conflict:
- return <div />;
- default:
- assertUnreachable(details);
- }
- }
- 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: collectionEvents } = 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(accountDetails, i18n);
-
- function ShortcutActionButtons(): VNode {
- return (
- <div>
- <button
- onClick={async () => {
- // the wizard should not require checking the account state
- // instead here all the values from the current decision should be
- // loaded into the new decision request, like we are doing with e
- // custom measures
- // FIXME-do-this add properties, limits, investigation state
- onNewDecision({
- original: activeDecision,
- custom_measures: activeDecision?.limits.custom_measures,
- });
- }}
- 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>New decision</i18n.Translate>
- </button>
- <a
- href={routeToShowTransfers.url({
- cid: account,
- })}
- 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>Show transfers</i18n.Translate>
- </a>
- </div>
- );
- }
-
- 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 gap-2">
- <h1 class="text-base font-semibold leading-7 text-black">
- <i18n.Translate>Case history for account:</i18n.Translate>
- </h1>
- <div>{account}</div>
- <CopyButton class="" getContent={() => account} />
- </header>
-
- {!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>
- )}
-
- <ShortcutActionButtons />
-
- {/* {selected && (
- <ShowConsolidated history={collectionEvents} until={selected} />
- )} */}
- <div class="p-4">
- <h1 class="text-base font-semibold leading-6 text-black">
- <i18n.Translate>Collected information</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>
- {collectionEvents.length === 0 ? (
- <Attention title={i18n.str`The event list is empty`} type="warning" />
- ) : (
- <ShowTimeline
- account={account}
- history={collectionEvents}
- routeToShowCollectedInfo={routeToShowCollectedInfo}
- />
- )}
-
- {!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}
- rules={activeDecision.limits.rules}
- startOpen
- measure={activeDecision.limits.successor_measure ?? ""}
- />
- </div>
- )}
- {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}
- rules={d.limits.rules}
- measure={d.limits.successor_measure ?? ""}
- />
- );
- })}
- </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: FormDesign = {
- type: "single-column",
- fields: [
- {
- id: "justification",
- type: "textArea",
- required: true,
- label: i18n.str`Justification`,
- },
- ],
- };
-
- if (decision.askInformation) {
- formDesign.fields.push({
- type: "caption",
- label: i18n.str`Form definition`,
- help: i18n.str`The user will need to complete this form.`,
- });
- formDesign.fields.push({
- id: "fields",
- type: "array",
- required: true,
- label: i18n.str`Fields`,
- fields: [
- {
- id: "name",
- type: "text",
- required: true,
- label: i18n.str`Name`,
- help: i18n.str`Name of the field in the form`,
- },
- {
- id: "type",
- 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",
- });
- }
- const officer = useOfficer();
- const session = officer.state === "ready" ? officer.account : undefined;
- const decisionForm = useForm<{ justification: string; fields: object }>(
- formDesign,
- { justification: "" },
- );
-
- const customFields = decisionForm.status.result.fields as [
- { name: string; type: string },
- ];
-
- // const customForm: FormDesign | undefined = !decisionForm.status.result.fields
- // ? undefined
- // : {
- // type: "double-column",
- // sections: [
- // {
- // fields: customFields.map((f) => {
- // return {
- // id: f.name,
- // label: f.name,
- // type: f.type,
- // } as UIFormElementConfig;
- // }),
- // title: "Required information",
- // },
- // ],
- // };
-
- const submitHandler =
- decisionForm === undefined || !session || decision.askInformation //&& 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: "preserve-investigate",
- },
- },
- },
- };
- 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);
- }
- },
- );
-
- 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"
- >
- <FormUI design={formDesign} model={decisionForm.model} />
-
- <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>
-
- <div class="my-2">
- <ShowMesaureInfo
- nextMeasures={decision.request.new_measures?.split(" ") ?? []}
- />
- </div>
-
- <ShowDecisionLimitInfo
- fixed
- since={AbsoluteTime.fromProtocolTimestamp(
- decision.request.decision_time,
- )}
- until={AbsoluteTime.fromProtocolTimestamp(
- decision.request.new_rules.expiration_time,
- )}
- rules={decision.request.new_rules.rules}
- startOpen
- measure={decision.request.new_rules.successor_measure ?? ""}
- />
- </div>
- );
-}
-
-function ShowMesaureInfo({ nextMeasures }: { nextMeasures: string[] }): VNode {
- const { i18n } = useTranslationContext();
- const measures = useServerMeasures();
- if (!measures) {
- return <Loading />;
- }
- if (measures instanceof TalerError) {
- return <ErrorLoadingWithDebug error={measures} />;
- }
- if (measures.type === "fail") {
- switch (measures.case) {
- case HttpStatusCode.Forbidden:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- This account signature is invalid, contact administrator or
- create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.NotFound:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not known, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.Conflict:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not enabled, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- default:
- assertUnreachable(measures);
- }
- }
- const filteredMeasures = nextMeasures.filter((n) => !!n && !!n.trim());
- const allMeasures = computeAvailableMesaures(
- measures.body,
- // cm,
- (m) => filteredMeasures.indexOf(m.name) === -1,
- );
-
- if (!filteredMeasures.length) {
- return <Fragment />;
- }
- if (filteredMeasures.length === 1) {
- return (
- <div>
- <i18n.Translate>
- The customer needs to complete this measure
- </i18n.Translate>
- <CurrentMeasureTable measures={allMeasures} />
- </div>
- );
- }
- return (
- <div>
- <i18n.Translate>
- The customer needs to complete all of these measures
- </i18n.Translate>
- <CurrentMeasureTable measures={allMeasures} />
- </div>
- );
-}
-
-export function ShowDecisionLimitInfo({
- rules,
- since,
- until,
- startOpen,
- justification,
- fixed,
- measure,
-}: {
- since: AbsoluteTime;
- until: AbsoluteTime;
- justification?: string;
- rules: KycRule[];
- startOpen?: boolean;
- fixed?: boolean;
- measure: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const [opened, setOpened] = useState(startOpen ?? false);
-
- function Header() {
- return (
- <div
- data-fixed={!!fixed}
- class="p-4 relative bg-gray-200 flex justify-between data-[fixed=false]:cursor-pointer"
- onClick={() => {
- if (!fixed) {
- 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-300 inset-y-0 flex items-center px-3">
- <i18n.Translate>Since</i18n.Translate>
- </div>
- <div class="p-2 bg-gray-50 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-50 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-300 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 bg-gray-50 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-50 sm:text-sm sm:leading-6">
- <Time format="dd/MM/yyyy HH:mm:ss" timestamp={until} />
- </div>
- </div>
- {AbsoluteTime.isNever(until) ? undefined : (
- <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 p-2 bg-gray-300 inset-y-0 flex items-center ">
- <i18n.Translate>Successor measure</i18n.Translate>
- </div>
- <div class="p-2 bg-gray-50 rounded-md rounded-l-none data-[left=true]:text-left text-gray-900 placeholder:text-gray-50 sm:text-sm sm:leading-6">
- {measure}
- </div>
- </div>
- )}
- {fixed ? (
- <Fragment />
- ) : (
- <div class="rounded-full bg-gray-50 p-2">
- <svg
- 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 h-6"
- >
- {opened ? (
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m19.5 8.25-7.5 7.5-7.5-7.5"
- />
- ) : (
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m4.5 15.75 7.5-7.5 7.5 7.5"
- />
- )}
- </svg>
- </div>
- )}
- </div>
- </div>
- );
- }
-
- if (!opened) {
- return (
- <div class="overflow-hidden border border-gray-800 rounded-xl">
- <Header />
- </div>
- );
- }
-
- return (
- <div class="overflow-hidden border border-gray-800 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>
- )}
-
- <RulesInfo rules={rules} />
- </div>
- </div>
- );
-}
-
-function AmlStateBadge({ state }: { state: TalerExchangeApi.AmlState }): VNode {
- const { i18n } = useTranslationContext();
- 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">
- <i18n.Translate>Normal</i18n.Translate>
- </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">
- <i18n.Translate>Pending</i18n.Translate>
- </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">
- <i18n.Translate>Frozen</i18n.Translate>
- </span>
- );
- }
- }
- assertUnreachable(state);
-}
-
-function ShowTimeline({
- history,
- account,
- routeToShowCollectedInfo,
-}: {
- account: string;
- routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
- history: TalerExchangeApi.KycAttributeCollectionEvent[];
-}): VNode {
- const { i18n } = useTranslationContext();
- return (
- <div class="flow-root">
- <ul role="list">
- {history.map((e, idx) => {
- const values = e.attributes ?? {};
- const formId = values[TalerFormAttributes.FORM_ID] as
- | string
- | undefined;
-
- return (
- <a
- href={routeToShowCollectedInfo.url({
- cid: account,
- rowId: String(e.rowid),
- })}
- >
- <li key={idx} class="hover:bg-gray-200 p-2 rounded">
- <div class="relative pb-3">
- <span class="absolute left-3 top-5 -ml-px h-full w-1 bg-gray-200"></span>
- <div class="relative flex space-x-3">
- {/* <ArrowDownCircleIcon class="h-8 w-8 text-green-700" /> */}
- <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="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
- />
- </svg>
- {!formId ? undefined : (
- <div>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 0 1-2.25 2.25M16.5 7.5V18a2.25 2.25 0 0 0 2.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 0 0 2.25 2.25h13.5M6 7.5h3v3H6v-3Z"
- />
- </svg>
- <span>{formId}</span>
- </div>
- )}
- <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
- <div class="whitespace-nowrap text-right text-sm text-gray-500">
- {e.collection_time.t_s === "never" ? (
- "never"
- ) : (
- <time
- dateTime={format(
- e.collection_time.t_s * 1000,
- "dd MMM yyyy",
- )}
- >
- {format(
- e.collection_time.t_s * 1000,
- "dd MMM yyyy HH:mm:ss",
- )}
- </time>
- )}
- </div>
- </div>
- </div>
- </div>
- </li>
- </a>
- );
- })}
- <li class="hover:bg-gray-200 p-2 rounded">
- <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
- <svg
- 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 h-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
- />
- </svg>
- <p class="text-sm text-gray-900">
- <i18n.Translate>Now</i18n.Translate>
- </p>
- </div>
- </li>
- </ul>
- </div>
- );
-}
-
-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.config.currency_specification.num_fractional_input_digits
- ) {
- e.currentTarget.value = e.currentTarget.value.substring(
- 0,
- sep_pos +
- config.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;
-} & Omit<Omit<FormMetadata, "icon">, "config">;
-
-type SimpleFormMetadata = {
- version?: number;
- id?: string;
-};
-
-export const codecForSimpleFormMetadata = (): Codec<SimpleFormMetadata> =>
- buildCodecForObject<SimpleFormMetadata>()
- .property("id", codecOptional(codecForString()))
- .property("version", codecOptional(codecForNumber()))
- .build("SimpleFormMetadata");
-
-type ParseJustificationFail =
- | "not-json"
- | "id-not-found"
- | "form-not-found"
- | "version-not-found";
-
-function parseJustification(
- s: string,
- listOfAllKnownForms: FormMetadata[],
-):
- | OperationOk<{
- justification: Justification;
- metadata: FormMetadata;
- }>
- | OperationFail<ParseJustificationFail> {
- try {
- const justification = JSON.parse(s);
- const info = codecForSimpleFormMetadata().decode(justification);
- if (!info.id) {
- return {
- type: "fail",
- case: "id-not-found",
- detail: {} as TalerErrorDetail,
- };
- }
- if (!info.version) {
- return {
- type: "fail",
- case: "version-not-found",
- detail: {} as TalerErrorDetail,
- };
- }
- const found = listOfAllKnownForms.find((f) => {
- return f.id === info.id && f.version === info.version;
- });
- if (!found) {
- return {
- type: "fail",
- case: "form-not-found",
- detail: {} as TalerErrorDetail,
- };
- }
- return opFixedSuccess({
- justification,
- metadata: found,
- });
- } catch (e) {
- return {
- type: "fail",
- case: "not-json",
- detail: {} as TalerErrorDetail,
- };
- }
-}
-
-const THRESHOLD_2000_WEEK: (currency: string) => TalerExchangeApi.KycRule[] = (
- currency,
-) => [
- {
- operation_type: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.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: LimitOperationType.withdraw,
- threshold: `${currency}:0`,
- timeframe: {
- d_us: "forever",
- },
- measures: ["verboten"],
- display_priority: 1,
- exposed: true,
- is_and_combinator: true,
- },
- {
- operation_type: LimitOperationType.deposit,
- threshold: `${currency}:0`,
- timeframe: {
- d_us: "forever",
- },
- measures: ["verboten"],
- display_priority: 1,
- exposed: true,
- is_and_combinator: true,
- },
- {
- operation_type: LimitOperationType.aggregate,
- threshold: `${currency}:0`,
- timeframe: {
- d_us: "forever",
- },
- measures: ["verboten"],
- display_priority: 1,
- exposed: true,
- is_and_combinator: true,
- },
- {
- operation_type: LimitOperationType.merge,
- threshold: `${currency}:0`,
- timeframe: {
- d_us: "forever",
- },
- measures: ["verboten"],
- display_priority: 1,
- exposed: true,
- is_and_combinator: true,
- },
- {
- operation_type: LimitOperationType.balance,
- threshold: `${currency}:0`,
- timeframe: {
- d_us: "forever",
- },
- measures: ["verboten"],
- display_priority: 1,
- exposed: true,
- is_and_combinator: true,
- },
- {
- operation_type: LimitOperationType.close,
- threshold: `${currency}:0`,
- timeframe: {
- d_us: "forever",
- },
- measures: ["verboten"],
- display_priority: 1,
- exposed: true,
- is_and_combinator: true,
- },
-];
-
-export function ShowMeasuresToSelect({
- onSelect,
-}: {
- onSelect?: (m: MeasureInfo) => void;
-}): VNode {
- const measures = useServerMeasures();
- const { i18n } = useTranslationContext();
- if (!measures) {
- return <Loading />;
- }
- if (measures instanceof TalerError) {
- return <ErrorLoadingWithDebug error={measures} />;
- }
- if (measures.type === "fail") {
- switch (measures.case) {
- case HttpStatusCode.Forbidden:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- This account signature is invalid, contact administrator or
- create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.NotFound:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not known, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.Conflict:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not enabled, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- default:
- assertUnreachable(measures);
- }
- }
-
- return (
- <CurrentMeasureTable
- measures={computeAvailableMesaures(measures.body)}
- onSelect={onSelect}
- />
- );
-}
-
-export function computeAvailableMesaures(
- serverMeasures: TalerExchangeApi.AvailableMeasureSummary | undefined,
- // customMeasures?: Readonly<CustomMeasures>,
- skpiFilter?: (m: MeasureInfo) => boolean,
-): Mesaures {
- const init: Mesaures = { forms: [], procedures: [], info: [] };
- if (!serverMeasures) {
- return init;
- }
- const server = Object.entries(serverMeasures.roots).reduce(
- (prev, [key, value]) => {
- if (value.check_name !== "SKIP") {
- if (!value.prog_name) {
- const r: MeasureInfo = {
- type: "info",
- name: key,
- context: value.context,
- checkName: value.check_name,
- check: serverMeasures.checks[value.check_name],
- custom: true,
- };
- if (skpiFilter && skpiFilter(r)) return prev; // skip
- prev.info.push(r);
- } else {
- const r: MeasureInfo = {
- type: "form",
- name: key,
- context: value.context,
- programName: value.prog_name,
- program: serverMeasures.programs[value.prog_name],
- checkName: value.check_name,
- check: serverMeasures.checks[value.check_name],
- custom: false,
- };
- if (skpiFilter && skpiFilter(r)) return prev; // skip
- prev.forms.push(r);
- }
- } else {
- if (!value.prog_name) {
- console.error(
- `ERROR: program name can't be empty for measure "${key}"`,
- );
- return prev;
- }
- const r: MeasureInfo = {
- type: "procedure",
- name: key,
- context: value.context,
- programName: value.prog_name,
- program: serverMeasures.programs[value.prog_name],
- custom: false,
- };
- if (skpiFilter && skpiFilter(r)) return prev; // skip
- prev.procedures.push(r);
- }
- return prev;
- },
- init,
- );
-
- return server;
-}
diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx
@@ -1,161 +0,0 @@
-/*
- 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,
- AmountJson,
- Amounts,
- HttpStatusCode,
- TalerExchangeApi,
- TalerProtocolTimestamp,
- assertUnreachable,
-} from "@gnu-taler/taler-util";
-import {
- Button,
- FormUI,
- LocalNotificationBanner,
- useExchangeApiContext,
- useForm,
- useLocalNotificationHandler,
- useTranslationContext
-} from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
-import { privatePages } from "../Routing.js";
-import { useUiFormsContext } from "../context/ui-forms.js";
-import { useOfficer } from "../hooks/officer.js";
-import { Justification } from "./CaseDetails.js";
-import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
-
-type FormType = {
- when: AbsoluteTime;
- state: TalerExchangeApi.AmlState;
- threshold: AmountJson;
- comment: string;
-};
-
-export function CaseUpdate({
- account,
- type: formId,
-}: {
- account: string;
- type: string;
-}): VNode {
- const { i18n } = useTranslationContext();
- const officer = useOfficer();
- const {
- lib: { exchange: api },
- } = useExchangeApiContext();
-
- const [notification, withErrorHandler] = useLocalNotificationHandler();
- const { config } = useExchangeApiContext();
-
- const initial: FormType = {
- when: AbsoluteTime.now(),
- state: TalerExchangeApi.AmlState.pending,
- threshold: Amounts.zeroOfCurrency(config.config.currency),
- comment: "",
- };
-
- if (officer.state !== "ready") {
- return <HandleAccountNotReady officer={officer} />;
- }
- const theForm: any = undefined; //searchForm(i18n, forms, formId);
- if (!theForm) {
- return <div>form with id {formId} not found</div>;
- }
-
- const form = useForm<FormType>(theForm.config, initial);
-
- const validatedForm =
- form.status.status !== "ok" ? undefined : form.status.result;
-
- const submitHandler =
- validatedForm === undefined
- ? undefined
- : withErrorHandler(
- () => {
- const justification: Justification = {
- id: theForm.id,
- label: theForm.label,
- version: theForm.version,
- value: validatedForm,
- };
-
- 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.makeAmlDesicion(officer.account, decision);
- },
- () => {
- window.location.href = privatePages.profile.url({});
- },
- (fail) => {
- switch (fail.case) {
- case HttpStatusCode.Forbidden:
- return i18n.str`Wrong credentials for "${officer.account}"`;
- 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);
- }
- },
- );
-
- return (
- <Fragment>
- <LocalNotificationBanner notification={notification} />
- <div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
- <FormUI design={theForm.config} model={form.model} />
- </div>
-
- <div class="mt-6 flex items-center justify-end gap-x-6">
- <a
- href={privatePages.caseDetails.url({ cid: account })}
- class="text-sm font-semibold leading-6 text-gray-900"
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </a>
- <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>Submit</i18n.Translate>
- </Button>
- </div>
- </Fragment>
- );
-}
-
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx b/packages/aml-backoffice-ui/src/pages/Cases.stories.tsx
@@ -1,27 +0,0 @@
-/*
- 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/>
- */
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-import * as tests from "@gnu-taler/web-util/testing";
-import { Accounts as TestedComponent } from "./Cases.js";
-
-export default {
- title: "cases",
-};
diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx
@@ -1,438 +0,0 @@
-/*
- 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 {
- HttpStatusCode,
- TalerError,
- assertUnreachable,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- InputToggle,
- Loading,
- RouteDefinition,
- useExchangeApiContext,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, VNode, h } from "preact";
-import { useCurrentDecisions } from "../hooks/decisions.js";
-
-import { useState, useEffect } from "preact/hooks";
-import { privatePages } from "../Routing.js";
-import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
-import { Officer } from "./Officer.js";
-import { useOfficer } from "../hooks/officer.js";
-
-type FormType = {
- // state: TalerExchangeApi.AmlState;
-};
-
-export function Accounts({
- routeToCaseById: caseByIdRoute,
-}: {
- routeToCaseById: RouteDefinition<{ cid: string }>;
-}): VNode {
- const { i18n } = useTranslationContext();
- const [filtered, setFiltered] = useState<boolean>();
- const list = useCurrentDecisions({ investigated: filtered });
-
- 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 signature is invalid, contact administrator or
- create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.NotFound:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not known, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.Conflict:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not enabled, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- default:
- assertUnreachable(list);
- }
- }
-
- const records = list.body;
- const onFirstPage = list.isFirstPage ? undefined : list.loadFirst;
- const onNext = list.isLastPage ? undefined : list.loadNext;
-
- return (
- <div>
- <div class="sm:flex sm:items-center">
- {filtered === true ? (
- <div class="px-2 sm:flex-auto">
- <h1 class="text-base font-semibold leading-6 text-gray-900">
- <i18n.Translate>Accounts 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>
- ) : filtered === false ? (
- <div class="px-2 sm:flex-auto">
- <h1 class="text-base font-semibold leading-6 text-gray-900">
- <i18n.Translate>Accounts without 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 active.
- </i18n.Translate>
- </p>
- </div>
- ) : (
- <div class="px-2 sm:flex-auto">
- <h1 class="text-base font-semibold leading-6 text-gray-900">
- <i18n.Translate>Accounts</i18n.Translate>
- </h1>
- <p class="mt-2 text-sm text-gray-700 w-80">
- <i18n.Translate>
- A list of all the accounts known to the exchange.
- </i18n.Translate>
- </p>
- </div>
- )}
-
- <JumpByIdForm
- caseByIdRoute={caseByIdRoute}
- filtered={filtered}
- onTog={setFiltered}
- />
- </div>
- <div class="mt-8 flow-root">
- <div class="overflow-x-auto">
- {!records.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>Account Id</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">
- {records.map((r) => {
- return (
- <tr key={r.h_payto} class="hover:bg-gray-100 ">
- <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 ">
- <div class="text-gray-900">
- <a
- href={privatePages.caseDetails.url({
- cid: r.h_payto,
- })}
- class="text-indigo-600 hover:text-indigo-900 font-mono"
- >
- {r.h_payto}
- </a>
- </div>
- </td>
- <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900">
- {r.to_investigate ? (
- <span title="require investigation">
- <ToInvestigateIcon />
- </span>
- ) : undefined}
- </td>
- </tr>
- );
- })}
- </tbody>
- </table>
- <Pagination onFirstPage={onFirstPage} onNext={onNext} />
- </div>
- )}
- </div>
- </div>
- </div>
- );
-}
-
-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 TransfersIcon = () => (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
- />
- </svg>
-);
-
-export const PeopleIcon = () => (
- <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="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
- />
- </svg>
-);
-
-export const HomeIcon = () => (
- <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="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
- />
- </svg>
-);
-export const FormIcon = () => (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="currentColor"
- class="w-6 h-6"
- >
- <path
- fillRule="evenodd"
- d="M1.5 5.625c0-1.036.84-1.875 1.875-1.875h17.25c1.035 0 1.875.84 1.875 1.875v12.75c0 1.035-.84 1.875-1.875 1.875H3.375A1.875 1.875 0 0 1 1.5 18.375V5.625ZM21 9.375A.375.375 0 0 0 20.625 9h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5Zm0 3.75a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5Zm0 3.75a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5a.375.375 0 0 0 .375-.375v-1.5ZM10.875 18.75a.375.375 0 0 0 .375-.375v-1.5a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375h7.5ZM3.375 15h7.5a.375.375 0 0 0 .375-.375v-1.5a.375.375 0 0 0-.375-.375h-7.5a.375.375 0 0 0-.375.375v1.5c0 .207.168.375.375.375Zm0-3.75h7.5a.375.375 0 0 0 .375-.375v-1.5A.375.375 0 0 0 10.875 9h-7.5A.375.375 0 0 0 3 9.375v1.5c0 .207.168.375.375.375Z"
- clipRule="evenodd"
- />
- </svg>
-);
-
-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,
-}: {
- onFirstPage?: () => void;
- onNext?: () => void;
-}) {
- const { i18n } = useTranslationContext();
- return (
- <nav
- class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg"
- aria-label="Pagination"
- >
- <div class="flex flex-1 justify-between sm:justify-end">
- <button
- class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
- disabled={!onFirstPage}
- onClick={onFirstPage}
- >
- <i18n.Translate>First page</i18n.Translate>
- </button>
- <button
- class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
- disabled={!onNext}
- onClick={onNext}
- >
- <i18n.Translate>Next</i18n.Translate>
- </button>
- </div>
- </nav>
- );
-}
-
-let latestTimeout: undefined | ReturnType<typeof setTimeout> = undefined;
-
-function JumpByIdForm({
- caseByIdRoute,
- filtered,
- onTog,
-}: {
- caseByIdRoute: RouteDefinition<{ cid: string }>;
- filtered?: boolean;
- onTog: (d: boolean | undefined) => void;
-}): VNode {
- const { i18n } = useTranslationContext();
- const [account, setAccount] = useState<string>("");
- const officer = useOfficer();
- const session = officer.state === "ready" ? officer.account : undefined;
- const { lib } = useExchangeApiContext();
- const [valid, setValid] = useState(false);
- const [error, setError] = useState<string>();
- useEffect(() => {
- if (!session || !account) return;
- const activeSession = session;
- if (latestTimeout) {
- clearTimeout(latestTimeout);
- }
- setError(undefined)
- setValid(false)
- latestTimeout = setTimeout(async function checkAccouunt() {
- let found = false
- try {
- const result = await lib.exchange.getAmlAttributesForAccount(
- activeSession,
- account,
- { limit: 1 },
- );
- found = (result.type === "ok");
- } catch (e) {
- console.log(e)
- }
- setValid(found)
- if (!found) {
- setError(i18n.str`account not found`);
- }
- }, 500);
- }, [account, session]);
- return (
- <form class="mt-5 grid grid-cols-1">
- <div>
- <div class="flex flex-row">
- <div class="w-full sm:max-w-xs">
- <input
- name="account"
- onChange={(e) => {
- setAccount(e.currentTarget.value);
- }}
- class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6"
- placeholder={i18n.str`Search by ID`}
- />
- </div>
- <a
- href={!valid ? undefined : caseByIdRoute.url({ cid: account })}
- data-disabled={!valid}
- class="data-[disabled=true]:bg-gray-400 mt-3 inline-flex w-full items-center justify-center 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 sm:ml-3 sm:mt-0 sm:w-auto"
- >
- <svg
- 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 h-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
- />
- </svg>
- </a>
- </div>
- {!error ? undefined : <p class="mt-2 text-sm text-red-600">{error}</p>}
- </div>
- <div class="mt-2 cursor-default">
- <InputToggle
- threeState
- name="inv"
- label={i18n.str`Only investigated`}
- handler={{
- name: "inv",
- onChange: (x) => onTog(x),
- value: filtered,
- }}
- />
- </div>
- </form>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx
@@ -1,150 +0,0 @@
-/*
- 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 {
- Button,
- FormDesign,
- FormErrors,
- FormUI,
- FormValues,
- InternationalizationAPI,
- LocalNotificationBanner,
- RecursivePartial,
- UIHandlerId,
- undefinedIfEmpty,
- useForm,
- useLocalNotificationHandler,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { VNode, h } from "preact";
-import { useOfficer } from "../hooks/officer.js";
-import { usePreferences } from "../hooks/preferences.js";
-
-type FormType = {
- password: string;
- repeat: string;
-};
-
-const createAccountForm = (
- i18n: InternationalizationAPI,
- allowInsecurePassword: boolean,
-): FormDesign => ({
- type: "single-column",
- fields: [
- {
- id: "password",
- type: "secret",
- label: i18n.str`Password`,
- required: true,
- validator(value) {
- return !value
- ? i18n.str`required`
- : allowInsecurePassword
- ? undefined
- : value.length < 12
- ? i18n.str`should have at least 12 characters`
- : !value.match(/[a-z]/) && value.match(/[A-Z]/)
- ? i18n.str`should have lowercase and uppercase characters`
- : !value.match(/\d/)
- ? i18n.str`should have numbers`
- : !value.match(/[^a-zA-Z\d]/)
- ? i18n.str`should have at least one character which is not a number or letter`
- : undefined;
- },
- },
- {
- id: "repeat",
- type: "secret",
- label: i18n.str`Repeat password`,
- required: true,
- validator(value) {
- return !value
- ? i18n.str`required`
- : // : state.password !== value
- // ? i18n.str`doesn't match`
- undefined;
- },
- },
- ],
-});
-
-export function CreateAccount(): VNode {
- const { i18n } = useTranslationContext();
- const [settings] = usePreferences();
- const officer = useOfficer();
-
- const [notification, withErrorHandler] = useLocalNotificationHandler();
-
- const design = createAccountForm(i18n, settings.allowInsecurePassword);
-
- const { model: handler, status } = useForm<FormType>(
- design,
- {
- password: undefined,
- repeat: undefined,
- },
- // createFormValidator(i18n, settings.allowInsecurePassword),
- );
-
- const createAccountHandler =
- status.status === "fail" || officer.state !== "not-found"
- ? undefined
- : withErrorHandler(
- async () => officer.create(status.result.password),
- () => {},
- );
- return (
- <div class="flex min-h-full flex-col ">
- <LocalNotificationBanner notification={notification} />
-
- <div class="sm:mx-auto sm:w-full sm:max-w-md">
- <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
- <i18n.Translate>Create account</i18n.Translate>
- </h2>
- </div>
-
- <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] ">
- <FormUI design={design} model={handler} />
- <div class="mt-8">
- <Button
- type="submit"
- disabled={!createAccountHandler}
- class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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"
- handler={createAccountHandler}
- >
- <i18n.Translate>Create</i18n.Translate>
- </Button>
- </div>
- </div>
- </div>
- );
-}
-
-/**
- * Show the element when the load ended
- * @param element
- */
-export function doAutoFocus(element: HTMLElement | null) {
- if (element) {
- setTimeout(() => {
- element.focus({ preventScroll: true });
- element.scrollIntoView({
- behavior: "smooth",
- block: "center",
- inline: "center",
- });
- }, 100);
- }
-}
diff --git a/packages/aml-backoffice-ui/src/pages/Dashboard.tsx b/packages/aml-backoffice-ui/src/pages/Dashboard.tsx
@@ -30,13 +30,13 @@ import {
useExchangeApiContext,
useTranslationContext
} from "@gnu-taler/web-util/browser";
-import { format, sub } from "date-fns";
+import { format } from "date-fns";
import { Fragment, h, VNode } from "preact";
import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
import { useOfficer } from "../hooks/officer.js";
import { usePreferences } from "../hooks/preferences.js";
import { useTopsServerStatistics } from "../hooks/server-info.js";
-import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
+import { HandleAccountNotReady } from "../components/HandleAccountNotReady.js";
export function Dashboard({
routeToDownloadStats,
@@ -377,74 +377,6 @@ function MetricValueNumber({
);
}
-export type Timeframe = { start: AbsoluteTime; end: AbsoluteTime };
-
-export function getTimeframesForDate(
- time: Date,
- timeframe: TalerCorebankApi.MonitorTimeframeParam,
-): {
- current: Timeframe;
- previous: Timeframe;
-} {
- switch (timeframe) {
- case TalerCorebankApi.MonitorTimeframeParam.hour: {
- const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
- AbsoluteTime.fromMilliseconds(
- sub(time, { hours: timeIndex }).getTime(),
- ),
- );
- return {
- current: { start: middle, end: high },
- previous: { start: low, end: middle },
- };
- }
- case TalerCorebankApi.MonitorTimeframeParam.day: {
- const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
- AbsoluteTime.fromMilliseconds(sub(time, { days: timeIndex }).getTime()),
- );
- return {
- current: { start: middle, end: high },
- previous: { start: low, end: middle },
- };
- }
- case TalerCorebankApi.MonitorTimeframeParam.month: {
- const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
- AbsoluteTime.fromMilliseconds(
- sub(time, { months: timeIndex }).getTime(),
- ),
- );
- return {
- current: { start: middle, end: high },
- previous: { start: low, end: middle },
- };
- }
-
- case TalerCorebankApi.MonitorTimeframeParam.year: {
- const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
- AbsoluteTime.fromMilliseconds(
- sub(time, { years: timeIndex }).getTime(),
- ),
- );
- return {
- current: { start: middle, end: high },
- previous: { start: low, end: middle },
- };
- }
- case TalerCorebankApi.MonitorTimeframeParam.decade: {
- const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
- AbsoluteTime.fromMilliseconds(
- sub(time, { years: timeIndex * 10 }).getTime(),
- ),
- );
- return {
- current: { start: middle, end: high },
- previous: { start: low, end: middle },
- };
- }
- default:
- assertUnreachable(timeframe);
- }
-}
function getDateStringForTimeframe(
date: AbsoluteTime,
diff --git a/packages/aml-backoffice-ui/src/pages/DecisionWizard.tsx b/packages/aml-backoffice-ui/src/pages/DecisionWizard.tsx
@@ -0,0 +1,411 @@
+/*
+ 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,
+ parsePaytoUri,
+ PaytoString,
+ PaytoUri,
+ TalerError,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { CopyButton, useTranslationContext } from "@gnu-taler/web-util/browser";
+import { Fragment, h, VNode } from "preact";
+import {
+ DecisionRequest,
+ useCurrentDecisionRequest,
+} from "../hooks/decision-request.js";
+import { Events } from "./decision/Events.js";
+import { Attributes } from "./decision/Information.js";
+import { Justification } from "./decision/Justification.js";
+import { Measures } from "./decision/Measures.js";
+import { Properties } from "./decision/Properties.js";
+import { Rules } from "./decision/Rules.js";
+import { Summary } from "./decision/Summary.js";
+import { useAccountActiveDecision } from "../hooks/decisions.js";
+
+export type WizardSteps =
+ | "attributes" // submit more information
+ | "rules" // define the limits
+ | "measures" // define a new form/challenge
+ | "properties" // define account information
+ | "events" // define events to trigger
+ | "justification" // finalize, investigate?;
+ | "summary";
+
+const STEPS_ORDER: WizardSteps[] = [
+ "attributes",
+ "rules",
+ "properties",
+ "events",
+ "measures",
+ "justification",
+ "summary",
+];
+
+const STEPS_ORDER_MAP = STEPS_ORDER.reduce(
+ (map, cur, idx, steps) => {
+ map[cur] = {
+ prev: idx === 0 ? undefined : steps[idx - 1],
+ next: idx === steps.length ? undefined : steps[idx + 1],
+ };
+ return map;
+ },
+ {} as {
+ [s in WizardSteps]: {
+ next: WizardSteps | undefined;
+ prev: WizardSteps | undefined;
+ };
+ },
+);
+
+export function isRulesCompleted(request: DecisionRequest): boolean {
+ return request.rules !== undefined && request.deadline !== undefined;
+}
+export function isAttributesCompleted(request: DecisionRequest): boolean {
+ return (
+ request.attributes === undefined || request.attributes.errors === undefined
+ );
+}
+export function isPropertiesCompleted(request: DecisionRequest): boolean {
+ return request.properties !== undefined && request.properties_errors === undefined;
+}
+export function isEventsCompleted(request: DecisionRequest): boolean {
+ return request.custom_events !== undefined;
+}
+export function isMeasuresCompleted(request: DecisionRequest): boolean {
+ return request.new_measures !== undefined;
+}
+export function isJustificationCompleted(request: DecisionRequest): boolean {
+ return request.keep_investigating !== undefined && !!request.justification;
+}
+export function isJustificationCompletedForNewACcount(
+ request: DecisionRequest,
+): boolean {
+ return (
+ request.keep_investigating !== undefined &&
+ !!request.justification &&
+ !!request.accountName
+ );
+}
+
+export function DecisionWizard({
+ account,
+ newPayto,
+ step,
+ formId,
+ onMove,
+}: {
+ account: string;
+ newPayto?: PaytoString;
+ formId: string | undefined;
+ step?: WizardSteps;
+ onMove: (n: WizardSteps | undefined) => void;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const stepOrDefault = step ?? STEPS_ORDER[0];
+ const content = (function () {
+ switch (stepOrDefault) {
+ case "rules":
+ return <Rules newPayto={newPayto} />;
+ case "properties":
+ return <Properties />;
+ case "events":
+ return <Events />;
+ case "measures":
+ return <Measures />;
+ case "justification":
+ return <Justification newPayto={newPayto} />;
+ case "attributes":
+ return <Attributes formId={formId} />;
+ case "summary":
+ return (
+ <Summary account={account} onMove={onMove} newPayto={newPayto} />
+ );
+ }
+ assertUnreachable(stepOrDefault);
+ })();
+
+ 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">
+ <Header account={account} newPayto={newPayto} />
+ <div>{account}</div>
+ <CopyButton class="" getContent={() => account} />
+ </header>
+
+ <WizardSteps
+ step={stepOrDefault}
+ onMove={onMove}
+ newAccount={!!newPayto}
+ />
+ <button
+ disabled={!STEPS_ORDER_MAP[stepOrDefault].prev}
+ onClick={() => {
+ onMove(STEPS_ORDER_MAP[stepOrDefault].prev);
+ }}
+ class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ >
+ <i18n.Translate>Prev</i18n.Translate>
+ </button>
+ <button
+ disabled={!STEPS_ORDER_MAP[stepOrDefault].next}
+ onClick={() => {
+ onMove(STEPS_ORDER_MAP[stepOrDefault].next);
+ }}
+ class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ {content}
+ </div>
+ );
+}
+function WizardSteps({
+ step: currentStep,
+ onMove,
+ newAccount,
+}: {
+ step: WizardSteps;
+ onMove: (n: WizardSteps | undefined) => void;
+ newAccount: boolean;
+}): VNode {
+ const [request] = useCurrentDecisionRequest();
+ const { i18n } = useTranslationContext();
+ const STEP_INFO: {
+ [s in WizardSteps]: {
+ label: TranslatedString;
+ description: TranslatedString;
+ isCompleted: (r: DecisionRequest) => boolean;
+ };
+ } = {
+ attributes: {
+ label: i18n.str`Attributes`,
+ description: i18n.str`Add more information about the customer`,
+ isCompleted: isAttributesCompleted,
+ },
+ rules: {
+ label: i18n.str`Rules`,
+ description: i18n.str`Set the limit of the operations`,
+ isCompleted: isRulesCompleted,
+ },
+ events: {
+ label: i18n.str`Events`,
+ description: i18n.str`Trigger notifications.`,
+ isCompleted: isEventsCompleted,
+ },
+ measures: {
+ label: i18n.str`Measures`,
+ description: i18n.str`Ask the customer to take action.`,
+ isCompleted: isMeasuresCompleted,
+ },
+ justification: {
+ label: i18n.str`Justification`,
+ description: i18n.str`Describe the decision.`,
+ isCompleted: newAccount
+ ? isJustificationCompletedForNewACcount
+ : isJustificationCompleted,
+ },
+ properties: {
+ label: i18n.str`Properties`,
+ description: i18n.str`Flag the current account state.`,
+ isCompleted: isPropertiesCompleted,
+ },
+ summary: {
+ label: i18n.str`Summary`,
+ description: i18n.str`Review and submit.`,
+ isCompleted: () => false,
+ },
+ };
+ return (
+ <div class="lg:border-b lg:border-t lg:border-gray-200">
+ <nav class="mx-auto max-w-7xl " aria-label="Progress">
+ <ol
+ role="list"
+ class="overflow-hidden rounded-md lg:flex lg:rounded-none lg:border-l lg:border-r lg:border-gray-200"
+ >
+ {STEPS_ORDER.map((stepLabel) => {
+ const info = STEP_INFO[stepLabel];
+ const st = info.isCompleted(request)
+ ? "completed"
+ : currentStep === stepLabel
+ ? "current"
+ : "incomplete";
+
+ const pos = !STEPS_ORDER_MAP[stepLabel].prev
+ ? "first"
+ : !STEPS_ORDER_MAP[stepLabel].next
+ ? "last"
+ : "middle";
+
+ return (
+ <li class="relative lg:flex-1">
+ <div
+ data-pos={pos}
+ class="overflow-hidden data-[pos=first]:rounded-t-md border data-[pos=first]:border-b-0 border-gray-200 lg:border-0"
+ >
+ {currentStep === stepLabel ? (
+ <span
+ class="absolute left-0 top-0 h-full w-1 bg-indigo-600 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full"
+ aria-hidden="true"
+ ></span>
+ ) : undefined}
+ <button
+ aria-current="step"
+ class="group"
+ onClick={() => {
+ onMove(stepLabel);
+ }}
+ >
+ <span
+ data-status={st}
+ class="absolute left-0 top-0 h-full w-1 data-[status=current]:bg-indigo-600 data-[status=current]:bg-transparent group-hover:bg-gray-200 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full"
+ aria-hidden="true"
+ ></span>
+ <div>
+ <span class="flex items-start px-4 pt-4 text-sm font-medium">
+ <span class="shrink-0">
+ <span
+ data-status={st}
+ class="flex size-6 items-center justify-center rounded-full data-[status=completed]:bg-indigo-600 border-2 data-[status=current]:border-indigo-600 data-[status=incomplete]:border-gray-300"
+ >
+ <svg
+ class="size-4 text-white "
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ aria-hidden="true"
+ data-slot="icon"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </span>
+ </span>
+ <span
+ data-status={st}
+ class="ml-4 data-[status=current]:text-indigo-600"
+ >
+ {info.label}
+ </span>
+ </span>
+ </div>
+ <div class="p-2 text-start">
+ <span class="ml-4 mt-0.5 flex min-w-0 flex-col">
+ <span
+ data-current={currentStep === stepLabel}
+ class="text-sm font-medium data-[current=true]:text-indigo-600"
+ ></span>
+ <span class="text-sm font-medium text-gray-500">
+ {info.description}
+ </span>
+ </span>
+ </div>
+ </button>
+ {pos === "first" ? undefined : (
+ <div
+ data-pos={pos}
+ class="absolute inset-0 left-0 top-0 hidden w-2 lg:block"
+ aria-hidden="true"
+ >
+ <svg
+ data-pos={pos}
+ class="size-full text-gray-300 data-[pos=middle]:h-full data-[pos=middle]:w-full"
+ viewBox="0 0 12 82"
+ fill="none"
+ preserveAspectRatio="none"
+ >
+ <path
+ d="M0.5 0V31L10.5 41L0.5 51V82"
+ stroke="currentcolor"
+ vector-effect="non-scaling-stroke"
+ />
+ </svg>
+ </div>
+ )}
+ </div>
+ </li>
+ );
+ })}
+ </ol>
+ </nav>
+ </div>
+ );
+}
+
+function Header({
+ newPayto,
+ account,
+}: {
+ account: string;
+ newPayto: PaytoString | undefined;
+}): VNode {
+ const { i18n } = useTranslationContext();
+ const isNewAccount = !!newPayto;
+
+ let newPaytoParsed: PaytoUri | undefined;
+ const isNewAccountAWallet =
+ newPayto === undefined
+ ? undefined
+ : (newPaytoParsed = parsePaytoUri(newPayto)) === undefined
+ ? undefined
+ : newPaytoParsed.isKnown &&
+ (newPaytoParsed.targetType === "taler-reserve" ||
+ newPaytoParsed.targetType === "taler-reserve-http");
+
+ const activeDecision = useAccountActiveDecision(
+ isNewAccount ? undefined : account,
+ );
+
+ const info =
+ !activeDecision ||
+ activeDecision instanceof TalerError ||
+ activeDecision.type === "fail"
+ ? undefined
+ : activeDecision.body;
+
+ if (!info && !isNewAccount) {
+ <h1 class="text-base font-semibold leading-7 text-black">
+ <i18n.Translate>loading... </i18n.Translate>
+ </h1>;
+ }
+ // info may be undefined if this is a new account
+ // for which we use the payto:// parameter
+ const isWallet = info?.is_wallet ?? isNewAccountAWallet;
+
+ if (isWallet === undefined) {
+ return (
+ <h1 class="text-base font-semibold leading-7 text-black">
+ <i18n.Translate>Decision for account: </i18n.Translate>
+ </h1>
+ );
+ }
+ if (isWallet) {
+ return (
+ <h1 class="text-base font-semibold leading-7 text-black">
+ <i18n.Translate>Decision for wallet: </i18n.Translate>
+ </h1>
+ );
+ } else {
+ return (
+ <h1 class="text-base font-semibold leading-7 text-black">
+ <i18n.Translate>Decision for bank account: </i18n.Translate>
+ </h1>
+ );
+ }
+}
diff --git a/packages/aml-backoffice-ui/src/pages/MeasureList.tsx b/packages/aml-backoffice-ui/src/pages/MeasureList.tsx
@@ -1,123 +0,0 @@
-/*
- 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 {
- assertUnreachable,
- HttpStatusCode,
- TalerError,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- Loading,
- RouteDefinition,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, h } from "preact";
-import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
-import { useServerMeasures } from "../hooks/server-info.js";
-import { computeAvailableMesaures } from "./CaseDetails.js";
-import { CurrentMeasureTable } from "./MeasuresTable.js";
-import { Officer } from "./Officer.js";
-
-export function MeasureList({ routeToNew }: { routeToNew: RouteDefinition }) {
- const { i18n } = useTranslationContext();
-
- const measures = useServerMeasures();
- // const [custom] = useCustomMeasures();
-
- if (!measures) {
- return <Loading />;
- }
- if (measures instanceof TalerError) {
- return <ErrorLoadingWithDebug error={measures} />;
- }
-
- if (measures.type === "fail") {
- switch (measures.case) {
- case HttpStatusCode.Forbidden:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- This account signature is invalid, contact administrator or
- create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.NotFound:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not known, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- case HttpStatusCode.Conflict:
- return (
- <Fragment>
- <Attention type="danger" title={i18n.str`Operation denied`}>
- <i18n.Translate>
- The designated AML account is not enabled, contact administrator
- or create a new one.
- </i18n.Translate>
- </Attention>
- <Officer />
- </Fragment>
- );
- default:
- assertUnreachable(measures);
- }
- }
-
- const ms = computeAvailableMesaures(
- measures.body,
- // , custom
- );
-
- return (
- <div>
- <div class="px-4 sm:px-6 lg:px-8">
- <div class="sm:flex sm:items-center">
- <div class="sm:flex-auto">
- <h1 class="text-base font-semibold text-gray-900">
- <i18n.Translate>Measures</i18n.Translate>
- </h1>
- <p class="mt-2 text-sm text-gray-700">
- <i18n.Translate>
- A list of all the pre-define measures in your that can used.
- </i18n.Translate>
- </p>
- </div>
- <div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
- <a
- href={routeToNew.url({})}
- class="block rounded-md bg-indigo-600 px-3 py-2 text-center 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>Add custom measure</i18n.Translate>
- </a>
- </div>
- </div>
-
- <CurrentMeasureTable measures={ms} />
- </div>
- </div>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/pages/NewMeasure.tsx b/packages/aml-backoffice-ui/src/pages/NewMeasure.tsx
@@ -1,994 +0,0 @@
-import {
- AmlProgramRequirement,
- assertUnreachable,
- AvailableMeasureSummary,
- KycCheckInformation,
- KycRule,
- TalerError,
- TranslatedString,
-} from "@gnu-taler/taler-util";
-import {
- design_challenger_email,
- design_challenger_phone,
- design_challenger_postal,
- ErrorsSummary,
- form_challenger_email,
- FormDesign,
- FormUI,
- InputToggle,
- InternationalizationAPI,
- RecursivePartial,
- useForm,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
-import { useCurrentDecisionRequest } from "../hooks/decision-request.js";
-import { useServerMeasures } from "../hooks/server-info.js";
-import { useState } from "preact/hooks";
-
-export type MeasureDefinition = {
- name: string;
- program: string;
- check: string;
- context: {
- key: string;
- type: "string" | "number" | "boolean" | "json";
- value: string;
- }[];
-};
-
-type VerificationMeasureDefinition = {
- name: string;
- readOnly: boolean;
- address: any;
-};
-
-/**
- * Defined new limits for the account
- * @param param0
- * @returns
- */
-export function NewMeasure({
- initial,
- isNew,
- onCancel,
- onAdded,
- onChanged,
- onRemoved,
-}: {
- initial?: Partial<MeasureDefinition>;
- isNew?: boolean;
- onCancel: () => void;
- onAdded: (name: string) => void;
- onChanged: (name: string) => void;
- onRemoved: (name: string) => void;
-}): VNode {
- const measures = useServerMeasures();
- const { i18n } = useTranslationContext();
-
- const summary =
- !measures || measures instanceof TalerError || measures.type === "fail"
- ? undefined
- : measures.body;
-
- if (!summary) {
- return (
- <div>
- <i18n.Translate>loading...</i18n.Translate>
- </div>
- );
- }
-
- return (
- <MeasureForm
- summary={summary}
- initial={initial}
- onCancel={onCancel}
- onAdded={onAdded}
- onChanged={onChanged}
- onRemoved={onRemoved}
- addingNew={isNew}
- />
- );
-}
-
-function NormalMeasureForm({
- summary,
- onCancel,
- onAdded,
- onChanged,
- onRemoved,
- initial,
- addingNew,
-}: {
- initial?: Partial<MeasureDefinition>;
- addingNew?: boolean;
- summary: AvailableMeasureSummary;
- onCancel: () => void;
- onAdded: (name: string) => void;
- onChanged: (name: string) => void;
- onRemoved: (name: string) => void;
-}): VNode {
- const [request, updateRequest] = useCurrentDecisionRequest();
- const { i18n } = useTranslationContext();
-
- const names = {
- measures: Object.entries(summary.roots).map(([key, value]) => ({
- key,
- value,
- })),
- programs: Object.entries(summary.programs).map(([key, value]) => ({
- key,
- value,
- })),
- checks: Object.entries(summary.checks).map(([key, value]) => ({
- key,
- value,
- })),
- };
-
- const design = formDesign(
- i18n,
- names.programs,
- names.checks,
- summary,
- !addingNew,
- );
-
- const form = useForm<MeasureDefinition>(design, initial ?? {});
-
- const name = !form.status.result ? undefined : form.status.result.name;
-
- function addNewCustomMeasure() {
- const newMeasure = form.status.result as MeasureDefinition;
- const currentMeasures = { ...request.custom_measures };
- currentMeasures[newMeasure.name] = {
- check_name: newMeasure.check,
- prog_name: newMeasure.program,
- context: (newMeasure.context ?? []).reduce(
- (prev, cur) => {
- prev[cur.key] = getContextValueByType(cur.type, cur.value);
- return prev;
- },
- {} as Record<string, any>,
- ),
- };
- updateRequest("add new measure", {
- custom_measures: currentMeasures,
- });
- if (onAdded) {
- onAdded(newMeasure.name);
- }
- }
-
- function updateCurrentCustomMeasure() {
- const newMeasure = form.status.result as MeasureDefinition;
-
- const CURRENT_MEASURES = { ...request.custom_measures };
- CURRENT_MEASURES[newMeasure.name] = {
- check_name: newMeasure.check,
- prog_name: newMeasure.program,
- context: (newMeasure.context ?? []).reduce(
- (prev, cur) => {
- prev[cur.key] = getContextValueByType(cur.type, cur.value);
- return prev;
- },
- {} as Record<string, any>,
- ),
- };
- updateRequest("update measure", {
- custom_measures: CURRENT_MEASURES,
- });
- if (onChanged) {
- onChanged(newMeasure.name);
- }
- }
-
- function removeCustomMeasure() {
- const currentMeasures = { ...request.custom_measures };
- delete currentMeasures[name!];
- updateRequest("remove measure", {
- custom_measures: currentMeasures,
- });
- if (onRemoved) {
- onRemoved(name!);
- }
- }
-
- return (
- <Fragment>
- <FormUI design={design} model={form.model} />
-
- <button
- onClick={() => {
- onCancel();
- }}
- class="m-4 rounded-md w-fit border-1 px-3 py-2 text-center text-sm shadow-sm "
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </button>
-
- {addingNew ? (
- <button
- disabled={form.status.status === "fail"}
- onClick={addNewCustomMeasure}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Add</i18n.Translate>
- </button>
- ) : (
- <Fragment>
- <button
- disabled={form.status.status === "fail"}
- onClick={updateCurrentCustomMeasure}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Update</i18n.Translate>
- </button>
-
- <button
- onClick={removeCustomMeasure}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Remove</i18n.Translate>
- </button>
- </Fragment>
- )}
-
- <DescribeMeasure measure={form.status.result} summary={summary} />
- </Fragment>
- );
-}
-function VerificationMeasureForm({
- summary,
- onCancel,
- onAdded,
- onChanged,
- onRemoved,
- initial,
- addingNew,
- challengeType,
-}: {
- initial?: Partial<MeasureDefinition>;
- addingNew?: boolean;
- summary: AvailableMeasureSummary;
- onCancel: () => void;
- onAdded: (name: string) => void;
- onChanged: (name: string) => void;
- onRemoved: (name: string) => void;
- challengeType: "email" | "phone" | "postal";
-}): VNode {
- const [request, updateRequest] = useCurrentDecisionRequest();
- const { i18n } = useTranslationContext();
-
- const design = verificationFormDesign(
- i18n,
- summary,
- !addingNew,
- challengeType,
- );
-
- const initAddr = (initial?.context ?? []).find(
- (d) => d.key === "initial_address",
- );
-
- let readOnly: boolean | undefined;
- let rest = {};
- if (initAddr && initAddr.value) {
- const va = JSON.parse(initAddr.value);
- readOnly = va.read_only;
- delete va.read_only;
- rest = { ...va };
- }
-
- const template: Partial<VerificationMeasureDefinition> = {
- name: initial?.name,
- readOnly,
- address: rest,
- };
-
- const form = useForm<VerificationMeasureDefinition>(design, template ?? {});
-
- // const name = !form.status.result ? undefined : form.status.result.name;
-
- if (!initial) {
- throw Error("verification doesn't have initial value");
- }
- if (!initial.check) {
- throw Error("verification doesn't have check");
- }
- if (!initial.program) {
- throw Error("verification doesn't have program");
- }
- if (!initial.context) {
- throw Error("verification doesn't have program");
- }
- if (!initial.name) {
- throw Error("verification doesn't have name");
- }
-
- const check_name = initial.check;
- const measure_name = initial.name;
- const prog_name = initial.program;
- const context = initial.context.reduce(
- (prev, cur) => {
- prev[cur.key] = getContextValueByType(cur.type, cur.value);
- return prev;
- },
- {} as Record<string, any>,
- );
-
- function addNewCustomMeasure() {
- const newMeasure = form.status.result as VerificationMeasureDefinition;
- const currentMeasures = { ...request.custom_measures };
- delete currentMeasures[measure_name];
-
- currentMeasures[newMeasure.name] = {
- check_name,
- prog_name,
- context: {
- ...context,
- initial_address: {
- read_only: newMeasure.readOnly,
- ...newMeasure.address,
- },
- },
- };
- updateRequest("add new measure", {
- custom_measures: currentMeasures,
- });
- if (onAdded) {
- onAdded(newMeasure.name);
- }
- }
-
- function updateCurrentCustomMeasure() {
- const newMeasure = form.status.result as VerificationMeasureDefinition;
-
- const CURRENT_MEASURES = { ...request.custom_measures };
- CURRENT_MEASURES[newMeasure.name] = {
- check_name,
- prog_name,
- context: {
- ...context,
- initial_address: {
- read_only: newMeasure.readOnly,
- ...newMeasure.address,
- },
- },
- };
- updateRequest("update measure", {
- custom_measures: CURRENT_MEASURES,
- });
- if (onChanged) {
- onChanged(newMeasure.name);
- }
- }
-
- function removeCustomMeasure() {
- const newMeasure = form.status.result as VerificationMeasureDefinition;
- const currentMeasures = { ...request.custom_measures };
- delete currentMeasures[newMeasure.name];
- updateRequest("remove measure", {
- custom_measures: currentMeasures,
- });
- if (onRemoved) {
- onRemoved(name!);
- }
- }
-
- return (
- <Fragment>
- <FormUI design={design} model={form.model} />
-
- <button
- onClick={() => {
- onCancel();
- }}
- class="m-4 rounded-md w-fit border-1 px-3 py-2 text-center text-sm shadow-sm "
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </button>
-
- {addingNew ? (
- <button
- disabled={form.status.status === "fail"}
- onClick={addNewCustomMeasure}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Add</i18n.Translate>
- </button>
- ) : (
- <Fragment>
- <button
- disabled={form.status.status === "fail"}
- onClick={updateCurrentCustomMeasure}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Update</i18n.Translate>
- </button>
-
- <button
- onClick={removeCustomMeasure}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Remove</i18n.Translate>
- </button>
- </Fragment>
- )}
-
- <DescribeMeasure measure={form.status.result} summary={summary} />
- </Fragment>
- );
-}
-
-export function MeasureForm({
- summary,
- onCancel,
- onAdded,
- onChanged,
- onRemoved,
- initial,
- addingNew,
-}: {
- initial?: Partial<MeasureDefinition>;
- addingNew?: boolean;
- summary: AvailableMeasureSummary;
- onCancel: () => void;
- onAdded: (name: string) => void;
- onChanged: (name: string) => void;
- onRemoved: (name: string) => void;
-}) {
- const challengeType = (initial?.context ?? []).find(
- (c) => c.key === "challenge-type",
- );
- const measureIsVerificationType = challengeType !== undefined;
- const [formType, setFormType] = useState<"verification" | "normal">(
- measureIsVerificationType ? "verification" : "normal",
- );
-
- const { i18n } = useTranslationContext();
-
- switch (formType) {
- case "verification": {
- const cType = JSON.parse(challengeType?.value as any)
- return (
- <div>
- <h2 class="mt-4 mb-2">
- <i18n.Translate>Configure verification type: {cType}</i18n.Translate>
- </h2>
- <div>
- <button
- onClick={async () => {
- setFormType("normal");
- }}
- 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>Show complete form</i18n.Translate>
- </button>
- </div>
-
- <VerificationMeasureForm
- onAdded={onAdded}
- onCancel={onCancel}
- onChanged={onChanged}
- onRemoved={onRemoved}
- summary={summary}
- addingNew={addingNew}
- initial={initial}
- challengeType={cType}
- />
- </div>
- );
- }
- case "normal": {
- return (
- <div>
- <h2 class="mt-4 mb-2">
- <i18n.Translate>Configure measure</i18n.Translate>
- </h2>
- {measureIsVerificationType ? (
- <div>
- <button
- onClick={async () => {
- setFormType("verification");
- }}
- 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>Show as verification</i18n.Translate>
- </button>
- </div>
- ) : undefined}
-
- <NormalMeasureForm
- onAdded={onAdded}
- onCancel={onCancel}
- onChanged={onChanged}
- onRemoved={onRemoved}
- summary={summary}
- addingNew={addingNew}
- initial={initial}
- />
- </div>
- );
- }
- default: {
- assertUnreachable(formType);
- }
- }
-}
-
-const formDesign = (
- i18n: InternationalizationAPI,
- programs: { key: string; value: AmlProgramRequirement }[],
- checks: { key: string; value: KycCheckInformation }[],
- summary: AvailableMeasureSummary,
- cantChangeName: boolean,
-): FormDesign => ({
- type: "single-column",
- fields: [
- {
- id: "name",
- type: "text",
- required: true,
- disabled: cantChangeName,
- label: i18n.str`Name`,
- validator(value) {
- return !value
- ? i18n.str`required`
- : summary.roots[value]
- ? i18n.str`There is already a measure with that name`
- : undefined;
- },
- },
- {
- type: "selectOne",
- id: "program",
- label: i18n.str`Program`,
- choices: programs.map((m) => {
- return {
- value: m.key,
- label: m.key,
- };
- }),
- help: i18n.str`Only required when no check is specified`,
- validator(value, form) {
- return !value
- ? !form.check
- ? i18n.str`Missing check or program`
- : undefined
- : programAndCheckMatch(i18n, summary, value, form.check) ??
- programAndContextMatch(i18n, summary, value, form.context);
- },
- },
- {
- type: "selectOne",
- id: "check",
- label: i18n.str`Check`,
- help: i18n.str`Without a check the program will run automatically`,
- choices: checks.map((m) => {
- return {
- value: m.key,
- label: m.key,
- };
- }),
- validator(value, form) {
- return checkAndcontextMatch(
- i18n,
- summary,
- value,
- (form.context ?? []) as {
- key: string;
- value: string;
- }[],
- );
- },
- },
- {
- type: "array",
- id: "context",
- label: i18n.str`Context`,
- labelFieldId: "key",
- fields: [
- {
- type: "text",
- id: "key",
- required: true,
- label: i18n.str`Field name`,
- },
- {
- type: "choiceHorizontal",
- id: "type",
- label: i18n.str`Type`,
- required: true,
- choices: [
- {
- label: i18n.str`string`,
- value: "string",
- },
- {
- label: i18n.str`number`,
- value: "number",
- },
- {
- label: i18n.str`boolean`,
- value: "boolean",
- },
- {
- label: i18n.str`json`,
- value: "json",
- },
- ],
- },
- {
- type: "textArea",
- id: "value",
- required: true,
- label: i18n.str`Value`,
- validator(value, form) {
- return validateContextValueByType(i18n, form["type"], value);
- },
- },
- ],
- },
- ],
-});
-
-function programAndCheckMatch(
- i18n: InternationalizationAPI,
- summary: AvailableMeasureSummary,
- progName: string,
- checkName: string | undefined,
-): TranslatedString | undefined {
- const program = summary.programs[progName];
- if (checkName === undefined) {
- if (program.inputs.length > 0) {
- return i18n.str`There are unsatisfied inputs: ${program.inputs.join(
- ", ",
- )}`;
- }
- return undefined;
- }
- const check = summary.checks[checkName];
- const missing = program.inputs.filter((d) => {
- return check.outputs.indexOf(d) === -1;
- });
- if (missing.length > 0) {
- return i18n.str`There are missing inputs: ${missing.join(", ")}`;
- }
- return;
-}
-
-function checkAndcontextMatch(
- i18n: InternationalizationAPI,
- summary: AvailableMeasureSummary,
- checkName: string | undefined,
- context: { key: string; value: string }[] | undefined,
-): TranslatedString | undefined {
- if (checkName === undefined) {
- return undefined;
- }
- const check = summary.checks[checkName];
- const output = !context ? [] : context.map((d) => d.key);
- const missing = check.requires.filter((d) => {
- return output.indexOf(d) === -1;
- });
- if (missing.length > 0) {
- return i18n.str`There are missing requirements: ${missing.join(", ")}`;
- }
- return;
-}
-
-function programAndContextMatch(
- i18n: InternationalizationAPI,
- summary: AvailableMeasureSummary,
- program: string,
- context: { key: string; value: string }[] | undefined,
-): TranslatedString | undefined {
- const check = summary.programs[program];
- const output = !context ? [] : context.map((d) => d.key);
- const missing = check.context.filter((d) => {
- return output.indexOf(d) === -1;
- });
- if (missing.length > 0) {
- return i18n.str`There are missing requirements: ${missing.join(", ")}`;
- }
- return;
-}
-
-function getJsonError(str: string) {
- try {
- JSON.parse(str);
- return undefined;
- } catch (e) {
- if (e instanceof SyntaxError) {
- return e.message;
- }
- return String(e);
- }
-}
-
-// convert the string value of the form into the corresponding type
-// based on the user choice
-// check the function validateContextValueByType
-function getContextValueByType(type: string, value: string) {
- if (type === "number") {
- return Number.parseInt(value, 10);
- }
- if (type === "boolean") {
- return value === "true" ? true : value === "false" ? false : undefined;
- }
- if (type === "json") {
- return JSON.parse(value);
- }
- return value;
-}
-
-const REGEX_NUMER = /^[0-9]*$/;
-
-function validateContextValueByType(
- i18n: InternationalizationAPI,
- type: string,
- value: string,
-) {
- if (!value) return i18n.str`Can't be empty`;
- if (type === "number") {
- const num = Number.parseInt(value, 10);
- return !REGEX_NUMER.test(value)
- ? i18n.str`It should be a number`
- : Number.isNaN(num)
- ? i18n.str`Not a number`
- : !Number.isFinite(num)
- ? i18n.str`It should be finite`
- : !Number.isSafeInteger(num)
- ? i18n.str`It should be a safe integer`
- : undefined;
- }
- if (type === "boolean") {
- if (value === "true" || value === "false") return undefined;
- return i18n.str`It should be either "true" or "false"`;
- }
- if (type === "json") {
- const error = getJsonError(value);
- if (error) {
- return i18n.str`Couldn't parse as json string: ${error}`;
- }
- return undefined;
- }
- return undefined;
-}
-
-function DescribeProgram({
- name,
- program,
-}: {
- name: string;
- program: AmlProgramRequirement;
-}): VNode {
- const { i18n } = useTranslationContext();
- return (
- <div class="rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 ">
- <dl class="flex flex-wrap">
- <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg">
- <dt class="text-sm/6 text-white">
- <i18n.Translate>Program</i18n.Translate>
- </dt>
- <dd class="mt-1 text-base font-semibold text-white">{name}</dd>
- </div>
- <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
- <dt class="flex-none text-gray-500">
- <i18n.Translate>Description</i18n.Translate>
- </dt>
- <dd class="text-sm/6 ">
- <i18n.Translate>{program.description}</i18n.Translate>
- </dd>
- </div>
- <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2">
- <dt class="flex-none text-gray-500">
- <i18n.Translate>Context</i18n.Translate>
- </dt>
- <dd class="text-sm/6 font-medium text-gray-900">
- <pre>{program.context.join(",")}</pre>
- </dd>
- </div>
- <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
- <dt class="flex-none text-gray-500">
- <i18n.Translate>Inputs</i18n.Translate>
- </dt>
- <dd class="text-sm/6 ">
- <pre class="whitespace-pre-wrap">{program.inputs.join(",")}</pre>
- </dd>
- </div>
- </dl>
- <div class="px-4 pb-2"></div>
- </div>
- );
-}
-function DescribeCheck({
- name,
- check,
-}: {
- name: string;
- check: KycCheckInformation;
-}): VNode {
- const { i18n } = useTranslationContext();
- return (
- <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 ">
- <dl class="flex flex-wrap">
- <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg">
- <dt class="text-sm/6 text-white">
- <i18n.Translate>Check</i18n.Translate>
- </dt>
- <dd class="mt-1 text-base font-semibold text-white">{name}</dd>
- </div>
- <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
- <dt class="flex-none text-gray-500">Description</dt>
- <dd class="text-sm/6 ">
- <i18n.Translate>{check.description}</i18n.Translate>
- </dd>
- </div>
- <div class="mt-2 flex w-full flex-none gap-x-4 border-t border-gray-900/5 px-6 pt-2">
- <dt class="flex-none text-gray-500">
- <i18n.Translate>Output</i18n.Translate>
- </dt>
- <dd class="text-sm/6 font-medium ">
- <pre class="whitespace-break-spaces">
- {check.outputs.join(", ")}
- </pre>
- </dd>
- </div>
- <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
- <dt class="flex-none text-gray-500">
- <i18n.Translate>Requires</i18n.Translate>
- </dt>
- <dd class="text-sm/6 ">
- <pre>{check.requires.join(",")}</pre>
- </dd>
- </div>
- <div class="mt-4 flex w-full flex-none gap-x-4 px-6">
- <dt class="flex-none text-gray-500">
- <i18n.Translate>Fallback</i18n.Translate>
- </dt>
- <dd class="text-sm/6 ">
- <pre>{check.fallback}</pre>
- </dd>
- </div>
- </dl>
- <div class="px-4 pb-2"></div>
- </div>
- );
-}
-function DescribeContext({
- context,
-}: {
- context: {
- key: string;
- type: "string" | "number" | "boolean" | "json";
- value: string;
- }[];
-}): VNode {
- const { i18n } = useTranslationContext();
- return (
- <div class="mt-6 rounded-lg bg-gray-150 ring-1 shadow-lg border-indigo-700 border ring-gray-900/5 ">
- <dl class="flex flex-wrap">
- <div class="flex-auto pt-4 pl-4 bg-indigo-600 rounded-t-lg">
- <dt class="text-sm/6 text-white">
- <i18n.Translate>Context</i18n.Translate>
- </dt>
- <dd class="mt-1 text-base font-semibold text-white"></dd>
- </div>
- {context.map(({ key, value }) => {
- return (
- <div key={key} class="mt-4 flex w-full flex-none gap-x-4 px-6">
- <dt class="flex-none text-gray-500">{key}</dt>
- <dd class="text-sm/6 ">
- <i18n.Translate>{value}</i18n.Translate>
- </dd>
- </div>
- );
- })}
- </dl>
- <div class="px-4 pb-2"></div>
- </div>
- );
-}
-function DescribeMeasure({
- measure,
- summary,
-}: {
- measure: RecursivePartial<MeasureDefinition>;
- summary: AvailableMeasureSummary;
-}): VNode {
- const { i18n } = useTranslationContext();
- const programName: string | undefined = measure.program;
- const program: AmlProgramRequirement | undefined =
- !programName || !summary.programs[programName]
- ? undefined
- : summary.programs[programName];
-
- const checkName: string | undefined = measure.check;
- const check =
- !checkName || !summary.checks[checkName]
- ? undefined
- : summary.checks[checkName];
-
- const context =
- !measure || !measure.context
- ? []
- : (measure.context as MeasureDefinition["context"]);
-
- return (
- <Fragment>
- <h2 class="mt-4 mb-2">
- <i18n.Translate>Description</i18n.Translate>
- </h2>
-
- {!program || !programName ? undefined : (
- <DescribeProgram name={programName} program={program} />
- )}
- {!check || !checkName ? undefined : (
- <DescribeCheck name={checkName} check={check} />
- )}
- {!context || !context.length ? undefined : (
- <DescribeContext context={context} />
- )}
- </Fragment>
- );
-}
-
-const verificationFormDesign = (
- i18n: InternationalizationAPI,
- summary: AvailableMeasureSummary,
- cantChangeName: boolean,
- challengeType: "email" | "phone" | "postal",
-): FormDesign => {
- const em =
- challengeType === "email"
- ? design_challenger_email(i18n)
- : challengeType === "phone"
- ? design_challenger_phone(i18n)
- : challengeType === "postal"
- ? design_challenger_postal(i18n)
- : undefined;
-
- if (!em) {
- throw Error(`unkown challenge type ${challengeType} `);
- }
-
- const fields = em.fields.map((f) => {
- f.disabled = false;
- f.required = false;
- if ("id" in f) {
- f.id = `address.${f.id}`;
- }
- return f;
- });
-
- return {
- type: "single-column",
- fields: [
- {
- id: "name",
- type: "text",
- required: true,
- disabled: cantChangeName,
- label: i18n.str`Name`,
- help: i18n.str`Name of the verfication measure`,
- validator(value) {
- return !value
- ? i18n.str`required`
- : summary.roots[value]
- ? i18n.str`There is already a measure with that name`
- : undefined;
- },
- },
- {
- type: "toggle",
- id: "readOnly",
- label: i18n.str`Read only`,
- help: i18n.str`Prevent the customer of changing the address`,
- },
- ...fields,
- ],
- };
-};
diff --git a/packages/aml-backoffice-ui/src/pages/Officer.tsx b/packages/aml-backoffice-ui/src/pages/Officer.tsx
@@ -1,84 +0,0 @@
-/*
- 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 {
- useExchangeApiContext,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { h } from "preact";
-import { useOfficer } from "../hooks/officer.js";
-import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
-import { useUiSettingsContext } from "../context/ui-settings.js";
-
-export function Officer() {
- const officer = useOfficer();
- const settings = useUiSettingsContext();
- const { lib } = useExchangeApiContext();
-
- const { i18n } = useTranslationContext();
- if (officer.state !== "ready") {
- return <HandleAccountNotReady officer={officer} />;
- }
-
- const url = new URL("./", lib.exchange.baseUrl);
- const signupEmail = settings.signupEmail ?? `aml-signup@${url.hostname}`;
-
- return (
- <div>
- <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
- <i18n.Translate>Public key</i18n.Translate>
- </h1>
- <div class="max-w-xl text-base leading-7 text-gray-700 lg:max-w-lg">
- <p class="mt-6 font-mono break-all">{officer.account.id}</p>
- </div>
- <p>
- <a
- href={`mailto:${signupEmail}?subject=${encodeURIComponent(
- "Request AML signup",
- )}&body=${encodeURIComponent(
- `I want my AML account\n\n\nPubKey: ${officer.account.id}`,
- )}`}
- target="_blank"
- rel="noreferrer"
- 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>Request account activation</i18n.Translate>
- </a>
- </p>
- <p>
- <button
- type="button"
- onClick={() => {
- officer.lock();
- }}
- class="m-4 block rounded-md border-0 bg-gray-200 px-3 py-2 text-center text-sm text-black shadow-sm "
- >
- <i18n.Translate>Lock account</i18n.Translate>
- </button>
- </p>
- <p>
- <button
- type="button"
- onClick={() => {
- officer.forget();
- }}
- class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
- >
- <i18n.Translate>Forget account</i18n.Translate>
- </button>
- </p>
- </div>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/pages/Profile.tsx b/packages/aml-backoffice-ui/src/pages/Profile.tsx
@@ -0,0 +1,84 @@
+/*
+ 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 {
+ useExchangeApiContext,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
+import { h } from "preact";
+import { useOfficer } from "../hooks/officer.js";
+import { HandleAccountNotReady } from "../components/HandleAccountNotReady.js";
+import { useUiSettingsContext } from "../context/ui-settings.js";
+
+export function Profile() {
+ const officer = useOfficer();
+ const settings = useUiSettingsContext();
+ const { lib } = useExchangeApiContext();
+
+ const { i18n } = useTranslationContext();
+ if (officer.state !== "ready") {
+ return <HandleAccountNotReady officer={officer} />;
+ }
+
+ const url = new URL("./", lib.exchange.baseUrl);
+ const signupEmail = settings.signupEmail ?? `aml-signup@${url.hostname}`;
+
+ return (
+ <div>
+ <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
+ <i18n.Translate>Public key</i18n.Translate>
+ </h1>
+ <div class="max-w-xl text-base leading-7 text-gray-700 lg:max-w-lg">
+ <p class="mt-6 font-mono break-all">{officer.account.id}</p>
+ </div>
+ <p>
+ <a
+ href={`mailto:${signupEmail}?subject=${encodeURIComponent(
+ "Request AML signup",
+ )}&body=${encodeURIComponent(
+ `I want my AML account\n\n\nPubKey: ${officer.account.id}`,
+ )}`}
+ target="_blank"
+ rel="noreferrer"
+ 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>Request account activation</i18n.Translate>
+ </a>
+ </p>
+ <p>
+ <button
+ type="button"
+ onClick={() => {
+ officer.lock();
+ }}
+ class="m-4 block rounded-md border-0 bg-gray-200 px-3 py-2 text-center text-sm text-black shadow-sm "
+ >
+ <i18n.Translate>Lock account</i18n.Translate>
+ </button>
+ </p>
+ <p>
+ <button
+ type="button"
+ onClick={() => {
+ officer.forget();
+ }}
+ class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ >
+ <i18n.Translate>Forget account</i18n.Translate>
+ </button>
+ </p>
+ </div>
+ );
+}
diff --git a/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx b/packages/aml-backoffice-ui/src/pages/RulesInfo.tsx
@@ -1,332 +0,0 @@
-import {
- amountFractionalBase,
- AmountJson,
- Amounts,
- assertUnreachable,
- CurrencySpecification,
- KycRule,
- LimitOperationType,
-} from "@gnu-taler/taler-util";
-import {
- Attention,
- useExchangeApiContext,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { formatDuration, intervalToDuration } from "date-fns";
-import { Fragment, h, VNode } from "preact";
-
-type KycRuleWithIdx = KycRule & {
- idx: number;
-};
-
-export function RulesInfo({
- rules,
- onEdit,
- onRemove,
- onNew,
-}: {
- rules: KycRule[];
- onNew?: () => void;
- onEdit?: (k: KycRule, idx: number) => void;
- onRemove?: (k: KycRule, idx: number) => void;
-}): VNode {
- const { i18n } = useTranslationContext();
- const { config } = useExchangeApiContext();
-
- if (!rules.length) {
- return (
- <div>
- <Attention
- title={i18n.str`There are no rules for operations`}
- type="warning"
- >
- <i18n.Translate>
- This mean that all operation have no limit.
- </i18n.Translate>
- </Attention>
- {!onNew ? undefined : (
- <button
- onClick={() => {
- onNew();
- }}
- 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 disabled:bg-gray-600"
- >
- <i18n.Translate>Add custom rule</i18n.Translate>
- </button>
- )}
- </div>
- );
- }
-
- const theRules = rules.map((r, idx): KycRuleWithIdx => ({ ...r, idx }));
-
- const sorted = theRules.sort((a, b) => {
- return sortKycRules(a, b);
- });
-
- const hasActions = !!onEdit || !!onRemove;
-
- return (
- <Fragment>
- <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="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
- >
- <i18n.Translate>Threshold</i18n.Translate>
- </th>
- <th
- scope="col"
- class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
- >
- <i18n.Translate>Escalation</i18n.Translate>
- </th>
- {!hasActions ? undefined : (
- <th
- scope="col"
- class="relative py-3.5 pl-3 pr-4 sm:pr-6 text-right"
- >
- {!onNew ? undefined : (
- <button
- onClick={() => {
- onNew();
- }}
- class="rounded-md w-fit border-0 p-1 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700 disabled:bg-gray-600"
- >
- <i18n.Translate>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M12 4.5v15m7.5-7.5h-15"
- />
- </svg>
- </i18n.Translate>
- </button>
- )}
- </th>
- )}
- </tr>
- </thead>
-
- <tbody id="thetable" class="divide-y divide-gray-200 bg-white ">
- {sorted.map((r) => {
- return (
- <tr class="even:bg-gray-200 ">
- <td class="flex whitespace-nowrap py-2 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-6 text-left">
- <span class="mx-2">
- {r.exposed ? (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
- />
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
- />
- </svg>
- ) : (
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6 text-gray-500"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
- />
- </svg>
- )}
- </span>
- <span>{r.operation_type}</span>
- </td>
- <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
- {r.timeframe.d_us === "forever" ? (
- <RenderAmount
- value={Amounts.parseOrThrow(r.threshold)}
- spec={config.config.currency_specification}
- />
- ) : (
- <i18n.Translate context="threshold">
- <RenderAmount
- value={Amounts.parseOrThrow(r.threshold)}
- spec={config.config.currency_specification}
- />
- every{" "}
- {formatDuration(
- intervalToDuration({
- start: 0,
- end: r.timeframe.d_us / 1000,
- }),
- )}
- </i18n.Translate>
- )}
- </td>
- <td class=" relative whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6 text-right">
- {r.is_and_combinator ? (
- <span class="text-gray-500">
- <i18n.Translate>(all)</i18n.Translate>
- </span>
- ) : (
- <Fragment />
- )}
- {r.measures}
- </td>
- {!hasActions ? undefined : (
- <td class="relative flex justify-end whitespace-nowrap py-2 pl-3 pr-4 text-sm font-medium sm:pr-6">
- {!onEdit ? undefined : (
- <button onClick={() => onEdit(r, r.idx)}>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6 text-green-700"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
- />
- </svg>
- </button>
- )}
- {!onRemove ? undefined : (
- <button onClick={() => onRemove(r, r.idx)}>
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6 text-red-700"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
- />
- </svg>
- </button>
- )}
- </td>
- )}
- </tr>
- );
- })}
- </tbody>
- </table>
- </div>
- </Fragment>
- );
-}
-
-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>
- );
-}
-
-export function rate(a: AmountJson, b: number): number {
- const af = toFloat(a);
- const bf = b;
- if (bf === 0) return 0;
- return af / bf;
-}
-
-function toFloat(amount: AmountJson): number {
- return amount.value + amount.fraction / amountFractionalBase;
-}
-
-const OPERATION_TYPE_ORDER = {
- [LimitOperationType.balance]: 1,
- [LimitOperationType.transaction]: 2,
- [LimitOperationType.withdraw]: 3,
- [LimitOperationType.deposit]: 4,
- [LimitOperationType.aggregate]: 5,
- [LimitOperationType.close]: 6,
- [LimitOperationType.refund]: 7,
- [LimitOperationType.merge]: 8,
-} as const;
-
-/**
- * Operation follows OPERATION_TYPE_ORDER.
- * Then operations with timeframe "forever" means they are not reset, like balance. Go first.
- * Then operations with high throughput first.
- * @param a
- * @param b
- * @returns
- */
-function sortKycRules(a: KycRule, b: KycRule): number {
- const op =
- OPERATION_TYPE_ORDER[a.operation_type] -
- OPERATION_TYPE_ORDER[b.operation_type];
- if (op !== 0) return op;
- const at = a.timeframe;
- const bt = b.timeframe;
- if (at.d_us === "forever" || bt.d_us === "forever") {
- if (at.d_us === "forever") return -1;
- if (bt.d_us === "forever") return 1;
- return Amounts.cmp(a.threshold, b.threshold);
- }
- const as = rate(Amounts.parseOrThrow(a.threshold), at.d_us);
- const bs = rate(Amounts.parseOrThrow(a.threshold), bt.d_us);
- return bs - as;
-}
diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx
@@ -36,6 +36,8 @@ import {
FormUI,
InternationalizationAPI,
Loading,
+ Pagination,
+ RouteDefinition,
Time,
UIFormElementConfig,
useExchangeApiContext,
@@ -47,15 +49,16 @@ import { useState } from "preact/hooks";
import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
import { useAccountDecisions } from "../hooks/decisions.js";
import { useOfficer } from "../hooks/officer.js";
-import { privatePages } from "../Routing.js";
-import { Pagination, ToInvestigateIcon } from "./Cases.js";
-import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
-import { Officer } from "./Officer.js";
+import { ToInvestigateIcon } from "./AccountList.js";
+import { HandleAccountNotReady } from "../components/HandleAccountNotReady.js";
+import { Profile } from "./Profile.js";
export function Search({
onNewDecision,
+ routeToAccountById,
}: {
onNewDecision: (account: string, payto: string) => void;
+ routeToAccountById: RouteDefinition<{ cid: string }>;
}) {
const officer = useOfficer();
const { i18n } = useTranslationContext();
@@ -73,7 +76,6 @@ export function Search({
const paytoForm = useForm<FormPayto>(
design,
{ paytoType: "iban" },
- // createFormValidator(i18n),
);
return (
@@ -114,7 +116,11 @@ export function Search({
}
})()}
{!paytoUri ? undefined : (
- <ShowResult payto={paytoUri} onNewDecision={onNewDecision} />
+ <ShowResult
+ payto={paytoUri}
+ onNewDecision={onNewDecision}
+ routeToAccountById={routeToAccountById}
+ />
)}
</div>
);
@@ -122,8 +128,11 @@ export function Search({
function ShowResult({
payto,
+ routeToAccountById,
onNewDecision,
}: {
+ routeToAccountById: RouteDefinition<{ cid: string }>;
+
payto: PaytoUri;
onNewDecision: (account: string, payto: string) => void;
}): VNode {
@@ -149,7 +158,7 @@ function ShowResult({
create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
case HttpStatusCode.NotFound:
@@ -161,7 +170,7 @@ function ShowResult({
or create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
case HttpStatusCode.Conflict:
@@ -173,7 +182,7 @@ function ShowResult({
or create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
default:
@@ -186,7 +195,7 @@ function ShowResult({
<div class="mt-8">
<div class="mb-2">
<a
- href={privatePages.caseDetails.url({
+ href={routeToAccountById.url({
cid: account,
})}
class="text-indigo-600 hover:text-indigo-900"
@@ -291,10 +300,6 @@ function ShowResult({
</i18n.Translate>
<button
- // href={privatePages.decideNew.url({
- // cid: account,
- // payto: encodeCrockForURI(paytoStr),
- // })}
onClick={async () => {
onNewDecision(account, encodeCrockForURI(paytoStr));
}}
diff --git a/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx b/packages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx
@@ -15,13 +15,10 @@
*/
import {
- AmountJson,
- Amounts,
assertUnreachable,
- CurrencySpecification,
HttpStatusCode,
TalerError,
- TalerFormAttributes,
+ TalerFormAttributes
} from "@gnu-taler/taler-util";
import {
Attention,
@@ -30,16 +27,16 @@ import {
Loading,
preloadedForms,
RouteDefinition,
+ useFormMeta,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
-import { useFormMeta } from "../../../web-util/src/hooks/useForm.js";
import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
import { useAccountInformation } from "../hooks/account.js";
-import { Officer } from "./Officer.js";
+import { Profile } from "./Profile.js";
export function ShowCollectedInfo({
- routeToCaseById,
+ routeToAccountById,
account,
routeToShowCollectedInfo,
rowId,
@@ -47,7 +44,7 @@ export function ShowCollectedInfo({
rowId: number;
account: string;
routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
- routeToCaseById: RouteDefinition<{ cid: string }>;
+ routeToAccountById: RouteDefinition<{ cid: string }>;
}): VNode {
const { i18n } = useTranslationContext();
@@ -70,7 +67,7 @@ export function ShowCollectedInfo({
create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
case HttpStatusCode.NotFound:
@@ -82,7 +79,7 @@ export function ShowCollectedInfo({
or create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
case HttpStatusCode.Conflict:
@@ -94,7 +91,7 @@ export function ShowCollectedInfo({
or create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
default:
@@ -158,7 +155,7 @@ export function ShowCollectedInfo({
form={formToBeUsed}
account={account}
data={event.attributes ?? {}}
- routeToCaseById={routeToCaseById}
+ routeToAccountById={routeToAccountById}
expectedVersion={FORM_VERSION}
previousId={previousId}
nextId={nextId}
@@ -171,7 +168,7 @@ function ShowForm({
form,
data,
account,
- routeToCaseById,
+ routeToAccountById,
expectedVersion,
previousId,
routeToShowCollectedInfo,
@@ -180,7 +177,7 @@ function ShowForm({
form: FormMetadata;
data: object;
account: string;
- routeToCaseById: RouteDefinition<{ cid: string }>;
+ routeToAccountById: RouteDefinition<{ cid: string }>;
expectedVersion: number | undefined;
routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
previousId?: number;
@@ -213,7 +210,7 @@ function ShowForm({
) : undefined}
<div class="my-4">
<a
- href={routeToCaseById.url({ cid: account })}
+ href={routeToAccountById.url({ cid: account })}
class="mt-3 inline-flex w-full items-center justify-center 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 sm:ml-3 sm:mt-0 sm:w-auto"
>
<i18n.Translate>Case details</i18n.Translate>
@@ -251,40 +248,3 @@ function ShowForm({
</Fragment>
);
}
-
-/**
- * send to web-utils
- * @param param0
- * @returns
- */
-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>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx
@@ -1,70 +0,0 @@
-/*
- 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/>
- */
-
-/**
- *
- * @author Sebastian Javier Marchano (sebasjm)
- */
-
-import {
- AbsoluteTime,
- Duration,
- TranslatedString,
-} from "@gnu-taler/taler-util";
-import { InternationalizationAPI } from "@gnu-taler/web-util/browser";
-import * as tests from "@gnu-taler/web-util/testing";
-import { ShowConsolidated as TestedComponent } from "./ShowConsolidated.js";
-
-export default {
- title: "show consolidated",
-};
-
-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[],
- Translate: () => undefined as unknown,
-};
-
-// export const WithEmptyHistory = tests.createExample(TestedComponent, {
-// history: getEventsFromAmlHistory([], nullTranslator),
-// until: AbsoluteTime.now(),
-// });
-
-// export const WithSomeEvents = tests.createExample(TestedComponent, {
-// history: getEventsFromAmlHistory(
-// [
-// {
-// collection_time: AbsoluteTime.toProtocolTimestamp(
-// AbsoluteTime.subtractDuraction(
-// AbsoluteTime.now(),
-// Duration.fromPrettyString("1d"),
-// ),
-// ),
-// provider_name: "asd",
-// attributes: {
-// email: "sebasjm@qwdde.com",
-// },
-// rowid: 1,
-// },
-// ],
-// nullTranslator,
-// ),
-// until: AbsoluteTime.now(),
-// });
diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx
@@ -1,142 +0,0 @@
-/*
- 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,
- TalerExchangeApi,
- TranslatedString,
-} from "@gnu-taler/taler-util";
-import {
- FormDesign,
- FormUI,
- UIFormElementConfig,
- useForm,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { format } from "date-fns";
-import { VNode, h } from "preact";
-import { useEffect } from "preact/hooks";
-// 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,
-}: {
- history: TalerExchangeApi.KycAttributeCollectionEvent[];
- until: AbsoluteTime;
-}): VNode {
- const { i18n } = useTranslationContext();
-
- const cons = getConsolidated(history, until);
-
- const fixed = fixProvidedInfo(cons.kyc);
-
- const design: FormDesign = {
- type: "double-column",
- sections:
- Object.entries(fixed).length > 0
- ? [
- {
- title: i18n.str`Collected information`,
- description:
- until.t_ms === "never"
- ? undefined
- : i18n.str`All information known until ${format(
- until.t_ms,
- "dd/MM/yyyy HH:mm:ss",
- )}`,
- fields: Object.entries(fixed).map(([key, field]) => {
- const result: UIFormElementConfig = {
- type: "text",
- label: key as TranslatedString,
- id: `${key}.value`,
- 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 { model: handler, update } = useForm(design, fixed);
-
- useEffect(() => {
- update(fixed);
- }, [until.t_ms]);
-
- return <FormUI design={design} model={handler} />;
-}
-
-interface Consolidated {
- kyc: {
- [field: string]: {
- value: unknown;
- provider?: string;
- since: AbsoluteTime;
- };
- };
-}
-
-export function getConsolidated(
- history: TalerExchangeApi.KycAttributeCollectionEvent[],
- when: AbsoluteTime,
-): Consolidated {
- const initial: Consolidated = {
- kyc: {},
- };
- return history.reduce((prev, cur) => {
- const collectionTime = AbsoluteTime.fromProtocolTimestamp(cur.collection_time);
- if (AbsoluteTime.cmp(when, collectionTime) <= 0) {
- return prev;
- }
-
- const formValues = cur.attributes ?? {}
- Object.keys(formValues).forEach((field) => {
- const value = (formValues as Record<string, unknown>)[field];
- prev.kyc[field] = {
- value,
- provider: cur.provider_name,
- since: collectionTime,
- };
- });
- return prev;
- }, initial);
-}
diff --git a/packages/aml-backoffice-ui/src/pages/Transfers.tsx b/packages/aml-backoffice-ui/src/pages/Transfers.tsx
@@ -3,19 +3,17 @@ import {
AmountJson,
Amounts,
assertUnreachable,
- CurrencySpecification,
encodeCrock,
hashNormalizedPaytoUri,
HttpStatusCode,
- PaytoHash,
- stringifyPaytoUri,
- TalerError,
+ PaytoHash
} from "@gnu-taler/taler-util";
import {
Attention,
FormDesign,
FormUI,
Loading,
+ RenderAmount,
RouteDefinition,
Time,
useExchangeApiContext,
@@ -26,13 +24,13 @@ import { format } from "date-fns";
import { Fragment, h, VNode } from "preact";
import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js";
import { useTransferList } from "../hooks/transfers.js";
-import { Officer } from "./Officer.js";
+import { Profile } from "./Profile.js";
export function Transfers({
- routeToCaseById,
+ routeToAccountById,
account,
}: {
- routeToCaseById: RouteDefinition<{ cid: string }>;
+ routeToAccountById: RouteDefinition<{ cid: string }>;
account?: PaytoHash;
}): VNode {
const { i18n, dateLocale } = useTranslationContext();
@@ -111,7 +109,7 @@ export function Transfers({
create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
case HttpStatusCode.NotFound:
@@ -123,7 +121,7 @@ export function Transfers({
or create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
case HttpStatusCode.Conflict:
@@ -135,7 +133,7 @@ export function Transfers({
or create a new one.
</i18n.Translate>
</Attention>
- <Officer />
+ <Profile />
</Fragment>
);
default:
@@ -154,7 +152,7 @@ export function Transfers({
<i18n.Translate>Transfers history for account</i18n.Translate>
{' '}
<a
- href={routeToCaseById.url({
+ href={routeToAccountById.url({
cid: account,
})}
class="text-indigo-600 hover:text-indigo-900 font-mono"
@@ -215,7 +213,7 @@ export function Transfers({
<i18n.Translate>Transfers history for account</i18n.Translate>
{' '}
<a
- href={routeToCaseById.url({
+ href={routeToAccountById.url({
cid: account,
})}
class="text-indigo-600 hover:text-indigo-900 font-mono"
@@ -321,7 +319,7 @@ export function Transfers({
</dt>
<dd class="mt-1 truncate text-gray-500 sm:hidden">
<a
- href={routeToCaseById.url({
+ href={routeToAccountById.url({
cid: hashPayto,
})}
class="text-indigo-600 hover:text-indigo-900 font-mono"
@@ -361,7 +359,7 @@ export function Transfers({
</td>
<td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">
<a
- href={routeToCaseById.url({
+ href={routeToAccountById.url({
cid: hashPayto,
})}
class="text-indigo-600 hover:text-indigo-900 font-mono"
@@ -409,39 +407,3 @@ export function Transfers({
);
}
-/**
- * send to web-utils
- * @param param0
- * @returns
- */
-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>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx
@@ -1,131 +0,0 @@
-/*
- 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 {
- Button,
- FormDesign,
- InputLine,
- InternationalizationAPI,
- LocalNotificationBanner,
- useForm,
- useLocalNotificationHandler,
- useTranslationContext,
-} from "@gnu-taler/web-util/browser";
-import { VNode, h } from "preact";
-import { useOfficer } from "../hooks/officer.js";
-
-type FormType = {
- password: string;
-};
-
-const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({
- type: "single-column",
- fields: [
- {
- id: "password",
- type: "text",
- label: i18n.str`Password`,
- required: true,
- },
- ],
-});
-
-export function UnlockAccount(): VNode {
- const { i18n } = useTranslationContext();
-
- const officer = useOfficer();
- const [notification, withErrorHandler] = useLocalNotificationHandler();
-
- const design = unlockAccountForm(i18n);
-
- const { model: handler, status } = useForm<FormType>(
- design,
- {
- password: undefined,
- },
- // (state) => {
- // return undefinedIfEmpty<FormErrors<FormType>>({
- // password: !state.password ? i18n.str`required` : undefined,
- // });
- // },
- );
-
- const unlockHandler =
- status.status === "fail" || officer.state !== "locked"
- ? undefined
- : withErrorHandler(
- async () => officer.tryUnlock(status.result.password),
- () => {},
- );
-
- const forgetHandler =
- officer.state === "not-found"
- ? undefined
- : withErrorHandler(
- async () => officer.forget(),
- () => {},
- );
-
- return (
- <div class="flex min-h-full flex-col ">
- <LocalNotificationBanner notification={notification} />
-
- <div class="sm:mx-auto sm:w-full sm:max-w-md">
- <h1 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
- <i18n.Translate>Account locked</i18n.Translate>
- </h1>
- <p class="mt-6 text-lg leading-8 text-gray-600">
- <i18n.Translate>
- Your account is normally locked anytime you reload. To unlock type
- your password again.
- </i18n.Translate>
- </p>
- </div>
-
- <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
- label={i18n.str`Password`}
- name="password"
- type="password"
- required
- handler={handler.getHandlerForAttributeKey("password")}
- />
- </div>
-
- <div class="mt-8">
- <Button
- type="submit"
- handler={unlockHandler}
- disabled={!unlockHandler}
- class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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>Unlock</i18n.Translate>
- </Button>
- </div>
- </div>
- <Button
- type="button"
- handler={forgetHandler}
- disabled={!forgetHandler}
- class="disabled:opacity-50 disabled:cursor-default m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
- >
- <i18n.Translate>Forget account</i18n.Translate>
- </Button>
- </div>
- </div>
- );
-}
diff --git a/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx
@@ -1,411 +0,0 @@
-/*
- 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,
- parsePaytoUri,
- PaytoString,
- PaytoUri,
- TalerError,
- TranslatedString,
-} from "@gnu-taler/taler-util";
-import { CopyButton, useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
-import {
- DecisionRequest,
- useCurrentDecisionRequest,
-} from "../../hooks/decision-request.js";
-import { Events } from "./Events.js";
-import { Attributes } from "./Information.js";
-import { Justification } from "./Justification.js";
-import { Measures } from "./Measures.js";
-import { Properties } from "./Properties.js";
-import { Rules } from "./Rules.js";
-import { Summary } from "./Summary.js";
-import { useAccountActiveDecision } from "../../hooks/decisions.js";
-
-export type WizardSteps =
- | "attributes" // submit more information
- | "rules" // define the limits
- | "measures" // define a new form/challenge
- | "properties" // define account information
- | "events" // define events to trigger
- | "justification" // finalize, investigate?;
- | "summary";
-
-const STEPS_ORDER: WizardSteps[] = [
- "attributes",
- "rules",
- "properties",
- "events",
- "measures",
- "justification",
- "summary",
-];
-
-const STEPS_ORDER_MAP = STEPS_ORDER.reduce(
- (map, cur, idx, steps) => {
- map[cur] = {
- prev: idx === 0 ? undefined : steps[idx - 1],
- next: idx === steps.length ? undefined : steps[idx + 1],
- };
- return map;
- },
- {} as {
- [s in WizardSteps]: {
- next: WizardSteps | undefined;
- prev: WizardSteps | undefined;
- };
- },
-);
-
-export function isRulesCompleted(request: DecisionRequest): boolean {
- return request.rules !== undefined && request.deadline !== undefined;
-}
-export function isAttributesCompleted(request: DecisionRequest): boolean {
- return (
- request.attributes === undefined || request.attributes.errors === undefined
- );
-}
-export function isPropertiesCompleted(request: DecisionRequest): boolean {
- return request.properties !== undefined && request.properties_errors === undefined;
-}
-export function isEventsCompleted(request: DecisionRequest): boolean {
- return request.custom_events !== undefined;
-}
-export function isMeasuresCompleted(request: DecisionRequest): boolean {
- return request.new_measures !== undefined;
-}
-export function isJustificationCompleted(request: DecisionRequest): boolean {
- return request.keep_investigating !== undefined && !!request.justification;
-}
-export function isJustificationCompletedForNewACcount(
- request: DecisionRequest,
-): boolean {
- return (
- request.keep_investigating !== undefined &&
- !!request.justification &&
- !!request.accountName
- );
-}
-
-export function AmlDecisionRequestWizard({
- account,
- newPayto,
- step,
- formId,
- onMove,
-}: {
- account: string;
- newPayto?: PaytoString;
- formId: string | undefined;
- step?: WizardSteps;
- onMove: (n: WizardSteps | undefined) => void;
-}): VNode {
- const { i18n } = useTranslationContext();
- const stepOrDefault = step ?? STEPS_ORDER[0];
- const content = (function () {
- switch (stepOrDefault) {
- case "rules":
- return <Rules newPayto={newPayto} />;
- case "properties":
- return <Properties />;
- case "events":
- return <Events />;
- case "measures":
- return <Measures />;
- case "justification":
- return <Justification newPayto={newPayto} />;
- case "attributes":
- return <Attributes formId={formId} />;
- case "summary":
- return (
- <Summary account={account} onMove={onMove} newPayto={newPayto} />
- );
- }
- assertUnreachable(stepOrDefault);
- })();
-
- 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">
- <Header account={account} newPayto={newPayto} />
- <div>{account}</div>
- <CopyButton class="" getContent={() => account} />
- </header>
-
- <WizardSteps
- step={stepOrDefault}
- onMove={onMove}
- newAccount={!!newPayto}
- />
- <button
- disabled={!STEPS_ORDER_MAP[stepOrDefault].prev}
- onClick={() => {
- onMove(STEPS_ORDER_MAP[stepOrDefault].prev);
- }}
- class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
- >
- <i18n.Translate>Prev</i18n.Translate>
- </button>
- <button
- disabled={!STEPS_ORDER_MAP[stepOrDefault].next}
- onClick={() => {
- onMove(STEPS_ORDER_MAP[stepOrDefault].next);
- }}
- class="m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm disabled:bg-gray-500 bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
- >
- <i18n.Translate>Next</i18n.Translate>
- </button>
- {content}
- </div>
- );
-}
-function WizardSteps({
- step: currentStep,
- onMove,
- newAccount,
-}: {
- step: WizardSteps;
- onMove: (n: WizardSteps | undefined) => void;
- newAccount: boolean;
-}): VNode {
- const [request] = useCurrentDecisionRequest();
- const { i18n } = useTranslationContext();
- const STEP_INFO: {
- [s in WizardSteps]: {
- label: TranslatedString;
- description: TranslatedString;
- isCompleted: (r: DecisionRequest) => boolean;
- };
- } = {
- attributes: {
- label: i18n.str`Attributes`,
- description: i18n.str`Add more information about the customer`,
- isCompleted: isAttributesCompleted,
- },
- rules: {
- label: i18n.str`Rules`,
- description: i18n.str`Set the limit of the operations`,
- isCompleted: isRulesCompleted,
- },
- events: {
- label: i18n.str`Events`,
- description: i18n.str`Trigger notifications.`,
- isCompleted: isEventsCompleted,
- },
- measures: {
- label: i18n.str`Measures`,
- description: i18n.str`Ask the customer to take action.`,
- isCompleted: isMeasuresCompleted,
- },
- justification: {
- label: i18n.str`Justification`,
- description: i18n.str`Describe the decision.`,
- isCompleted: newAccount
- ? isJustificationCompletedForNewACcount
- : isJustificationCompleted,
- },
- properties: {
- label: i18n.str`Properties`,
- description: i18n.str`Flag the current account state.`,
- isCompleted: isPropertiesCompleted,
- },
- summary: {
- label: i18n.str`Summary`,
- description: i18n.str`Review and submit.`,
- isCompleted: () => false,
- },
- };
- return (
- <div class="lg:border-b lg:border-t lg:border-gray-200">
- <nav class="mx-auto max-w-7xl " aria-label="Progress">
- <ol
- role="list"
- class="overflow-hidden rounded-md lg:flex lg:rounded-none lg:border-l lg:border-r lg:border-gray-200"
- >
- {STEPS_ORDER.map((stepLabel) => {
- const info = STEP_INFO[stepLabel];
- const st = info.isCompleted(request)
- ? "completed"
- : currentStep === stepLabel
- ? "current"
- : "incomplete";
-
- const pos = !STEPS_ORDER_MAP[stepLabel].prev
- ? "first"
- : !STEPS_ORDER_MAP[stepLabel].next
- ? "last"
- : "middle";
-
- return (
- <li class="relative lg:flex-1">
- <div
- data-pos={pos}
- class="overflow-hidden data-[pos=first]:rounded-t-md border data-[pos=first]:border-b-0 border-gray-200 lg:border-0"
- >
- {currentStep === stepLabel ? (
- <span
- class="absolute left-0 top-0 h-full w-1 bg-indigo-600 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full"
- aria-hidden="true"
- ></span>
- ) : undefined}
- <button
- aria-current="step"
- class="group"
- onClick={() => {
- onMove(stepLabel);
- }}
- >
- <span
- data-status={st}
- class="absolute left-0 top-0 h-full w-1 data-[status=current]:bg-indigo-600 data-[status=current]:bg-transparent group-hover:bg-gray-200 lg:bottom-0 lg:top-auto lg:h-1 lg:w-full"
- aria-hidden="true"
- ></span>
- <div>
- <span class="flex items-start px-4 pt-4 text-sm font-medium">
- <span class="shrink-0">
- <span
- data-status={st}
- class="flex size-6 items-center justify-center rounded-full data-[status=completed]:bg-indigo-600 border-2 data-[status=current]:border-indigo-600 data-[status=incomplete]:border-gray-300"
- >
- <svg
- class="size-4 text-white "
- viewBox="0 0 24 24"
- fill="currentColor"
- aria-hidden="true"
- data-slot="icon"
- >
- <path
- fill-rule="evenodd"
- d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
- clip-rule="evenodd"
- />
- </svg>
- </span>
- </span>
- <span
- data-status={st}
- class="ml-4 data-[status=current]:text-indigo-600"
- >
- {info.label}
- </span>
- </span>
- </div>
- <div class="p-2 text-start">
- <span class="ml-4 mt-0.5 flex min-w-0 flex-col">
- <span
- data-current={currentStep === stepLabel}
- class="text-sm font-medium data-[current=true]:text-indigo-600"
- ></span>
- <span class="text-sm font-medium text-gray-500">
- {info.description}
- </span>
- </span>
- </div>
- </button>
- {pos === "first" ? undefined : (
- <div
- data-pos={pos}
- class="absolute inset-0 left-0 top-0 hidden w-2 lg:block"
- aria-hidden="true"
- >
- <svg
- data-pos={pos}
- class="size-full text-gray-300 data-[pos=middle]:h-full data-[pos=middle]:w-full"
- viewBox="0 0 12 82"
- fill="none"
- preserveAspectRatio="none"
- >
- <path
- d="M0.5 0V31L10.5 41L0.5 51V82"
- stroke="currentcolor"
- vector-effect="non-scaling-stroke"
- />
- </svg>
- </div>
- )}
- </div>
- </li>
- );
- })}
- </ol>
- </nav>
- </div>
- );
-}
-
-function Header({
- newPayto,
- account,
-}: {
- account: string;
- newPayto: PaytoString | undefined;
-}): VNode {
- const { i18n } = useTranslationContext();
- const isNewAccount = !!newPayto;
-
- let newPaytoParsed: PaytoUri | undefined;
- const isNewAccountAWallet =
- newPayto === undefined
- ? undefined
- : (newPaytoParsed = parsePaytoUri(newPayto)) === undefined
- ? undefined
- : newPaytoParsed.isKnown &&
- (newPaytoParsed.targetType === "taler-reserve" ||
- newPaytoParsed.targetType === "taler-reserve-http");
-
- const activeDecision = useAccountActiveDecision(
- isNewAccount ? undefined : account,
- );
-
- const info =
- !activeDecision ||
- activeDecision instanceof TalerError ||
- activeDecision.type === "fail"
- ? undefined
- : activeDecision.body;
-
- if (!info && !isNewAccount) {
- <h1 class="text-base font-semibold leading-7 text-black">
- <i18n.Translate>loading... </i18n.Translate>
- </h1>;
- }
- // info may be undefined if this is a new account
- // for which we use the payto:// parameter
- const isWallet = info?.is_wallet ?? isNewAccountAWallet;
-
- if (isWallet === undefined) {
- return (
- <h1 class="text-base font-semibold leading-7 text-black">
- <i18n.Translate>Decision for account: </i18n.Translate>
- </h1>
- );
- }
- if (isWallet) {
- return (
- <h1 class="text-base font-semibold leading-7 text-black">
- <i18n.Translate>Decision for wallet: </i18n.Translate>
- </h1>
- );
- } else {
- return (
- <h1 class="text-base font-semibold leading-7 text-black">
- <i18n.Translate>Decision for bank account: </i18n.Translate>
- </h1>
- );
- }
-}
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx
@@ -17,13 +17,13 @@ import { Fragment, h, VNode } from "preact";
import { useMemo, useState } from "preact/hooks";
import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
import { useServerMeasures } from "../../hooks/server-info.js";
-import { computeAvailableMesaures } from "../CaseDetails.js";
+import { computeAvailableMesaures } from "../../utils/computeAvailableMesaures.js";
import {
CurrentMeasureTable,
MeasureInfo,
Mesaures,
-} from "../MeasuresTable.js";
-import { MeasureDefinition, NewMeasure } from "../NewMeasure.js";
+} from "../../components/MeasuresTable.js";
+import { MeasureDefinition, NewMeasure } from "../../components/NewMeasure.js";
/**
* Ask for more information, define new paths to proceed
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx
@@ -136,12 +136,12 @@ function ReloadForm({ merged }: { merged: any }): VNode {
);
}
-export type PropertiesForm = {
+type PropertiesForm = {
defined: { [name: string]: boolean };
custom: { name: string; value: string }[];
};
-export const propertiesForm = (
+const propertiesForm = (
i18n: InternationalizationAPI,
props: UIFormElementConfig[],
): FormDesign => ({
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx
@@ -30,8 +30,8 @@ import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
import { useServerMeasures } from "../../hooks/server-info.js";
-import { ShowDecisionLimitInfo } from "../CaseDetails.js";
-import { RulesInfo } from "../RulesInfo.js";
+import { ShowDecisionLimitInfo } from "../../components/ShowDecisionLimitInfo.js";
+import { RulesInfo } from "../../components/RulesInfo.js";
const DEFAULT_MEASURE_IF_NONE = ["VERBOTEN"];
export const DEFAULT_LIMITS_WHEN_NEW_ACCOUNT: LegitimizationRuleSet = {
@@ -63,7 +63,7 @@ export type RuleInconsistency =
| "shold-not-have-wallet-rules"
| "shold-not-have-bank-rules";
-export function findRuleInconsistency(
+function findRuleInconsistency(
isWallet: boolean,
rules: KycRule[],
): undefined | RuleInconsistency {
diff --git a/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx b/packages/aml-backoffice-ui/src/pages/decision/Summary.tsx
@@ -14,21 +14,18 @@ import {
import {
Attention,
Button,
- LocalNotificationBanner,
useExchangeApiContext,
useLocalNotificationHandler,
- useTranslationContext,
+ useTranslationContext
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
+import { CurrentMeasureTable, Mesaures } from "../../components/MeasuresTable.js";
+import { ShowDecisionLimitInfo } from "../../components/ShowDecisionLimitInfo.js";
import { useCurrentDecisionRequest } from "../../hooks/decision-request.js";
import { useOfficer } from "../../hooks/officer.js";
import { useServerMeasures } from "../../hooks/server-info.js";
-import {
- computeAvailableMesaures,
- ShowDecisionLimitInfo,
-} from "../CaseDetails.js";
-import { CurrentMeasureTable, Mesaures } from "../MeasuresTable.js";
+import { computeAvailableMesaures } from "../../utils/computeAvailableMesaures.js";
import {
isAttributesCompleted,
isEventsCompleted,
@@ -38,7 +35,7 @@ import {
isPropertiesCompleted,
isRulesCompleted,
WizardSteps,
-} from "./AmlDecisionRequestWizard.js";
+} from "../DecisionWizard.js";
/**
* Mark for further investigation and explain decision
@@ -228,7 +225,6 @@ export function Summary({
return (
<Fragment>
- {/* <LocalNotificationBanner notification={notification} /> */}
{INVALID_RULES ? (
<Fragment>
@@ -375,7 +371,7 @@ export function Summary({
* @param measures
* @returns
*/
-export function workaround_defaultProgramName(
+function workaround_defaultProgramName(
measures: Record<string, MeasureInformation>,
) {
const ms = Object.keys(measures);
diff --git a/packages/aml-backoffice-ui/src/pages/index.stories.ts b/packages/aml-backoffice-ui/src/pages/index.stories.ts
@@ -13,5 +13,4 @@
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/>
*/
-export * as a1 from "./ShowConsolidated.stories.js";
-export * as a3 from "./Cases.stories.js";
+// export * as a1 from "./ShowConsolidated.stories.js";
diff --git a/packages/aml-backoffice-ui/src/utils/computeAvailableMesaures.ts b/packages/aml-backoffice-ui/src/utils/computeAvailableMesaures.ts
@@ -0,0 +1,66 @@
+import type { TalerExchangeApi } from "@gnu-taler/taler-util";
+import { MeasureInfo, Mesaures } from "../components/MeasuresTable.js";
+
+
+export function computeAvailableMesaures(
+ serverMeasures: TalerExchangeApi.AvailableMeasureSummary | undefined,
+ // customMeasures?: Readonly<CustomMeasures>,
+ skpiFilter?: (m: MeasureInfo) => boolean
+): Mesaures {
+ const init: Mesaures = { forms: [], procedures: [], info: [] };
+ if (!serverMeasures) {
+ return init;
+ }
+ const server = Object.entries(serverMeasures.roots).reduce(
+ (prev, [key, value]) => {
+ if (value.check_name !== "SKIP") {
+ if (!value.prog_name) {
+ const r: MeasureInfo = {
+ type: "info",
+ name: key,
+ context: value.context,
+ checkName: value.check_name,
+ check: serverMeasures.checks[value.check_name],
+ custom: true,
+ };
+ if (skpiFilter && skpiFilter(r)) return prev; // skip
+ prev.info.push(r);
+ } else {
+ const r: MeasureInfo = {
+ type: "form",
+ name: key,
+ context: value.context,
+ programName: value.prog_name,
+ program: serverMeasures.programs[value.prog_name],
+ checkName: value.check_name,
+ check: serverMeasures.checks[value.check_name],
+ custom: false,
+ };
+ if (skpiFilter && skpiFilter(r)) return prev; // skip
+ prev.forms.push(r);
+ }
+ } else {
+ if (!value.prog_name) {
+ console.error(
+ `ERROR: program name can't be empty for measure "${key}"`
+ );
+ return prev;
+ }
+ const r: MeasureInfo = {
+ type: "procedure",
+ name: key,
+ context: value.context,
+ programName: value.prog_name,
+ program: serverMeasures.programs[value.prog_name],
+ custom: false,
+ };
+ if (skpiFilter && skpiFilter(r)) return prev; // skip
+ prev.procedures.push(r);
+ }
+ return prev;
+ },
+ init
+ );
+
+ return server;
+}
diff --git a/packages/aml-backoffice-ui/src/utils/getTimeframesForDate.ts b/packages/aml-backoffice-ui/src/utils/getTimeframesForDate.ts
@@ -0,0 +1,72 @@
+import { AbsoluteTime, assertUnreachable } from "@gnu-taler/taler-util";
+import { sub } from "date-fns";
+import { TalerCorebankApi } from "@gnu-taler/taler-util";
+
+export type Timeframe = { start: AbsoluteTime; end: AbsoluteTime };
+
+export function getTimeframesForDate(
+ time: Date,
+ timeframe: TalerCorebankApi.MonitorTimeframeParam,
+): {
+ current: Timeframe;
+ previous: Timeframe;
+} {
+ switch (timeframe) {
+ case TalerCorebankApi.MonitorTimeframeParam.hour: {
+ const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+ AbsoluteTime.fromMilliseconds(
+ sub(time, { hours: timeIndex }).getTime(),
+ ),
+ );
+ return {
+ current: { start: middle, end: high },
+ previous: { start: low, end: middle },
+ };
+ }
+ case TalerCorebankApi.MonitorTimeframeParam.day: {
+ const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+ AbsoluteTime.fromMilliseconds(sub(time, { days: timeIndex }).getTime()),
+ );
+ return {
+ current: { start: middle, end: high },
+ previous: { start: low, end: middle },
+ };
+ }
+ case TalerCorebankApi.MonitorTimeframeParam.month: {
+ const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+ AbsoluteTime.fromMilliseconds(
+ sub(time, { months: timeIndex }).getTime(),
+ ),
+ );
+ return {
+ current: { start: middle, end: high },
+ previous: { start: low, end: middle },
+ };
+ }
+
+ case TalerCorebankApi.MonitorTimeframeParam.year: {
+ const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+ AbsoluteTime.fromMilliseconds(
+ sub(time, { years: timeIndex }).getTime(),
+ ),
+ );
+ return {
+ current: { start: middle, end: high },
+ previous: { start: low, end: middle },
+ };
+ }
+ case TalerCorebankApi.MonitorTimeframeParam.decade: {
+ const [high, middle, low] = [0, 1, 2].map((timeIndex) =>
+ AbsoluteTime.fromMilliseconds(
+ sub(time, { years: timeIndex * 10 }).getTime(),
+ ),
+ );
+ return {
+ current: { start: middle, end: high },
+ previous: { start: low, end: middle },
+ };
+ }
+ default:
+ assertUnreachable(timeframe);
+ }
+}
diff --git a/packages/web-util/src/components/Pagination.tsx b/packages/web-util/src/components/Pagination.tsx
@@ -0,0 +1,41 @@
+import { h } from "preact";
+import { useTranslationContext } from "../index.browser.js";
+
+/**
+ * Common pagination footer for tables.
+ *
+ * @param param0
+ * @returns
+ */
+export function Pagination({
+ onFirstPage,
+ onNext,
+}: {
+ onFirstPage?: () => void;
+ onNext?: () => void;
+}) {
+ const { i18n } = useTranslationContext();
+ return (
+ <nav
+ class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg"
+ aria-label="Pagination"
+ >
+ <div class="flex flex-1 justify-between sm:justify-end">
+ <button
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onFirstPage}
+ onClick={onFirstPage}
+ >
+ <i18n.Translate>First page</i18n.Translate>
+ </button>
+ <button
+ class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0"
+ disabled={!onNext}
+ onClick={onNext}
+ >
+ <i18n.Translate>Next</i18n.Translate>
+ </button>
+ </div>
+ </nav>
+ );
+}
diff --git a/packages/web-util/src/components/RenderAmount.tsx b/packages/web-util/src/components/RenderAmount.tsx
@@ -0,0 +1,44 @@
+import { AmountJson, Amounts, CurrencySpecification } from "@gnu-taler/taler-util";
+import { h, VNode } from "preact";
+
+/**
+ * Common way to render amount
+ *
+ * @param value the amount to be rendered
+ * @param spec currency specification
+ * @param hideSmall don't show very tiny value
+ * @param withColor show negative as red and positive as green
+ * @param negative show a minus sign on negative value
+ * @returns
+ */
+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>
+ );
+}
diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts
@@ -12,3 +12,5 @@ export * from "./ShowInputErrorLabel.js";
export * from "./NotificationBanner.js";
export * from "./ToastBanner.js";
export * from "./Time.js";
+export * from "./RenderAmount.js";
+export * from "./Pagination.js";