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:
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