taler-typescript-core

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

commit 6782fad916bcceac752873004e41277421a1e795
parent b16e687faf720c8996262352964d7f562388682a
Author: Florian Dold <dold@taler.net>
Date:   Mon, 31 Aug 2026 22:40:31 +0200

AML WebUI: add form PDF previews

Diffstat:
Mpackages/taler-exchange-aml-webui/src/Routing.test.tsx | 8++++++++
Mpackages/taler-exchange-aml-webui/src/Routing.tsx | 35+++++++++++++++++++++++++++++++++--
Apackages/taler-exchange-aml-webui/src/pages/Forms.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-exchange-aml-webui/src/pages/Forms.tsx | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts | 34++++++++++++++++++++++++++++++++--
Mpackages/taler-util/src/http-client/exchange-client.ts | 43+++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-util/src/http-client/status-handling.test.ts | 29+++++++++++++++++++++++++++++
7 files changed, 501 insertions(+), 4 deletions(-)

diff --git a/packages/taler-exchange-aml-webui/src/Routing.test.tsx b/packages/taler-exchange-aml-webui/src/Routing.test.tsx @@ -81,6 +81,7 @@ test("top-level pages are separate navigation destinations", async () => { "Accounts", "Find account", "Transfers", + "Forms", "AML configuration", "Session", ], @@ -99,6 +100,13 @@ test("top-level pages are separate navigation destinations", async () => { ); assert.equal(linkWithText(links, "Accounts").dataset.selected, "false"); + links = navigationLinksFor("/forms"); + assert.equal(linkWithText(links, "Forms").dataset.selected, "true"); + assert.equal( + linkWithText(links, "AML configuration").dataset.selected, + "false", + ); + render(null, container as unknown as Element); container.remove(); await window.happyDOM.abort(); diff --git a/packages/taler-exchange-aml-webui/src/Routing.tsx b/packages/taler-exchange-aml-webui/src/Routing.tsx @@ -62,6 +62,7 @@ import { Dashboard } from "./pages/Dashboard.js"; import { DecisionWizard, WizardSteps } from "./pages/DecisionWizard.js"; import { Developer } from "./pages/Developer.js"; import { Info } from "./pages/Info.js"; +import { Forms } from "./pages/Forms.js"; import { Profile } from "./pages/Profile.js"; import { Search } from "./pages/Search.js"; import { ShowCollectedInfo } from "./pages/ShowCollectedInfo.js"; @@ -71,7 +72,7 @@ import { initialRuleDraft, } from "./utils/rule-defaults.js"; -const AML_OFFICER_RESOURCE_PROTOCOL = "40:0:0"; +const AML_OFFICER_RESOURCE_PROTOCOL = "41:0:0"; const routes = { dev: "/dev", @@ -80,6 +81,7 @@ const routes = { accounts: "/accounts", search: "/search", transfers: "/transfers", + forms: "/forms", info: "/info", account: (cid: string) => `/account/${cid}`, accountTransfers: (cid: string) => `/account/${cid}/transfers`, @@ -179,7 +181,7 @@ export function OfficerAccessGate({ title={i18n.str`This exchange is not compatible with the AML WebUI.`} > <i18n.Translate> - Officer identity and permissions require exchange protocol v40 or + Officer identity and permissions require exchange protocol v41 or newer. </i18n.Translate> </Attention> @@ -434,6 +436,9 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { <Route path={routes.transfers}> <Transfers routeToAccountById={routeToAccountById} /> </Route> + <Route path={routes.forms}> + <Forms officer={officer} /> + </Route> <Route path={routes.info} component={Info} /> <Route path="/show-collected/:cid/:rowId"> {(params) => ( @@ -635,6 +640,12 @@ export function Navigation(): VNode { selected: (location: string) => location.startsWith("/transfers"), }, { + route: routes.forms, + Icon: FormIcon, + label: i18n.str`Forms`, + selected: (location: string) => location === routes.forms, + }, + { route: routes.info, Icon: ConfigurationIcon, label: i18n.str`AML configuration`, @@ -764,3 +775,23 @@ function ConfigurationIcon(): VNode { </svg> ); } + +function FormIcon(): VNode { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="h-6 w-6 shrink-0" + aria-hidden="true" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5A3.375 3.375 0 0 0 10.125 2.25H8.25m0 12.75h7.5m-7.5 3h7.5M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.625a9.375 9.375 0 0 0-9.375-9.375Z" + /> + </svg> + ); +} diff --git a/packages/taler-exchange-aml-webui/src/pages/Forms.test.ts b/packages/taler-exchange-aml-webui/src/pages/Forms.test.ts @@ -0,0 +1,45 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildRenderFormRequest, parseJsonObject } from "./Forms.js"; + +test("advanced form JSON must contain an object", () => { + assert.deepEqual(parseJsonObject("[]"), { + error: "The JSON value must be an object.", + }); + assert.deepEqual(parseJsonObject('{"staff":"Alice"}'), { + value: { staff: "Alice" }, + }); + assert.ok(parseJsonObject("{").error); +}); + +test("form fields and reserved metadata override advanced JSON", () => { + assert.deepEqual( + buildRenderFormRequest( + { id: "identity", version: 3 }, + { + FORM_ID: "wrong", + FORM_VERSION: 99, + FORM_CONTEXT: { wrong: true }, + full_name: "advanced", + filing_date: "2026-08-31", + }, + { full_name: "Alice Example" }, + { country: "DE" }, + ), + { + FORM_ID: "identity", + FORM_VERSION: 3, + FORM_CONTEXT: { country: "DE" }, + full_name: "Alice Example", + filing_date: "2026-08-31", + }, + ); +}); diff --git a/packages/taler-exchange-aml-webui/src/pages/Forms.tsx b/packages/taler-exchange-aml-webui/src/pages/Forms.tsx @@ -0,0 +1,311 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ +import { OfficerSession, TalerFormAttributes } from "@gnu-taler/taler-util"; +import { + AsyncButton, + Attention, + ErrorsSummary, + FormDesign, + FormMetadata, + FormUI, + useExchangeApiContext, + useForm, + useNotifiedOperation, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useMemo, useState } from "preact/hooks"; +import { useUiFormsContext } from "../context/ui-forms.js"; +import { OfficerReady } from "../hooks/officer.js"; + +type ParsedObject = + | { value: Record<string, unknown>; error?: undefined } + | { value?: undefined; error: string }; + +export function parseJsonObject(raw: string): ParsedObject { + try { + const parsed: unknown = JSON.parse(raw || "{}"); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) + ) { + return { error: "The JSON value must be an object." }; + } + return { value: parsed as Record<string, unknown> }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +export function buildRenderFormRequest( + metadata: Pick<FormMetadata, "id" | "version">, + advanced: Record<string, unknown>, + fields: Record<string, unknown>, + context: Record<string, unknown>, +): Record<string, unknown> { + return { + ...advanced, + ...fields, + [TalerFormAttributes.FORM_ID]: metadata.id, + [TalerFormAttributes.FORM_VERSION]: metadata.version, + [TalerFormAttributes.FORM_CONTEXT]: context, + }; +} + +export function Forms({ officer }: { officer: OfficerReady }): VNode { + const { i18n } = useTranslationContext(); + const { forms } = useUiFormsContext(); + const [filter, setFilter] = useState(""); + const [selected, setSelected] = useState<FormMetadata>(); + const matching = useMemo(() => { + const query = filter.trim().toLocaleLowerCase(); + return forms.filter( + (form) => + !query || + form.id.toLocaleLowerCase().includes(query) || + String(form.label).toLocaleLowerCase().includes(query), + ); + }, [filter, forms]); + + return ( + <div class="space-y-6"> + <div> + <h1 class="text-2xl font-semibold text-gray-950 dark:text-gray-50"> + <i18n.Translate>Render AML/KYC form</i18n.Translate> + </h1> + <p class="mt-2 text-sm text-gray-600 dark:text-gray-300"> + <i18n.Translate> + Fill a form and download the PDF exactly as the exchange renders it. + Nothing entered here is stored or attached to an account. + </i18n.Translate> + </p> + </div> + + {!selected ? ( + <div class="space-y-4"> + <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> + <i18n.Translate>Find a form</i18n.Translate> + <input + type="search" + class="mt-2 block w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-950" + value={filter} + onInput={(event) => setFilter(event.currentTarget.value)} + placeholder={i18n.str`Search by name or form identifier`} + /> + </label> + <div class="grid gap-3 lg:grid-cols-2"> + {matching.map((form) => ( + <button + type="button" + class="rounded-lg border border-outline p-4 text-left hover:bg-surfaceContainer" + onClick={() => setSelected(form)} + key={`${form.id}:${form.version}`} + > + <span class="block font-semibold text-gray-950 dark:text-gray-50"> + {form.label} + </span> + {form.description ? ( + <span class="mt-1 block text-sm text-gray-600 dark:text-gray-300"> + {form.description} + </span> + ) : undefined} + <span class="mt-2 block break-all font-mono text-xs text-gray-500 dark:text-gray-400"> + {form.id} · v{form.version} + </span> + </button> + ))} + </div> + {!matching.length ? ( + <p class="text-sm text-gray-600 dark:text-gray-300"> + <i18n.Translate>No matching forms.</i18n.Translate> + </p> + ) : undefined} + </div> + ) : ( + <FormRenderer + key={`${selected.id}:${selected.version}`} + form={selected} + session={officer.session} + onChooseAnother={() => setSelected(undefined)} + /> + )} + </div> + ); +} + +function FormRenderer({ + form, + session, + onChooseAnother, +}: { + form: FormMetadata; + session: OfficerSession; + onChooseAnother: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { lib } = useExchangeApiContext(); + const [rawContext, setRawContext] = useState("{}"); + const [rawData, setRawData] = useState("{}"); + const emptyDesign: FormDesign = { type: "single-column", fields: [] }; + const contextForm = useForm<Record<string, unknown>>( + form.contextForm ?? emptyDesign, + {}, + ); + const parsedContext = parseJsonObject(rawContext); + const context = form.contextForm + ? contextForm.status.result + : (parsedContext.value ?? {}); + const design = + typeof form.config === "function" ? form.config(context) : form.config; + const formModel = useForm<Record<string, unknown>>(design, {}); + const parsedData = parseJsonObject(rawData); + const request = parsedData.value + ? buildRenderFormRequest( + form, + parsedData.value, + formModel.status.result, + context, + ) + : undefined; + + const render = useNotifiedOperation< + Awaited<ReturnType<typeof lib.exchange.renderAmlFormAsPdf>>, + [Record<string, unknown>] + >((_ct, data) => lib.exchange.renderAmlFormAsPdf(session, data), { + onSuccess: (pdf) => { + const copy = new Uint8Array(pdf.byteLength); + copy.set(pdf); + const url = URL.createObjectURL( + new Blob([copy.buffer], { type: "application/pdf" }), + ); + const download = document.createElement("a"); + const safeId = form.id.replace(/[^a-zA-Z0-9_.-]+/g, "-"); + download.href = url; + download.download = `aml-form-${safeId}-v${form.version}.pdf`; + download.hidden = true; + document.body.append(download); + download.click(); + download.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + }, + }); + + return ( + <div class="space-y-6"> + <div class="flex flex-wrap items-start justify-between gap-4"> + <div> + <h2 class="text-xl font-semibold text-gray-950 dark:text-gray-50"> + {form.label} + </h2> + {form.description ? ( + <p class="mt-1 text-sm text-gray-600 dark:text-gray-300"> + {form.description} + </p> + ) : undefined} + <p class="mt-2 break-all font-mono text-xs text-gray-500 dark:text-gray-400"> + {form.id} · v{form.version} + </p> + </div> + <button + type="button" + class="rounded-md border border-outline px-3 py-2 text-sm font-semibold hover:bg-surfaceContainer" + onClick={onChooseAnother} + > + <i18n.Translate>Choose another form</i18n.Translate> + </button> + </div> + + {form.contextForm ? ( + <section class="rounded-lg border border-outline p-4"> + <h3 class="mb-3 font-semibold"> + <i18n.Translate>Form context</i18n.Translate> + </h3> + <FormUI design={form.contextForm} model={contextForm.model} /> + {contextForm.status.errors ? ( + <ErrorsSummary errors={contextForm.status.errors} /> + ) : undefined} + </section> + ) : ( + <JsonEditor + title={i18n.str`Advanced form context`} + help={i18n.str`Operator-defined values used to choose or configure form fields.`} + value={rawContext} + error={parsedContext.error} + onChange={setRawContext} + /> + )} + + <section class="space-y-3"> + {formModel.status.errors ? ( + <ErrorsSummary errors={formModel.status.errors} /> + ) : undefined} + <FormUI design={design} model={formModel.model} /> + </section> + + <JsonEditor + title={i18n.str`Advanced form data`} + help={i18n.str`Additional PDF fields that are not represented by the form above.`} + value={rawData} + error={parsedData.error} + onChange={setRawData} + /> + + <div class="flex items-center gap-4"> + <AsyncButton + class="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-onPrimary disabled:opacity-50" + disabled={!request || !!parsedContext.error} + onClick={() => request && render.run(request)} + > + <i18n.Translate>Render and download PDF</i18n.Translate> + </AsyncButton> + {render.running ? ( + <span class="text-sm text-gray-600 dark:text-gray-300"> + <i18n.Translate>Rendering PDF…</i18n.Translate> + </span> + ) : undefined} + </div> + </div> + ); +} + +function JsonEditor({ + title, + help, + value, + error, + onChange, +}: { + title: string; + help: string; + value: string; + error: string | undefined; + onChange: (value: string) => void; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <details class="rounded-lg border border-outline p-4"> + <summary class="cursor-pointer text-sm font-semibold">{title}</summary> + <p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{help}</p> + <textarea + aria-label={title} + class="mt-3 min-h-40 w-full rounded-md border-gray-300 font-mono text-sm dark:border-gray-700 dark:bg-gray-950" + value={value} + onInput={(event) => onChange(event.currentTarget.value)} + /> + {error ? ( + <div class="mt-3"> + <Attention type="danger" title={i18n.str`Invalid JSON`}> + {error} + </Attention> + </div> + ) : undefined} + </details> + ); +} diff --git a/packages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts b/packages/taler-harness/src/integrationtests/test-tops-aml-pdf.ts @@ -100,8 +100,8 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { const { decideMeasure, - submitForm, - submitOfficerForm, + submitForm: submitStoredForm, + submitOfficerForm: submitStoredOfficerForm, expectInvestigate, expectNoInvestigate, officerAcc, @@ -112,6 +112,36 @@ export async function runTopsAmlPdfTest(t: GlobalTestState) { challengerSms, } = await setupMeasuresTestEnvironment(t); + const renderForm = async ( + data: Record<string, unknown>, + label: string, + byAmlOfficer: boolean, + ): Promise<void> => { + const pdf = succeedOrThrow( + await exchangeClient.renderAmlFormAsPdf(officerAcc, { + FILE_NUMBER: "preview-test", + FILING_DATE: "2026-08-31", + AML_STAFF_NAME: "Alice AML Officer", + BY_AML_OFFICER: byAmlOfficer, + ...data, + }), + ); + assertPdf(t, pdf, `${label} preview`); + }; + + const submitForm = async ( + ...args: Parameters<typeof submitStoredForm> + ): ReturnType<typeof submitStoredForm> => { + await renderForm(args[1] as Record<string, unknown>, args[0], false); + return submitStoredForm(...args); + }; + const submitOfficerForm = async ( + ...args: Parameters<typeof submitStoredOfficerForm> + ): ReturnType<typeof submitStoredOfficerForm> => { + await renderForm(args[1] as Record<string, unknown>, args[0], true); + return submitStoredOfficerForm(...args); + }; + // Final PDF generation { const res = succeedOrThrow( diff --git a/packages/taler-util/src/http-client/exchange-client.ts b/packages/taler-util/src/http-client/exchange-client.ts @@ -1482,6 +1482,49 @@ export class TalerExchangeHttpClient { } /** + * Render one filled AML/KYC form without storing it. + * + * https://docs.taler.net/core/api-exchange.html#post--aml-$OFFICER_PUB-render-form + */ + async renderAmlFormAsPdf( + auth: OfficerSession, + form: Record<string, unknown>, + ): Promise< + | OperationOk<Uint8Array> + | OperationFail< + | HttpStatusCode.BadRequest + | HttpStatusCode.Forbidden + | HttpStatusCode.PayloadTooLarge + | HttpStatusCode.InternalServerError + | HttpStatusCode.NotImplemented + > + > { + const resp = await this.fetch(`aml/${pathSegment(auth.id)}/render-form`, { + method: "POST", + headers: { + "Taler-AML-Officer-Signature": encodeCrock( + signAmlQuery(auth.__signingKey), + ), + }, + body: form, + compress: this.preventCompression ? undefined : "deflate", + }); + + switch (resp.status) { + case HttpStatusCode.Ok: + return opFixedSuccess(resp, await resp.bytes()); + case HttpStatusCode.BadRequest: + case HttpStatusCode.Forbidden: + case HttpStatusCode.PayloadTooLarge: + case HttpStatusCode.InternalServerError: + case HttpStatusCode.NotImplemented: + return opKnownHttpFailure(resp.status, resp); + default: + return opUnknownHttpFailure(resp); + } + } + + /** * https://docs.taler.net/core/api-exchange.html#post--aml-$OFFICER_PUB-decision * */ diff --git a/packages/taler-util/src/http-client/status-handling.test.ts b/packages/taler-util/src/http-client/status-handling.test.ts @@ -91,6 +91,35 @@ test("AML officer resource returns identity and signs the request", async () => } }); +test("AML form rendering posts the filled form and returns PDF bytes", async () => { + const lib = new StubHttpLib(200, { pdf: "bytes" }); + const client = new TalerExchangeHttpClient("https://exchange.example/", { + httpClient: lib, + }); + const officer: OfficerSession = { + id: "4TQZQ0M6CB0JZXJ3Z1B4H7X3EVAQXX45SB9QT0NXZYKAHQXZ3EYG" as OfficerId, + __signingKey: new Uint8Array(32) as any, + }; + const form = { FORM_ID: "name_and_dob", full_name: "Alice Example" }; + + const result = await client.renderAmlFormAsPdf(officer, form); + + assert.strictEqual( + lib.lastUrl, + `${client.baseUrl}aml/${officer.id}/render-form`, + ); + assert.strictEqual(lib.lastOptions?.method, "POST"); + assert.deepStrictEqual(lib.lastOptions?.body, form); + assert.ok(lib.lastOptions?.headers?.["Taler-AML-Officer-Signature"]); + assert.strictEqual(result.type, "ok"); + if (result.type === "ok") { + assert.deepStrictEqual( + result.body, + new TextEncoder().encode(JSON.stringify({ pdf: "bytes" })), + ); + } +}); + test("an unknown corebank account is not an empty token list", async (t) => { // 404 means the base URL is wrong or the account is gone. Reporting it as // a successful empty list makes a misconfigured bank indistinguishable from