aboutsummaryrefslogtreecommitdiff
path: root/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/aml-backoffice-ui/src/pages/CaseDetails.tsx')
-rw-r--r--packages/aml-backoffice-ui/src/pages/CaseDetails.tsx458
1 files changed, 308 insertions, 150 deletions
diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
index 0875f047b..bb936cebf 100644
--- a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
+++ b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx
@@ -1,41 +1,75 @@
+/*
+ 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,
+ Codec,
HttpStatusCode,
+ OperationFail,
+ OperationOk,
TalerError,
+ TalerErrorDetail,
+ TalerExchangeApi,
TranslatedString,
- assertUnreachable
+ assertUnreachable,
+ buildCodecForObject,
+ codecForNumber,
+ codecForString,
+ codecOptional,
} from "@gnu-taler/taler-util";
-import { DefaultForm, ErrorLoading, InternationalizationAPI, Loading, useTranslationContext } from "@gnu-taler/web-util/browser";
+import {
+ DefaultForm,
+ ErrorLoading,
+ FormMetadata,
+ InternationalizationAPI,
+ Loading,
+ useTranslationContext,
+} from "@gnu-taler/web-util/browser";
import { format } from "date-fns";
-import { Fragment, VNode, h } from "preact";
+import { VNode, h } from "preact";
import { useState } from "preact/hooks";
-import { FormMetadata } from "../forms/declaration.js";
+import { privatePages } from "../Routing.js";
+import { useUiFormsContext } from "../context/ui-forms.js";
+import { preloadedForms } from "../forms/index.js";
import { useCaseDetails } from "../hooks/useCaseDetails.js";
-import { Pages } from "../pages.js";
-import { Justification, parseJustification } from "./AntiMoneyLaunderingForm.js";
import { ShowConsolidated } from "./ShowConsolidated.js";
-import { AmlExchangeBackend } from "../utils/types.js";
-import { uiForms } from "../forms/declaration.js";
-export type AmlEvent = AmlFormEvent | AmlFormEventError | KycCollectionEvent | KycExpirationEvent;
+export type AmlEvent =
+ | AmlFormEvent
+ | AmlFormEventError
+ | KycCollectionEvent
+ | KycExpirationEvent;
+
type AmlFormEvent = {
type: "aml-form";
when: AbsoluteTime;
title: TranslatedString;
justification: Justification;
- metadata: FormMetadata<any>;
- state: AmlExchangeBackend.AmlState;
+ metadata: FormMetadata;
+ state: TalerExchangeApi.AmlState;
threshold: AmountJson;
};
type AmlFormEventError = {
type: "aml-form-error";
when: AbsoluteTime;
title: TranslatedString;
- justification: undefined,
- metadata: undefined,
- state: AmlExchangeBackend.AmlState;
+ justification: undefined;
+ metadata: undefined;
+ state: TalerExchangeApi.AmlState;
threshold: AmountJson;
};
type KycCollectionEvent = {
@@ -58,29 +92,36 @@ function selectSooner(a: WithTime, b: WithTime) {
return AbsoluteTime.cmp(a.when, b.when);
}
-function titleForJustification(op: ReturnType<typeof parseJustification>, i18n: InternationalizationAPI): TranslatedString {
+function titleForJustification(
+ op: ReturnType<typeof parseJustification>,
+ i18n: InternationalizationAPI,
+): TranslatedString {
if (op.type === "ok") {
return op.body.justification.label as TranslatedString;
}
switch (op.case) {
- case "not-json": return "error: the justification is not a form" as TranslatedString
- case "id-not-found": return "error: justification form's id not found" as TranslatedString
- case "version-not-found": return "error: justification form's version not found" as TranslatedString
- case "form-not-found": return `error: justification form not found` as TranslatedString
+ case "not-json":
+ return i18n.str`error: the justification is not a form`;
+ case "id-not-found":
+ return i18n.str`error: justification form's id not found`;
+ case "version-not-found":
+ return i18n.str`error: justification form's version not found`;
+ case "form-not-found":
+ return i18n.str`error: justification form not found`;
default: {
- assertUnreachable(op.case)
+ assertUnreachable(op.case);
}
}
}
export function getEventsFromAmlHistory(
- aml: AmlExchangeBackend.AmlDecisionDetail[],
- kyc: AmlExchangeBackend.KycDetail[],
+ aml: TalerExchangeApi.AmlDecisionDetail[],
+ kyc: TalerExchangeApi.KycDetail[],
i18n: InternationalizationAPI,
+ forms: FormMetadata[],
): AmlEvent[] {
const ae: AmlEvent[] = aml.map((a) => {
-
- const just = parseJustification(a.justification, uiForms.forms(i18n))
+ const just = parseJustification(a.justification, forms);
return {
type: just.type === "ok" ? "aml-form" : "aml-form-error",
state: a.new_state,
@@ -117,104 +158,120 @@ export function getEventsFromAmlHistory(
export function CaseDetails({ account }: { account: string }) {
const [selected, setSelected] = useState<AbsoluteTime>(AbsoluteTime.now());
- const [showForm, setShowForm] = useState<{ justification: Justification, metadata: FormMetadata<any> }>()
+ const [showForm, setShowForm] = useState<{
+ justification: Justification;
+ metadata: FormMetadata;
+ }>();
const { i18n } = useTranslationContext();
- const details = useCaseDetails(account)
+ const details = useCaseDetails(account);
+ const { forms } = useUiFormsContext();
+
+ const allForms = [...forms, ...preloadedForms(i18n)];
if (!details) {
- return <Loading />
+ return <Loading />;
}
if (details instanceof TalerError) {
- return <ErrorLoading error={details} />
+ return <ErrorLoading 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)
+ case HttpStatusCode.Conflict:
+ return <div />;
+ default:
+ assertUnreachable(details);
}
}
- const { aml_history, kyc_attributes } = details.body
+ const { aml_history, kyc_attributes } = details.body;
- const events = getEventsFromAmlHistory(aml_history, kyc_attributes, i18n);
+ const events = getEventsFromAmlHistory(
+ aml_history,
+ kyc_attributes,
+ i18n,
+ allForms,
+ );
if (showForm !== undefined) {
- return <DefaultForm
- readOnly={true}
- initial={showForm.justification.value}
- form={showForm.metadata.impl(showForm.justification.value)}
- >
- <div class="mt-6 flex items-center justify-end gap-x-6">
- <button
- class="text-sm font-semibold leading-6 text-gray-900"
- onClick={() => {
- setShowForm(undefined)
- }}
- >
- <i18n.Translate>Cancel</i18n.Translate>
- </button>
- </div>
-
- </DefaultForm>
+ return (
+ <DefaultForm
+ readOnly={true}
+ initial={showForm.justification.value}
+ form={showForm.metadata as any} // FIXME: HERE
+ >
+ <div class="mt-6 flex items-center justify-end gap-x-6">
+ <button
+ class="text-sm font-semibold leading-6 text-gray-900"
+ onClick={() => {
+ setShowForm(undefined);
+ }}
+ >
+ <i18n.Translate>Cancel</i18n.Translate>
+ </button>
+ </div>
+ </DefaultForm>
+ );
}
return (
<div>
<a
- href={Pages.newFormEntry.url({ account })}
+ href={privatePages.caseNew.url({ cid: account })}
class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700"
>
- <i18n.Translate>
- New AML form
- </i18n.Translate>
+ <i18n.Translate>New AML form</i18n.Translate>
</a>
<header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8">
<h1 class="text-base font-semibold leading-7 text-black">
<i18n.Translate>
- Case history for account <span title={account}>{account.substring(0, 16)}...</span>
+ Case history for account{" "}
+ <span title={account}>{account.substring(0, 16)}...</span>
</i18n.Translate>
</h1>
</header>
- <ShowTimeline history={events} onSelect={(e) => {
- switch (e.type) {
- case "aml-form": {
- const { justification, metadata } = e
- setShowForm({ justification, metadata })
- break;
- }
- case "kyc-collection":
- case "kyc-expiration": {
- setSelected(e.when);
- break;
+ <ShowTimeline
+ history={events}
+ onSelect={(e) => {
+ switch (e.type) {
+ case "aml-form": {
+ const { justification, metadata } = e;
+ setShowForm({ justification, metadata });
+ break;
+ }
+ case "kyc-collection":
+ case "kyc-expiration": {
+ setSelected(e.when);
+ break;
+ }
+ case "aml-form-error":
}
- case "aml-form-error":
- }
- }} />
+ }}
+ />
{/* {selected && <ShowEventDetails event={selected} />} */}
{selected && <ShowConsolidated history={events} until={selected} />}
</div>
);
}
-function AmlStateBadge({ state }: { state: AmlExchangeBackend.AmlState }): VNode {
+function AmlStateBadge({ state }: { state: TalerExchangeApi.AmlState }): VNode {
switch (state) {
- case AmlExchangeBackend.AmlState.normal: {
+ case TalerExchangeApi.AmlState.normal: {
return (
<span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
Normal
</span>
);
}
- case AmlExchangeBackend.AmlState.pending: {
+ case TalerExchangeApi.AmlState.pending: {
return (
<span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20">
Pending
</span>
);
}
- case AmlExchangeBackend.AmlState.frozen: {
+ case TalerExchangeApi.AmlState.frozen: {
return (
<span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20">
Frozen
@@ -222,93 +279,194 @@ function AmlStateBadge({ state }: { state: AmlExchangeBackend.AmlState }): VNode
);
}
}
- assertUnreachable(state)
+ assertUnreachable(state);
}
-function ShowTimeline({ history, onSelect }: { onSelect: (e: AmlEvent) => void, history: AmlEvent[] }): VNode {
- return <div class="flow-root">
- <ul role="list">
- {history.map((e, idx) => {
- const isLast = history.length - 1 === idx;
- return (
- <li
- data-ok={e.type !== "aml-form-error"}
- class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer"
- onClick={() => {
- onSelect(e);
- }}
- >
- <div class="relative pb-6">
- {!isLast ? (
- <span
- class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200"
- aria-hidden="true"
- ></span>
- ) : undefined}
- <div class="relative flex space-x-3">
- {(() => {
- switch (e.type) {
- case "aml-form-error":
- case "aml-form": {
- return <div>
- <AmlStateBadge state={e.state} />
- <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
- {e.threshold.currency}{" "}
- {Amounts.stringifyValue(e.threshold)}
- </span>
- </div>
- }
- case "kyc-collection": {
- return (
- // <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>
- );
- }
- case "kyc-expiration": {
- // return <ClockIcon class="h-8 w-8 text-gray-700" />;
- return <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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
-
+function ShowTimeline({
+ history,
+ onSelect,
+}: {
+ onSelect: (e: AmlEvent) => void;
+ history: AmlEvent[];
+}): VNode {
+ return (
+ <div class="flow-root">
+ <ul role="list">
+ {history.map((e, idx) => {
+ const isLast = history.length - 1 === idx;
+ return (
+ <li
+ key={idx}
+ data-ok={e.type !== "aml-form-error"}
+ class="hover:bg-gray-200 p-2 rounded data-[ok=true]:cursor-pointer"
+ onClick={() => {
+ onSelect(e);
+ }}
+ >
+ <div class="relative pb-6">
+ {!isLast ? (
+ <span
+ class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200"
+ aria-hidden="true"
+ ></span>
+ ) : undefined}
+ <div class="relative flex space-x-3">
+ {(() => {
+ switch (e.type) {
+ case "aml-form-error":
+ case "aml-form": {
+ return (
+ <div>
+ <AmlStateBadge state={e.state} />
+ <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 ">
+ {e.threshold.currency}{" "}
+ {Amounts.stringifyValue(e.threshold)}
+ </span>
+ </div>
+ );
+ }
+ case "kyc-collection": {
+ return (
+ // <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>
+ );
+ }
+ case "kyc-expiration": {
+ // return <ClockIcon class="h-8 w-8 text-gray-700" />;
+ return (
+ <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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
+ />
+ </svg>
+ );
+ }
}
- }
- assertUnreachable(e)
- })()}
- <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
- {e.type === "aml-form" ?
- <span
- // href={Pages.newFormEntry.url({ account })}
- class="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"
- >
- {e.title}
- </span>
- :
- <p class="text-sm text-gray-900">{e.title}</p>
- }
- <div class="whitespace-nowrap text-right text-sm text-gray-500">
- {e.when.t_ms === "never" ? (
- "never"
+ assertUnreachable(e);
+ })()}
+ <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5">
+ {e.type === "aml-form" ? (
+ <span
+ // href={Pages.newFormEntry.url({ account })}
+ class="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"
+ >
+ {e.title}
+ </span>
) : (
- <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}>
- {format(e.when.t_ms, "dd MMM yyyy")}
- </time>
+ <p class="text-sm text-gray-900">{e.title}</p>
)}
+ <div class="whitespace-nowrap text-right text-sm text-gray-500">
+ {e.when.t_ms === "never" ? (
+ "never"
+ ) : (
+ <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}>
+ {format(e.when.t_ms, "dd MMM yyyy")}
+ </time>
+ )}
+ </div>
</div>
</div>
</div>
- </div>
- </li>
- );
- })}
- </ul>
- </div>
-
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ );
}
-function ShowEventDetails({ event }: { event: AmlEvent }): VNode {
- return <div>type {event.type}</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 {
+ type: "ok",
+ body: {
+ justification,
+ metadata: found,
+ },
+ };
+ } catch (e) {
+ return {
+ type: "fail",
+ case: "not-json",
+ detail: {} as TalerErrorDetail,
+ };
+ }
+}