commit 5d35ea590cc66aeba0a6beb69bd94c3b8c6627a5
parent 7031cd8e11943ce3c6afb941edb7c8bb603a4549
Author: Florian Dold <dold@taler.net>
Date: Thu, 27 Aug 2026 01:00:00 +0200
AML web UI: paginate case history and show request failures
Diffstat:
6 files changed, 297 insertions(+), 93 deletions(-)
diff --git a/packages/taler-exchange-aml-webui/src/hooks/account.ts b/packages/taler-exchange-aml-webui/src/hooks/account.ts
@@ -14,13 +14,21 @@
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import {
+ KycAttributeCollectionEvent,
OfficerSession,
+ opFixedSuccess,
PaytoString,
TalerExchangeResultByMethod2,
TalerHttpError,
} from "@gnu-taler/taler-util";
+import { dummyHttpResponse } from "@gnu-taler/taler-util/http";
// FIX default import https://github.com/microsoft/TypeScript/issues/49189
-import { useExchangeApiContext } from "@gnu-taler/web-util/browser";
+import {
+ buildPaginatedResult,
+ ListPointer,
+ useExchangeApiContext,
+ useListPointer,
+} from "@gnu-taler/web-util/browser";
import _useSWR, { mutate, SWRHook } from "swr";
import { useOfficer } from "./officer.js";
const useSWR = _useSWR as unknown as SWRHook;
@@ -44,26 +52,107 @@ export function useAccountInformation(paytoHash?: string) {
const {
lib: { exchange: api },
} = useExchangeApiContext();
+ const [pointer, updatePointer] = useListPointer(
+ (row: KycAttributeCollectionEvent) => String(row.rowid),
+ );
- async function fetcher([officer, account]: [OfficerSession, PaytoString]) {
- return await api.getAmlAttributesForAccount(officer, account);
+ async function fetcher([officer, account, offset]: [
+ OfficerSession,
+ PaytoString,
+ ListPointer,
+ ]) {
+ return await api.getAmlAttributesForAccount(officer, account, {
+ order: offset.order,
+ offset: offset.id,
+ limit: 11,
+ });
}
const { data, error } = useSWR<
TalerExchangeResultByMethod2<"getAmlAttributesForAccount">,
TalerHttpError
- >(!session ? undefined : [session, paytoHash], fetcher, {
- refreshInterval: 0,
- refreshWhenHidden: false,
- revalidateOnFocus: false,
- revalidateOnReconnect: false,
- refreshWhenOffline: false,
- errorRetryCount: 0,
- errorRetryInterval: 1,
- shouldRetryOnError: false,
- keepPreviousData: true,
- });
+ >(
+ !session
+ ? undefined
+ : [session, paytoHash, pointer, "getAmlAttributesForAccount"],
+ fetcher,
+ {
+ refreshInterval: 0,
+ refreshWhenHidden: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ refreshWhenOffline: false,
+ errorRetryCount: 0,
+ errorRetryInterval: 1,
+ shouldRetryOnError: false,
+ keepPreviousData: true,
+ },
+ );
+
+ if (data?.type === "ok") {
+ return buildPaginatedResult(data.body.details, pointer, updatePointer, 11);
+ }
+ if (data) return data;
+ if (error) return error;
+ return undefined;
+}
+export function useAccountInformationAround(paytoHash: string, rowId: number) {
+ const officer = useOfficer();
+ const session = officer.state === "ready" ? officer.session : undefined;
+ const {
+ lib: { exchange: api },
+ } = useExchangeApiContext();
+
+ async function fetcher([officer, account, targetRow]: [
+ OfficerSession,
+ string,
+ number,
+ ]) {
+ const current = await api.getAmlAttributesForAccount(officer, account, {
+ order: "dec",
+ offset: String(targetRow + 1),
+ limit: 1,
+ });
+ if (current.type !== "ok") return current;
+ const event = current.body.details.find((row) => row.rowid === targetRow);
+ if (!event) {
+ return opFixedSuccess(dummyHttpResponse, {
+ event: undefined,
+ previousId: undefined,
+ nextId: undefined,
+ });
+ }
+ const [older, newer] = await Promise.all([
+ api.getAmlAttributesForAccount(officer, account, {
+ order: "dec",
+ offset: String(targetRow),
+ limit: 1,
+ }),
+ api.getAmlAttributesForAccount(officer, account, {
+ order: "asc",
+ offset: String(targetRow),
+ limit: 1,
+ }),
+ ]);
+ if (older.type !== "ok") return older;
+ if (newer.type !== "ok") return newer;
+ return opFixedSuccess(dummyHttpResponse, {
+ event,
+ previousId: older.body.details[0]?.rowid,
+ nextId: newer.body.details[0]?.rowid,
+ });
+ }
+
+ const { data, error } = useSWR<
+ Awaited<ReturnType<typeof fetcher>>,
+ TalerHttpError
+ >(
+ !session
+ ? undefined
+ : [session, paytoHash, rowId, "getAmlAttributesForAccountAround"],
+ fetcher,
+ );
if (data) return data;
if (error) return error;
return undefined;
diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx
@@ -19,16 +19,20 @@ import {
assertUnreachable,
HttpStatusCode,
OfficerSession,
+ OperationFail,
TalerError,
TalerExchangeApi,
TalerFormAttributes,
+ TranslatedString,
} from "@gnu-taler/taler-util";
import {
Attention,
AsyncButton,
CopyButton,
ErrorLoading,
+ FailLoading,
Loading,
+ Pagination,
RouteDefinition,
useExchangeApiContext,
useNotificationContext,
@@ -44,15 +48,15 @@ import { ShowDecisionLimitInfo } from "../components/ShowDecisionLimitInfo.js";
import { ShowDefaultRules } from "../components/ShowDefaultRules.js";
import { ShowLegistimizationInfo } from "../components/ShowLegitimizationInfo.js";
import { useAccountInformation } from "../hooks/account.js";
-import { DecisionRequest } from "../hooks/decision-request.js";
-import { useAccountDecisions } from "../hooks/decisions.js";
+import {
+ useAccountActiveDecision,
+ useAccountDecisions,
+} from "../hooks/decisions.js";
import { useCurrentLegitimizations } from "../hooks/legitimizations.js";
import { useOfficer } from "../hooks/officer.js";
import { useServerMeasures } from "../hooks/server-info.js";
import { BANK_RULES, WALLET_RULES } from "./decision/Rules.js";
-const utfDecoder = new TextDecoder("Latin1");
-
export function ShowProperties(props: {
properties?: AccountProperties;
}): VNode {
@@ -83,7 +87,7 @@ export function AccountDetails({
onNewDecision,
routeToShowTransfers,
}: {
- onNewDecision: (d: Partial<DecisionRequest>) => void;
+ onNewDecision: () => void;
routeToShowCollectedInfo: RouteDefinition<{ cid: string; rowId: string }>;
account: string;
routeToShowTransfers: RouteDefinition<{ cid: string }>;
@@ -91,6 +95,7 @@ export function AccountDetails({
const { i18n } = useTranslationContext();
const details = useAccountInformation(account);
const history = useAccountDecisions(account);
+ const active = useAccountActiveDecision(account);
const legistimizations = useCurrentLegitimizations(account);
const officer = useOfficer();
const session = officer.state === "ready" ? officer.session : undefined;
@@ -136,7 +141,7 @@ export function AccountDetails({
const measures = useServerMeasures();
- if (!details || !history || !legistimizations) {
+ if (!details || !history || !active || !legistimizations || !measures) {
return <Loading />;
}
if (details instanceof TalerError) {
@@ -148,14 +153,12 @@ export function AccountDetails({
);
}
if (details.type === "fail") {
- switch (details.case) {
- case HttpStatusCode.Forbidden:
- case HttpStatusCode.NotFound:
- case HttpStatusCode.Conflict:
- return <div />;
- default:
- assertUnreachable(details);
- }
+ return (
+ <KnownFailure
+ title={i18n.str`Failed to load account information.`}
+ operation={details}
+ />
+ );
}
if (history instanceof TalerError) {
return (
@@ -166,14 +169,28 @@ export function AccountDetails({
);
}
if (history.type === "fail") {
- switch (history.case) {
- case HttpStatusCode.Forbidden:
- case HttpStatusCode.NotFound:
- case HttpStatusCode.Conflict:
- return <div />;
- default:
- assertUnreachable(history);
- }
+ return (
+ <KnownFailure
+ title={i18n.str`Failed to load decision history.`}
+ operation={history}
+ />
+ );
+ }
+ if (active instanceof TalerError) {
+ return (
+ <ErrorLoading
+ title={i18n.str`Failed to load the active AML decision.`}
+ error={active}
+ />
+ );
+ }
+ if (active.type === "fail") {
+ return (
+ <KnownFailure
+ title={i18n.str`Failed to load the active AML decision.`}
+ operation={active}
+ />
+ );
}
if (legistimizations instanceof TalerError) {
return (
@@ -184,24 +201,38 @@ export function AccountDetails({
);
}
if (legistimizations.type === "fail") {
- switch (legistimizations.case) {
- case HttpStatusCode.NotFound:
- return <div />;
- default:
- assertUnreachable(legistimizations);
- }
+ return (
+ <KnownFailure
+ title={i18n.str`Failed to load current legitimizations.`}
+ operation={legistimizations}
+ />
+ );
+ }
+ if (measures instanceof TalerError) {
+ return (
+ <ErrorLoading
+ title={i18n.str`Failed to load available measures.`}
+ error={measures}
+ />
+ );
+ }
+ if (measures.type === "fail") {
+ return (
+ <KnownFailure
+ title={i18n.str`Failed to load available measures.`}
+ operation={measures}
+ />
+ );
}
- const collectionEvents = details.body.details;
-
- collectionEvents.sort((a, b) =>
+ const collectionEvents = [...details.body].sort((a, b) =>
AbsoluteTime.cmp(
AbsoluteTime.fromProtocolTimestamp(a.collection_time),
AbsoluteTime.fromProtocolTimestamp(b.collection_time),
),
);
- const activeDecision = history.body.find((d) => d.is_active);
+ const activeDecision = active.body;
const restDecisions = !activeDecision
? history.body
: history.body.filter((d) => d.rowid !== activeDecision.rowid);
@@ -216,10 +247,7 @@ export function AccountDetails({
// 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,
- });
+ onNewDecision();
}}
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"
>
@@ -237,15 +265,8 @@ export function AccountDetails({
);
}
- const defaultRules =
- !measures || measures instanceof TalerError || measures.type === "fail"
- ? []
- : measures.body.default_rules;
-
- const serverMeasures =
- !measures || measures instanceof TalerError || measures.type === "fail"
- ? undefined
- : measures.body;
+ const defaultRules = measures.body.default_rules;
+ const serverMeasures = measures.body;
const filteredRulesByType = !activeDecision
? defaultRules
@@ -346,6 +367,11 @@ export function AccountDetails({
history={collectionEvents}
routeToShowCollectedInfo={routeToShowCollectedInfo}
/>
+ <Pagination
+ onFirstPage={details.loadFirst}
+ onPrevious={details.loadPrev}
+ onNext={details.loadNext}
+ />
</Fragment>
)}
@@ -403,6 +429,11 @@ export function AccountDetails({
/>
);
})}
+ <Pagination
+ onFirstPage={history.loadFirst}
+ onPrevious={history.loadPrev}
+ onNext={history.loadNext}
+ />
</div>
) : !activeDecision ? (
<div class="ty-4">
@@ -425,6 +456,11 @@ export function AccountDetails({
/>
);
})}
+ <Pagination
+ onFirstPage={legistimizations.loadFirst}
+ onPrevious={legistimizations.loadPrev}
+ onNext={legistimizations.loadNext}
+ />
</div>
) : (
<Fragment />
@@ -433,6 +469,39 @@ export function AccountDetails({
);
}
+function KnownFailure({
+ title,
+ operation,
+}: {
+ title: TranslatedString;
+ operation: OperationFail<
+ HttpStatusCode.Forbidden | HttpStatusCode.NotFound | HttpStatusCode.Conflict
+ >;
+}): VNode {
+ return (
+ <FailLoading
+ title={title}
+ operation={operation}
+ translate={(failure, i18n) => {
+ switch (failure.case) {
+ case HttpStatusCode.Forbidden:
+ return <p>{i18n.str`The officer session signature is invalid.`}</p>;
+ case HttpStatusCode.NotFound:
+ return (
+ <p>{i18n.str`The requested AML account or session was not found.`}</p>
+ );
+ case HttpStatusCode.Conflict:
+ return (
+ <p>{i18n.str`The officer session is disabled or the account state changed.`}</p>
+ );
+ default:
+ return assertUnreachable(failure.case);
+ }
+ }}
+ />
+ );
+}
+
function ShowTimeline({
history,
account,
diff --git a/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx b/packages/taler-exchange-aml-webui/src/pages/AccountList.tsx
@@ -40,7 +40,7 @@ import { Fragment, VNode, h } from "preact";
import { useAmlAccounts } from "../hooks/decisions.js";
import { format } from "date-fns";
-import { useEffect, useState } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import csvIcon from "../assets/csv-icon.png";
import xlsIcon from "../assets/excel-icon.png";
import { useOfficer } from "../hooks/officer.js";
@@ -93,10 +93,14 @@ export function AccountList({
const download = useNotifiedOperation<
Awaited<ReturnType<typeof lib.exchange.getAmlAccountsAsOtherFormat>>,
- [OfficerSession, Mime]
+ [
+ OfficerSession,
+ Mime,
+ { investigation?: boolean; open?: boolean; highRisk?: boolean },
+ ]
>(
- (_ct, officerSession: OfficerSession, mime: Mime) =>
- lib.exchange.getAmlAccountsAsOtherFormat(officerSession, mime),
+ (_ct, officerSession: OfficerSession, mime: Mime, filters) =>
+ lib.exchange.getAmlAccountsAsOtherFormat(officerSession, mime, filters),
{
onFail: showError(i18n.str`Failed to download`, (fail) => {
switch (fail.case) {
@@ -223,7 +227,14 @@ export function AccountList({
<AsyncButton
disabled={download.running}
onClick={
- !session ? undefined : () => download.run(session, "text/csv")
+ !session
+ ? undefined
+ : () =>
+ download.run(session, "text/csv", {
+ investigation: investigated,
+ open: opened,
+ highRisk,
+ })
}
>
<img class="size-6 w-6" src={csvIcon} />
@@ -233,7 +244,12 @@ export function AccountList({
onClick={
!session
? undefined
- : () => download.run(session, "application/vnd.ms-excel")
+ : () =>
+ download.run(session, "application/vnd.ms-excel", {
+ investigation: investigated,
+ open: opened,
+ highRisk,
+ })
}
>
<img class="size-6 w-6" src={xlsIcon} />
@@ -572,7 +588,6 @@ export const SearchIcon = (props?: h.JSX.SVGAttributes<SVGSVGElement>) => (
</svg>
);
-let latestTimeout: undefined | ReturnType<typeof setTimeout> = undefined;
type FilterName = "investigated" | "highRisk" | "open";
function JumpByIdForm({
caseByIdRoute,
@@ -594,15 +609,18 @@ function JumpByIdForm({
const { lib } = useExchangeApiContext();
const [valid, setValid] = useState(false);
const [error, setError] = useState<string>();
+ const requestSequence = useRef(0);
useEffect(() => {
- if (!session || !account) return;
- const activeSession = session;
- if (latestTimeout) {
- clearTimeout(latestTimeout);
+ const sequence = ++requestSequence.current;
+ if (!session || !account) {
+ setError(undefined);
+ setValid(false);
+ return;
}
+ const activeSession = session;
setError(undefined);
setValid(false);
- latestTimeout = setTimeout(async function checkAccouunt() {
+ const timeout = setTimeout(async function checkAccount() {
let found = false;
try {
const result = await lib.exchange.getAmlAttributesForAccount(
@@ -614,11 +632,13 @@ function JumpByIdForm({
} catch (e) {
console.log(e);
}
+ if (requestSequence.current !== sequence) return;
setValid(found);
if (!found) {
setError(i18n.str`Invalid account`);
}
}, 500);
+ return () => clearTimeout(timeout);
}, [account, session]);
return (
<form class="mt-5 grid grid-cols-1">
diff --git a/packages/taler-exchange-aml-webui/src/pages/Search.tsx b/packages/taler-exchange-aml-webui/src/pages/Search.tsx
@@ -49,7 +49,7 @@ import {
useForm,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
-import { Fragment, h, VNode } from "preact";
+import { h, VNode } from "preact";
import { useState } from "preact/hooks";
import { BitcoinSewgit } from "../../../taler-util/src/segwit_addr.js";
import { HandleSessionNotReady } from "../components/HandleAccountNotReady.js";
@@ -270,8 +270,8 @@ function ShowResult({
<i18n.Translate>ACTIVE</i18n.Translate>
</span>
) : undefined}
- {r.decision_time ? (
- <span title="require investigation">
+ {r.to_investigate ? (
+ <span title={i18n.str`Requires investigation`}>
<ToInvestigateIcon />
</span>
) : undefined}
@@ -283,6 +283,7 @@ function ShowResult({
</table>
<Pagination
onFirstPage={history.loadFirst}
+ onPrevious={history.loadPrev}
onNext={history.loadNext}
/>
</div>
diff --git a/packages/taler-exchange-aml-webui/src/pages/ShowCollectedInfo.tsx b/packages/taler-exchange-aml-webui/src/pages/ShowCollectedInfo.tsx
@@ -27,14 +27,13 @@ import {
FormMetadata,
FormUI,
Loading,
- preloadedForms,
RouteDefinition,
useFormMeta,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
-import { useAccountInformation } from "../hooks/account.js";
-import { Profile } from "./Profile.js";
+import { useUiFormsContext } from "../context/ui-forms.js";
+import { useAccountInformationAround } from "../hooks/account.js";
const TALER_SCREEN_ID = 110;
@@ -50,8 +49,9 @@ export function ShowCollectedInfo({
routeToAccountById: RouteDefinition<{ cid: string }>;
}): VNode {
const { i18n } = useTranslationContext();
+ const { forms } = useUiFormsContext();
- const details = useAccountInformation(account);
+ const details = useAccountInformationAround(account, rowId);
if (!details) {
return <Loading />;
@@ -100,20 +100,24 @@ export function ShowCollectedInfo({
);
}
- const { details: history } = details.body;
- const eventIndex = history.findIndex((h) => h.rowid === rowId);
- const event = eventIndex === -1 ? undefined : history[eventIndex];
+ const { event, previousId, nextId } = details.body;
if (!event) {
- return <div>There is no collection event with id {rowId}</div>;
+ return (
+ <Attention type="warning" title={i18n.str`Collection event not found`}>
+ <i18n.Translate>
+ There is no collection event with ID {rowId} for this account.
+ </i18n.Translate>
+ </Attention>
+ );
}
- const hasNext = eventIndex < history.length - 1;
- const hasPrevious = eventIndex > 0;
- const previousId = hasPrevious ? history[eventIndex - 1].rowid : undefined;
- const nextId = hasNext ? history[eventIndex + 1].rowid : undefined;
if (!event.attributes) {
return (
- <div>the event referenced by rowId doesn't have any information</div>
+ <Attention type="warning" title={i18n.str`Attributes unavailable`}>
+ <i18n.Translate>
+ The collection event does not contain readable attributes.
+ </i18n.Translate>
+ </Attention>
);
}
const FORM_ID = event.attributes[TalerFormAttributes.FORM_ID] as
@@ -123,14 +127,26 @@ export function ShowCollectedInfo({
| number
| undefined;
if (!FORM_ID) {
- return <div>no form id in the collected information</div>;
+ return (
+ <Attention type="warning" title={i18n.str`Form identifier missing`}>
+ <i18n.Translate>
+ The collected information does not identify the form used to create
+ it.
+ </i18n.Translate>
+ </Attention>
+ );
}
- const forms = preloadedForms(i18n);
const formsWithId = forms.filter((f) => f.id === FORM_ID);
if (!formsWithId.length) {
- return <div>form not found with id {FORM_ID}</div>;
+ return (
+ <Attention type="warning" title={i18n.str`Form not available`}>
+ <i18n.Translate>
+ Form {FORM_ID} is not configured in this AML Web UI.
+ </i18n.Translate>
+ </Attention>
+ );
}
let lastVersion = -1;
@@ -152,7 +168,7 @@ export function ShowCollectedInfo({
return (
<ShowForm
- key={eventIndex}
+ key={rowId}
form={formToBeUsed}
account={account}
data={event.attributes ?? {}}
@@ -228,7 +244,7 @@ function ShowForm({
data-disabled={previousId === undefined}
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 data-[disabled=true]:bg-gray-500"
>
- <i18n.Translate>Next event</i18n.Translate>
+ <i18n.Translate>Previous event</i18n.Translate>
</a>
<a
href={
@@ -242,7 +258,7 @@ function ShowForm({
data-disabled={nextId === undefined}
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 data-[disabled=true]:bg-gray-500"
>
- <i18n.Translate>Previous event</i18n.Translate>
+ <i18n.Translate>Next event</i18n.Translate>
</a>
</div>
<FormUI design={design} model={model} disabled />
diff --git a/packages/web-util/src/components/Pagination.tsx b/packages/web-util/src/components/Pagination.tsx
@@ -9,9 +9,11 @@ import { useTranslationContext } from "../context/translation.js";
*/
export function Pagination({
onFirstPage,
+ onPrevious,
onNext,
}: {
onFirstPage?: () => void;
+ onPrevious?: () => void;
onNext?: () => void;
}) {
const { i18n } = useTranslationContext();
@@ -30,6 +32,13 @@ export function Pagination({
</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={!onPrevious}
+ onClick={onPrevious}
+ >
+ <i18n.Translate>Previous</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}
>