taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

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:
Mpackages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx | 150++-----------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/Routing.tsx | 137+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Apackages/aml-backoffice-ui/src/components/CreateAccount.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpackages/aml-backoffice-ui/src/pages/HandleAccountNotReady.tsx -> packages/aml-backoffice-ui/src/components/HandleAccountNotReady.tsx | 0
Apackages/aml-backoffice-ui/src/components/MeasureList.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpackages/aml-backoffice-ui/src/pages/MeasuresTable.tsx -> packages/aml-backoffice-ui/src/components/MeasuresTable.tsx | 0
Apackages/aml-backoffice-ui/src/components/NewMeasure.tsx | 994+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/components/RulesInfo.tsx | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/components/ShowConsolidated.tsx | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/components/ShowDecisionLimitInfo.tsx | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/components/UnlockAccount.tsx | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/AccountDetails.tsx | 992+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/pages/AccountList.tsx | 406+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 1285-------------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/CaseUpdate.tsx | 161-------------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/Cases.stories.tsx | 27---------------------------
Dpackages/aml-backoffice-ui/src/pages/Cases.tsx | 438-------------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/CreateAccount.tsx | 150-------------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Dashboard.tsx | 72++----------------------------------------------------------------------
Apackages/aml-backoffice-ui/src/pages/DecisionWizard.tsx | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/aml-backoffice-ui/src/pages/MeasureList.tsx | 123-------------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/NewMeasure.tsx | 994-------------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/Officer.tsx | 84-------------------------------------------------------------------------------
Apackages/aml-backoffice-ui/src/pages/Profile.tsx | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpackages/aml-backoffice-ui/src/pages/RulesInfo.tsx | 332-------------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 33+++++++++++++++++++--------------
Mpackages/aml-backoffice-ui/src/pages/ShowCollectedInfo.tsx | 64++++++++++++----------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/ShowConsolidated.stories.tsx | 70----------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx | 142-------------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Transfers.tsx | 62++++++++++++--------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/UnlockAccount.tsx | 131-------------------------------------------------------------------------------
Dpackages/aml-backoffice-ui/src/pages/decision/AmlDecisionRequestWizard.tsx | 411-------------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 6+++---
Mpackages/aml-backoffice-ui/src/pages/decision/Properties.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 6+++---
Mpackages/aml-backoffice-ui/src/pages/decision/Summary.tsx | 16++++++----------
Mpackages/aml-backoffice-ui/src/pages/index.stories.ts | 3+--
Apackages/aml-backoffice-ui/src/utils/computeAvailableMesaures.ts | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/aml-backoffice-ui/src/utils/getTimeframesForDate.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/components/Pagination.tsx | 41+++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/components/RenderAmount.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/components/index.ts | 2++
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> &nbsp; <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";