commit 5b29c9a433341c87f4fa5b764c765004bcbb6e6e parent 1d7e843f14610d200538f8c4410049f3d4addca4 Author: Florian Dold <dold@taler.net> Date: Mon, 31 Aug 2026 23:20:06 +0200 AML WebUI: navigate form previews and drop account-name prompt Diffstat:
12 files changed, 187 insertions(+), 102 deletions(-)
diff --git a/packages/taler-exchange-aml-webui/src/Routing.test.tsx b/packages/taler-exchange-aml-webui/src/Routing.test.tsx @@ -107,6 +107,9 @@ test("top-level pages are separate navigation destinations", async () => { "false", ); + links = navigationLinksFor("/forms/D5Q6ASK9DXJP6X35/1"); + assert.equal(linkWithText(links, "Forms").dataset.selected, "true"); + 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 @@ -82,6 +82,8 @@ const routes = { search: "/search", transfers: "/transfers", forms: "/forms", + form: (formId: string, version: number) => + `/forms/${encodeCrockForURI(formId)}/${version}`, info: "/info", account: (cid: string) => `/account/${cid}`, accountTransfers: (cid: string) => `/account/${cid}/transfers`, @@ -436,8 +438,25 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { <Route path={routes.transfers}> <Transfers routeToAccountById={routeToAccountById} /> </Route> + <Route path="/forms/:formId/:version"> + {(params) => ( + <Forms + officer={officer} + selectedForm={{ + id: decodeCrockFromURI(params.formId), + version: Number.parseInt(params.version, 10), + }} + routeToForms={routes.forms} + routeToForm={routes.form} + /> + )} + </Route> <Route path={routes.forms}> - <Forms officer={officer} /> + <Forms + officer={officer} + routeToForms={routes.forms} + routeToForm={routes.form} + /> </Route> <Route path={routes.info} component={Info} /> <Route path="/show-collected/:cid/:rowId"> @@ -600,7 +619,6 @@ function DecisionPage({ <DecisionWizard account={account} fullPayto={targetPayto} - newAccount={!original} step={step} formId={formId} onMove={onMove} @@ -643,7 +661,8 @@ export function Navigation(): VNode { route: routes.forms, Icon: FormIcon, label: i18n.str`Forms`, - selected: (location: string) => location === routes.forms, + selected: (location: string) => + location === routes.forms || location.startsWith(`${routes.forms}/`), }, { route: routes.info, diff --git a/packages/taler-exchange-aml-webui/src/hooks/decision-request.ts b/packages/taler-exchange-aml-webui/src/hooks/decision-request.ts @@ -78,10 +78,6 @@ export interface DecisionRequest { */ justification?: string; /** - * Name of the account holder if this is an unknown account to the exchange - */ - accountName?: string; - /** * Custom properties not listed on GANA */ custom_properties?: Record<string, string>; @@ -113,7 +109,6 @@ const DECISION_REQUEST_EMPTY: DecisionRequest = { onExpire_measure: undefined, custom_events: undefined, attributes: undefined, - accountName: undefined, properties_errors: undefined, triggering_events: undefined, justification: undefined, diff --git a/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.stories.tsx b/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.stories.tsx @@ -226,7 +226,6 @@ function Story({ <DecisionWizard account={accountId} fullPayto={payto} - newAccount={newAccount} step={step} formId={formId} officer={officer} @@ -368,7 +367,6 @@ export const NewWalletDecision = tests.createExample( initial: { ...completeDecision, original: undefined, - accountName: "New wallet customer", }, }, { @@ -422,7 +420,6 @@ export const DefaultRulesAwaitingReview = tests.createExample( initial: { ...completeDecision, original: undefined, - accountName: "Example Trading AG", ...initialRuleDraft( effectiveRuleLimits(undefined, measureSummary, false), ), diff --git a/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.tsx b/packages/taler-exchange-aml-webui/src/pages/DecisionWizard.tsx @@ -93,20 +93,9 @@ export function isMeasuresCompleted(request: DecisionRequest): boolean { export function isJustificationCompleted(request: DecisionRequest): boolean { return request.keep_investigating !== undefined && !!request.justification; } -export function isJustificationCompletedForNewACcount( - request: DecisionRequest, -): boolean { - return ( - request.keep_investigating !== undefined && - !!request.justification && - !!request.accountName - ); -} - export function DecisionWizard({ account, fullPayto, - newAccount, step, formId, onMove, @@ -114,7 +103,6 @@ export function DecisionWizard({ }: { account: string; fullPayto: string; - newAccount: boolean; formId: string | undefined; step?: WizardSteps; officer: OfficerReady; @@ -122,7 +110,7 @@ export function DecisionWizard({ }): VNode { const { i18n } = useTranslationContext(); const stepOrDefault = step ?? STEPS_ORDER[0]; - const stepInfo = getStepInfo(i18n, newAccount)[stepOrDefault]; + const stepInfo = getStepInfo(i18n)[stepOrDefault]; const stepNumber = STEPS_ORDER.indexOf(stepOrDefault) + 1; const content = (function () { switch (stepOrDefault) { @@ -135,7 +123,7 @@ export function DecisionWizard({ case "measures": return <Measures />; case "justification": - return <Justification newAccount={newAccount} />; + return <Justification />; case "attributes": return <Attributes formId={formId} />; case "summary": @@ -144,7 +132,6 @@ export function DecisionWizard({ account={account} onMove={onMove} fullPayto={fullPayto} - newAccount={newAccount} officer={officer} /> ); @@ -154,11 +141,7 @@ export function DecisionWizard({ return ( <div class="space-y-6"> - <WizardSteps - step={stepOrDefault} - onMove={onMove} - newAccount={newAccount} - /> + <WizardSteps step={stepOrDefault} onMove={onMove} /> <section class="overflow-visible rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-950" aria-labelledby="decision-step-heading" @@ -216,7 +199,6 @@ type StepInfo = { function getStepInfo( i18n: ReturnType<typeof useTranslationContext>["i18n"], - newAccount: boolean, ): Record<WizardSteps, StepInfo> { return { attributes: { @@ -247,9 +229,7 @@ function getStepInfo( justification: { label: i18n.str`Case disposition`, description: i18n.str`Record the rationale and whether the case remains under investigation.`, - isCompleted: newAccount - ? isJustificationCompletedForNewACcount - : isJustificationCompleted, + isCompleted: isJustificationCompleted, }, summary: { label: i18n.str`Review and submit`, @@ -262,15 +242,13 @@ function getStepInfo( function WizardSteps({ step: currentStep, onMove, - newAccount, }: { step: WizardSteps; onMove: (n: WizardSteps | undefined) => void; - newAccount: boolean; }): VNode { const [request] = useCurrentDecisionRequest(); const { i18n } = useTranslationContext(); - const STEP_INFO = getStepInfo(i18n, newAccount); + const STEP_INFO = getStepInfo(i18n); return ( <div class="overflow-x-auto lg:overflow-visible"> <nav aria-label={i18n.str`Decision progress`}> diff --git a/packages/taler-exchange-aml-webui/src/pages/Forms.test.ts b/packages/taler-exchange-aml-webui/src/pages/Forms.test.ts @@ -6,9 +6,41 @@ 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 { setupI18n } from "@gnu-taler/taler-util"; +import { encodeCrockForURI } from "@gnu-taler/web-util/browser"; import assert from "node:assert/strict"; import test from "node:test"; -import { buildRenderFormRequest, parseJsonObject } from "./Forms.js"; +import { h, render } from "preact"; +import { Router } from "wouter-preact"; +import { memoryLocation } from "wouter-preact/memory-location"; +import { UiFormsProvider } from "../context/ui-forms.js"; +import { OfficerReady } from "../hooks/officer.js"; +import { buildRenderFormRequest, Forms, parseJsonObject } from "./Forms.js"; + +setupI18n("en", {}); + +async function installDom() { + const happyDomPackage = "happy-dom"; + const { Window } = await import(happyDomPackage); + const window = new Window({ url: "https://aml.example/#/forms" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + HTMLElement: window.HTMLElement, + HTMLAnchorElement: window.HTMLAnchorElement, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} test("advanced form JSON must contain an object", () => { assert.deepEqual(parseJsonObject("[]"), { @@ -43,3 +75,53 @@ test("form fields and reserved metadata override advanced JSON", () => { }, ); }); + +test("opening a form adds navigation history and details link back", async () => { + const window = await installDom(); + const location = memoryLocation({ path: "/forms", record: true }); + const container = window.document.createElement("div"); + window.document.body.append(container); + const routeToForm = (formId: string, version: number): string => + `/forms/${encodeCrockForURI(formId)}/${version}`; + const officer = {} as OfficerReady; + + const page = (selectedForm?: { id: string; version: number }) => + h(Router, { + hook: location.hook, + children: h(UiFormsProvider, { + children: h(Forms, { + officer, + selectedForm, + routeToForms: "/forms", + routeToForm, + }), + }), + }); + + render(page(), container as unknown as Element); + const formLink = container.querySelector( + 'a[href^="/forms/"]', + ) as HTMLAnchorElement | null; + assert.ok(formLink); + const detailRoute = formLink.getAttribute("href"); + assert.ok(detailRoute); + + formLink.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(location.history, ["/forms", detailRoute]); + + render( + page({ id: "missing-test-form", version: 1 }), + container as unknown as Element, + ); + const backLink = container.querySelector( + 'a[href="/forms"]', + ) as HTMLAnchorElement | null; + assert.ok(backLink); + assert.equal(backLink.textContent, "←Back to forms"); + assert.equal(backLink.getAttribute("href"), "/forms"); + + render(null, container as unknown as Element); + container.remove(); + await window.happyDOM.abort(); +}); diff --git a/packages/taler-exchange-aml-webui/src/pages/Forms.tsx b/packages/taler-exchange-aml-webui/src/pages/Forms.tsx @@ -21,6 +21,7 @@ import { } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useMemo, useState } from "preact/hooks"; +import { Link } from "wouter-preact"; import { useUiFormsContext } from "../context/ui-forms.js"; import { OfficerReady } from "../hooks/officer.js"; @@ -59,11 +60,26 @@ export function buildRenderFormRequest( }; } -export function Forms({ officer }: { officer: OfficerReady }): VNode { +export function Forms({ + officer, + selectedForm, + routeToForms, + routeToForm, +}: { + officer: OfficerReady; + selectedForm?: Pick<FormMetadata, "id" | "version">; + routeToForms: string; + routeToForm: (formId: string, version: number) => string; +}): VNode { const { i18n } = useTranslationContext(); const { forms } = useUiFormsContext(); const [filter, setFilter] = useState(""); - const [selected, setSelected] = useState<FormMetadata>(); + const selected = selectedForm + ? forms.find( + (form) => + form.id === selectedForm.id && form.version === selectedForm.version, + ) + : undefined; const matching = useMemo(() => { const query = filter.trim().toLocaleLowerCase(); return forms.filter( @@ -77,7 +93,20 @@ export function Forms({ officer }: { officer: OfficerReady }): VNode { return ( <div class="space-y-6"> <div> - <h1 class="text-2xl font-semibold text-gray-950 dark:text-gray-50"> + {selectedForm ? ( + <Link + href={routeToForms} + class="inline-flex min-h-10 items-center rounded-md text-sm font-semibold text-primary hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:text-darkPrimary" + > + <span aria-hidden="true">←</span> + <span class="ml-2"> + <i18n.Translate>Back to forms</i18n.Translate> + </span> + </Link> + ) : undefined} + <h1 + class={`${selectedForm ? "mt-3 " : ""}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"> @@ -88,7 +117,7 @@ export function Forms({ officer }: { officer: OfficerReady }): VNode { </p> </div> - {!selected ? ( + {!selectedForm ? ( <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> @@ -102,10 +131,9 @@ export function Forms({ officer }: { officer: OfficerReady }): VNode { </label> <div class="grid gap-3 lg:grid-cols-2"> {matching.map((form) => ( - <button - type="button" + <Link + href={routeToForm(form.id, form.version)} 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"> @@ -119,7 +147,7 @@ export function Forms({ officer }: { officer: OfficerReady }): VNode { <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> + </Link> ))} </div> {!matching.length ? ( @@ -128,13 +156,18 @@ export function Forms({ officer }: { officer: OfficerReady }): VNode { </p> ) : undefined} </div> - ) : ( + ) : selected ? ( <FormRenderer key={`${selected.id}:${selected.version}`} form={selected} session={officer.session} - onChooseAnother={() => setSelected(undefined)} /> + ) : ( + <Attention type="warning" title={i18n.str`No matching forms.`}> + <code class="break-all"> + {selectedForm.id} · v{selectedForm.version} + </code> + </Attention> )} </div> ); @@ -143,11 +176,9 @@ export function Forms({ officer }: { officer: OfficerReady }): VNode { function FormRenderer({ form, session, - onChooseAnother, }: { form: FormMetadata; session: OfficerSession; - onChooseAnother: () => void; }): VNode { const { i18n } = useTranslationContext(); const { lib } = useExchangeApiContext(); @@ -199,27 +230,18 @@ function FormRenderer({ 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} + <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> - </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> + ) : 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> {form.contextForm ? ( diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Justification.test.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Justification.test.tsx @@ -98,7 +98,7 @@ test("case disposition shows current and future status and stores the selection" return ( <DecisionRequestProvider initial={initial}> <RequestObserver /> - {showForm ? <Justification newAccount={false} /> : undefined} + {showForm ? <Justification /> : undefined} </DecisionRequestProvider> ); } @@ -107,6 +107,7 @@ test("case disposition shows current and future status and stores the selection" window.document.body.append(container); render(<Harness showForm />, container as unknown as Element); + assert.doesNotMatch(container.textContent, /Account holder/); const currentStatus = container.querySelector('[data-status="active"]'); assert.equal(currentStatus?.textContent, "Under investigation"); const choices = Array.from( diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Justification.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Justification.tsx @@ -35,10 +35,10 @@ const TALER_SCREEN_ID = 106; * @param param0 * @returns */ -export function Justification({ newAccount }: { newAccount: boolean }): VNode { +export function Justification(): VNode { const { i18n } = useTranslationContext(); const [request, updateRequest] = useCurrentDecisionRequest(); - const design = formDesign(i18n, newAccount); + const design = formDesign(i18n); const currentInvestigationStatus = request.original?.to_investigate; const [investigationStatus, setInvestigationStatus] = useState( initialInvestigationStatus(request), @@ -46,14 +46,12 @@ export function Justification({ newAccount }: { newAccount: boolean }): VNode { const form = useForm<FormType>(design, { justification: request.justification, - accountName: request.accountName, }); onComponentUnload(() => { updateRequest("unload justification", { keep_investigating: investigationStatus, justification: form.status.result.justification ?? "", - accountName: form.status.result.accountName ?? "", }); }); @@ -183,25 +181,13 @@ export function InvestigationStatusChoice({ type FormType = { justification: string; - accountName: string; measure: string; }; -const formDesign = ( - i18n: InternationalizationAPI, - unknownAccount: boolean, -): FormDesign => ({ +const formDesign = (i18n: InternationalizationAPI): FormDesign => ({ type: "single-column", fields: [ { - id: "accountName", - type: "text", - label: i18n.str`Account holder`, - required: true, - help: i18n.str`Full name used in the payto URI when creating this previously unknown account.`, - hidden: !unknownAccount, - }, - { id: "justification", type: "textArea", required: true, diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Summary.tsx @@ -50,7 +50,6 @@ import { isAttributesCompleted, isEventsCompleted, isJustificationCompleted, - isJustificationCompletedForNewACcount, isMeasuresCompleted, isPropertiesCompleted, isRulesCompleted, @@ -67,12 +66,10 @@ export function Summary({ account, onMove, fullPayto, - newAccount, officer, }: { account?: string; fullPayto: string; - newAccount: boolean; officer: OfficerReady; onMove: (n: WizardSteps | undefined) => void; }): VNode { @@ -113,9 +110,7 @@ export function Summary({ const INVALID_MEASURES = !isMeasuresCompleted(decision); //.new_measures === undefined; const INVALID_PROPERTIES = !isPropertiesCompleted(decision); //.properties === undefined; const INVALID_EVENTS = !isEventsCompleted(decision); //false; //decision.inhibit_events === undefined; - const INVALID_JUSTIFICATION = newAccount - ? !isJustificationCompletedForNewACcount(decision) - : !isJustificationCompleted(decision); + const INVALID_JUSTIFICATION = !isJustificationCompleted(decision); const INVALID_ATTRIBUTES = !isAttributesCompleted(decision); const recordedEvents = [ diff --git a/packages/taler-exchange-aml-webui/src/utils/decision-validation.test.ts b/packages/taler-exchange-aml-webui/src/utils/decision-validation.test.ts @@ -66,6 +66,20 @@ test("decision builder preserves custom state and AND measures", () => { assert.strictEqual(result.request?.new_measures, "+review"); }); +test("an unknown account preserves its supplied payto URI without a separate name", () => { + const fullPayto = + "payto://iban/CH9300762011623852957?receiver-name=Example%20Trading%20AG"; + const result = buildAmlDecisionRequest({ + account: "ACCOUNT", + fullPayto, + decision: completeDecision(), + measures: measureSummary, + }); + + assert.deepStrictEqual(result.errors, []); + assert.strictEqual(result.request?.payto_uri, fullPayto); +}); + test("attribute uploads require salt and a future expiration", () => { const decision = completeDecision(); decision.attributes = { diff --git a/packages/taler-exchange-aml-webui/src/utils/decision-validation.ts b/packages/taler-exchange-aml-webui/src/utils/decision-validation.ts @@ -33,7 +33,6 @@ export type DecisionValidationError = | "missing-events" | "missing-measures" | "missing-justification" - | "missing-account-name" | "invalid-attributes" | "missing-measure-metadata" | "invalid-custom-measure" @@ -98,9 +97,6 @@ export function buildAmlDecisionRequest({ } if (decision.new_measures === undefined) errors.push("missing-measures"); if (!decision.justification?.trim()) errors.push("missing-justification"); - if (!decision.original && fullPayto && !decision.accountName?.trim()) { - errors.push("missing-account-name"); - } if (!attributesAreComplete(decision)) errors.push("invalid-attributes"); if (!measures) errors.push("missing-measure-metadata"); @@ -168,9 +164,6 @@ export function buildAmlDecisionRequest({ if (parsed.tag === "error") { errors.push("invalid-payto"); } else { - if (decision.accountName?.trim()) { - parsed.value.params["receiver-name"] = decision.accountName.trim(); - } paytoUri = Paytos.toFullString(parsed.value); } }