taler-typescript-core

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

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:
Mpackages/taler-exchange-aml-webui/src/hooks/account.ts | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/taler-exchange-aml-webui/src/pages/AccountDetails.tsx | 159++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-exchange-aml-webui/src/pages/AccountList.tsx | 44++++++++++++++++++++++++++++++++------------
Mpackages/taler-exchange-aml-webui/src/pages/Search.tsx | 7++++---
Mpackages/taler-exchange-aml-webui/src/pages/ShowCollectedInfo.tsx | 54+++++++++++++++++++++++++++++++++++-------------------
Mpackages/web-util/src/components/Pagination.tsx | 9+++++++++
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} >