commit b562f718d965762866fe38dc4ae5fdbfea6bdd90 parent 9e5a13451760cb8d8d28794edbc44d1f2124be25 Author: Florian Dold <dold@taler.net> Date: Sun, 23 Aug 2026 16:05:10 +0200 web-util: add accessible terms acceptance reader Diffstat:
20 files changed, 1455 insertions(+), 748 deletions(-)
diff --git a/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx b/packages/taler-exchange-kyc-webui/src/pages/FillForm.tsx @@ -194,7 +194,9 @@ function ShowForm({ <i18n.Translate>Submit</i18n.Translate> </AsyncButton> </div> - {!status.errors ? undefined : <ErrorsSummary errors={status.errors} />} + {!status.errors || design.type === "accept-tos" ? undefined : ( + <ErrorsSummary errors={status.errors} /> + )} </div> ); } diff --git a/packages/taler-exchange-kyc-webui/src/pages/TriggerForms.tsx b/packages/taler-exchange-kyc-webui/src/pages/TriggerForms.tsx @@ -86,7 +86,15 @@ export function TriggerForms({ formId }: Props): VNode { } function ShowForm({ form }: { form: FormMetadata }) { - const { model: handler, design } = useFormMeta<FormType>(form, {}, {}); + const context = + form.id === "accept-tos" + ? { + tos_url: "https://exchange.demo.taler.net/terms", + tos_version: "v1", + provider_name: "Taler Operations AG", + } + : {}; + const { model: handler, design } = useFormMeta<FormType>(form, context, {}); return ( <Fragment> diff --git a/packages/wallet-webui/package.json b/packages/wallet-webui/package.json @@ -36,6 +36,7 @@ "@gnu-taler/idb-bridge": "workspace:*", "@gnu-taler/taler-util": "workspace:*", "@gnu-taler/taler-wallet-core": "workspace:*", + "@gnu-taler/web-util": "workspace:*", "@preact/signals": "^2.3.1", "@sqlite.org/sqlite-wasm": "3.53.0-build1", "jsqr": "^1.4.0", diff --git a/packages/wallet-webui/src/screens/TermsScreen.tsx b/packages/wallet-webui/src/screens/TermsScreen.tsx @@ -7,7 +7,7 @@ import { isMarkdownContentType, isSafeMarkdownSize, SafeMarkdown, -} from "../ui/SafeMarkdown.js"; +} from "@gnu-taler/web-util/browser"; export function exchangeTermsStatusLabel(status: string | undefined): string { switch (status) { @@ -95,6 +95,7 @@ export function TermsScreen(props: { {isMarkdownContentType(props.contentType) ? ( <SafeMarkdown content={props.content} + tooLargeMessage={i18n.str`These terms are too large to display safely. Save the file to review them.`} onOpenLink={props.onOpenLink} /> ) : ( diff --git a/packages/wallet-webui/src/ui/SafeMarkdown.tsx b/packages/wallet-webui/src/ui/SafeMarkdown.tsx @@ -1,674 +0,0 @@ -import { Fragment, type ComponentChildren } from "preact"; -import { isSafeExternalUrl } from "@gnu-taler/taler-util"; -import { i18n } from "../i18n/runtime.js"; - -// Exchange terms are untrusted input. This deliberately small Markdown -// renderer creates Preact nodes only: it never interprets raw HTML or loads -// images, and it exposes only absolute HTTP(S) links to the host application. -const MAX_NESTING = 24; -export const MAX_SAFE_MARKDOWN_BYTES = 256 * 1024; - -type InlineNode = - | { kind: "text"; value: string } - | { kind: "break" } - | { kind: "code"; value: string } - | { kind: "emphasis"; children: InlineNode[] } - | { kind: "strong"; children: InlineNode[] } - | { kind: "link"; url?: string; children: InlineNode[] }; - -type BlockNode = - | { kind: "heading"; level: number; children: InlineNode[] } - | { kind: "paragraph"; children: InlineNode[] } - | { kind: "rule" } - | { kind: "quote"; children: BlockNode[] } - | { kind: "code"; value: string } - | { - kind: "list"; - ordered: boolean; - start: number; - items: BlockNode[][]; - }; - -type ListMarker = { - indent: number; - ordered: boolean; - start: number; - contentIndent: number; - content: string; -}; - -function normalizeUrl(value: string): string | undefined { - try { - const url = new URL(value); - return isSafeExternalUrl(url.href) ? url.href : undefined; - } catch { - return undefined; - } -} - -function appendText(nodes: InlineNode[], value: string): void { - if (!value) return; - const previous = nodes.at(-1); - if (previous?.kind === "text") previous.value += value; - else nodes.push({ kind: "text", value }); -} - -type InlineIndex = { - nextSquareClose: Int32Array; - nextAngleClose: Int32Array; - parenthesisClose: Int32Array; - nextMarker: Map<string, Int32Array>; - backtickClose: Int32Array; -}; - -function escapedCharacters(text: string): Uint8Array { - const escaped = new Uint8Array(text.length); - let slashes = 0; - for (let index = 0; index < text.length; index++) { - escaped[index] = slashes % 2; - slashes = text[index] === "\\" ? slashes + 1 : 0; - } - return escaped; -} - -function nextCharacterIndex( - text: string, - escaped: Uint8Array, - character: string, -): Int32Array { - const result = new Int32Array(text.length + 1); - let next = -1; - result[text.length] = -1; - for (let index = text.length - 1; index >= 0; index--) { - if (text[index] === character && !escaped[index]) next = index; - result[index] = next; - } - return result; -} - -function nextMarkerIndex( - text: string, - escaped: Uint8Array, - marker: string, -): Int32Array { - const result = new Int32Array(text.length + 1); - let next = -1; - result[text.length] = -1; - for (let index = text.length - 1; index >= 0; index--) { - if (text.startsWith(marker, index) && !escaped[index]) next = index; - result[index] = next; - } - return result; -} - -function inlineIndex(text: string): InlineIndex { - const escaped = escapedCharacters(text); - const parenthesisClose = new Int32Array(text.length + 1); - const backtickClose = new Int32Array(text.length + 1); - parenthesisClose.fill(-1); - backtickClose.fill(-1); - const parentheses: number[] = []; - const pendingBackticks = new Map<number, number>(); - for (let index = 0; index < text.length; index++) { - if (escaped[index]) continue; - if (text[index] === "(") parentheses.push(index); - else if (text[index] === ")") { - const opening = parentheses.pop(); - if (opening !== undefined) parenthesisClose[opening] = index; - } - if (text[index] !== "`") continue; - let runLength = 1; - while (text[index + runLength] === "`") runLength++; - const opening = pendingBackticks.get(runLength); - if (opening === undefined) pendingBackticks.set(runLength, index); - else { - backtickClose[opening] = index; - pendingBackticks.delete(runLength); - } - index += runLength - 1; - } - const nextMarker = new Map<string, Int32Array>(); - for (const marker of ["*", "**", "***", "_", "__", "___"]) - nextMarker.set(marker, nextMarkerIndex(text, escaped, marker)); - return { - nextSquareClose: nextCharacterIndex(text, escaped, "]"), - nextAngleClose: nextCharacterIndex(text, escaped, ">"), - parenthesisClose, - nextMarker, - backtickClose, - }; -} - -function linkAt( - text: string, - start: number, - index: InlineIndex, -): { label: string; destination: string; end: number } | undefined { - const labelEnd = index.nextSquareClose[start + 1] ?? -1; - if (labelEnd === -1 || text[labelEnd + 1] !== "(") return undefined; - const destinationEnd = index.parenthesisClose[labelEnd + 1] ?? -1; - if (destinationEnd === -1) return undefined; - let destination = text.slice(labelEnd + 2, destinationEnd).trim(); - if (destination.startsWith("<") && destination.endsWith(">")) - destination = destination.slice(1, -1); - return { - label: text.slice(start + 1, labelEnd), - destination, - end: destinationEnd + 1, - }; -} - -function inlineNodes(text: string, depth = 0, allowLinks = true): InlineNode[] { - if (depth >= MAX_NESTING) return [{ kind: "text", value: text }]; - const delimiters = inlineIndex(text); - const nodes: InlineNode[] = []; - let index = 0; - while (index < text.length) { - const character = text[index]; - if (character === "\n") { - nodes.push({ kind: "break" }); - index++; - continue; - } - if (character === "\\" && index + 1 < text.length) { - const escaped = text[index + 1]; - if (/^[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]$/.test(escaped)) { - appendText(nodes, escaped); - index += 2; - continue; - } - } - if (character === "`") { - let runLength = 1; - while (text[index + runLength] === "`") runLength++; - const end = delimiters.backtickClose[index] ?? -1; - if (end !== -1) { - let value = text - .slice(index + runLength, end) - .replace(/[\t\n ]+/g, " "); - if (value.startsWith(" ") && value.endsWith(" ") && value.trim()) - value = value.slice(1, -1); - nodes.push({ kind: "code", value }); - index = end + runLength; - continue; - } - } - if (text.startsWith("![", index)) { - const image = linkAt(text, index + 1, delimiters); - if (image) { - nodes.push(...inlineNodes(image.label, depth + 1, false)); - index = image.end; - continue; - } - } - if (allowLinks && character === "[") { - const link = linkAt(text, index, delimiters); - if (link) { - nodes.push({ - kind: "link", - url: normalizeUrl(link.destination), - children: inlineNodes(link.label, depth + 1, false), - }); - index = link.end; - continue; - } - } - if (allowLinks && character === "<") { - const end = delimiters.nextAngleClose[index + 1] ?? -1; - if (end !== -1) { - const candidate = text.slice(index + 1, end); - const url = normalizeUrl(candidate); - if (url) { - nodes.push({ - kind: "link", - url, - children: inlineNodes(candidate, depth + 1, false), - }); - index = end + 1; - continue; - } - } - } - const combinedMarker = text.startsWith("***", index) - ? "***" - : text.startsWith("___", index) - ? "___" - : undefined; - if (combinedMarker) { - const end = delimiters.nextMarker.get(combinedMarker)?.[index + 3] ?? -1; - if (end > index + 3) { - nodes.push({ - kind: "strong", - children: [ - { - kind: "emphasis", - children: inlineNodes(text.slice(index + 3, end), depth + 1), - }, - ], - }); - index = end + 3; - continue; - } - } - const strongMarker = text.startsWith("**", index) - ? "**" - : text.startsWith("__", index) - ? "__" - : undefined; - if (strongMarker) { - const end = delimiters.nextMarker.get(strongMarker)?.[index + 2] ?? -1; - if (end > index + 2) { - nodes.push({ - kind: "strong", - children: inlineNodes(text.slice(index + 2, end), depth + 1), - }); - index = end + 2; - continue; - } - } - if (character === "*" || character === "_") { - const end = delimiters.nextMarker.get(character)?.[index + 1] ?? -1; - if (end > index + 1) { - nodes.push({ - kind: "emphasis", - children: inlineNodes(text.slice(index + 1, end), depth + 1), - }); - index = end + 1; - continue; - } - } - appendText(nodes, character); - index++; - } - return nodes; -} - -function listMarker(line: string): ListMarker | undefined { - const match = line.match(/^( *)([-+*]|(\d+)[.)])(?:[\t ]+(.*))?$/); - if (!match) return undefined; - const indent = match[1].length; - const marker = match[2]; - return { - indent, - ordered: !!match[3], - start: match[3] ? Number(match[3]) : 1, - contentIndent: indent + marker.length + 1, - content: match[4] ?? "", - }; -} - -function isThematicBreak(line: string): boolean { - const compact = line.trim().replaceAll(/[\t ]/g, ""); - return ( - compact.length >= 3 && - (compact.split("").every((value) => value === "-") || - compact.split("").every((value) => value === "_") || - compact.split("").every((value) => value === "*")) - ); -} - -function startsBlock(line: string): boolean { - return ( - /^ {0,3}#{1,6}(?:[\t ]|$)/.test(line) || - /^ {0,3}(?:`{3,}|~{3,})/.test(line) || - /^ {0,3}>/.test(line) || - isThematicBreak(line) || - (listMarker(line)?.indent ?? 4) <= 3 - ); -} - -function paragraphText(lines: string[]): string { - return lines - .map((line, index) => { - if (index === lines.length - 1) return line; - if (/[\t ]{2,}$/.test(line)) return `${line.trimEnd()}\n`; - if (line.endsWith("\\")) return `${line.slice(0, -1)}\n`; - return `${line} `; - }) - .join(""); -} - -function parseList( - lines: string[], - startIndex: number, - depth: number, -): { block: BlockNode; nextIndex: number } { - const first = listMarker(lines[startIndex]); - if (!first) throw Error("list parser called without a list marker"); - const items: BlockNode[][] = []; - let index = startIndex; - while (index < lines.length) { - const marker = listMarker(lines[index]); - if ( - !marker || - marker.indent !== first.indent || - marker.ordered !== first.ordered - ) - break; - const itemLines = [marker.content]; - index++; - while (index < lines.length) { - const line = lines[index]; - if (!line.trim()) { - itemLines.push(""); - index++; - continue; - } - const nextMarker = listMarker(line); - if ( - nextMarker?.indent === first.indent && - nextMarker.ordered === first.ordered - ) - break; - const leading = line.match(/^ */)?.[0].length ?? 0; - if (leading <= first.indent) break; - itemLines.push(line.slice(Math.min(leading, marker.contentIndent))); - index++; - } - while (!itemLines.at(-1)?.trim()) itemLines.pop(); - items.push(blockNodes(itemLines, depth + 1)); - } - return { - block: { - kind: "list", - ordered: first.ordered, - start: first.start, - items, - }, - nextIndex: index, - }; -} - -function blockNodes(sourceLines: string[], depth = 0): BlockNode[] { - if (depth >= MAX_NESTING) - return [ - { - kind: "paragraph", - children: inlineNodes(sourceLines.join("\n"), depth), - }, - ]; - const lines = sourceLines.map((line) => line.replace(/\r$/, "")); - const blocks: BlockNode[] = []; - let index = 0; - while (index < lines.length) { - const line = lines[index]; - if (!line.trim()) { - index++; - continue; - } - const fence = line.match(/^ {0,3}(`{3,}|~{3,}).*$/); - if (fence) { - const marker = fence[1]; - const value: string[] = []; - index++; - const closing = new RegExp( - `^ {0,3}${marker[0]}{${marker.length},}[\\t ]*$`, - ); - while (index < lines.length && !closing.test(lines[index])) { - value.push(lines[index]); - index++; - } - if (index < lines.length) index++; - blocks.push({ kind: "code", value: value.join("\n") }); - continue; - } - const heading = line.match(/^ {0,3}(#{1,6})(?:[\t ]+(.*?)[\t ]*|[\t ]*)$/); - if (heading) { - blocks.push({ - kind: "heading", - level: heading[1].length, - children: inlineNodes((heading[2] ?? "").replace(/[\t ]+#+$/, "")), - }); - index++; - continue; - } - if ( - index + 1 < lines.length && - /^ {0,3}(?:=+|-+)[\t ]*$/.test(lines[index + 1]) - ) { - blocks.push({ - kind: "heading", - level: lines[index + 1].trimStart().startsWith("=") ? 1 : 2, - children: inlineNodes(line.trim()), - }); - index += 2; - continue; - } - if (isThematicBreak(line)) { - blocks.push({ kind: "rule" }); - index++; - continue; - } - if (/^ {0,3}>/.test(line)) { - const quoted: string[] = []; - while (index < lines.length) { - const quote = lines[index].match(/^ {0,3}>[\t ]?(.*)$/); - if (quote) { - quoted.push(quote[1]); - index++; - } else if (!lines[index].trim()) { - quoted.push(""); - index++; - } else break; - } - blocks.push({ kind: "quote", children: blockNodes(quoted, depth + 1) }); - continue; - } - const marker = listMarker(line); - if (marker && marker.indent <= 3) { - const list = parseList(lines, index, depth); - blocks.push(list.block); - index = list.nextIndex; - continue; - } - const paragraph: string[] = []; - while ( - index < lines.length && - lines[index].trim() && - (!paragraph.length || !startsBlock(lines[index])) - ) { - paragraph.push(lines[index]); - index++; - } - blocks.push({ - kind: "paragraph", - children: inlineNodes(paragraphText(paragraph)), - }); - } - return blocks; -} - -function inlineContent( - nodes: InlineNode[], - onOpenLink: (url: string) => void, - path: string, -): ComponentChildren { - return nodes.map((node, index) => { - const key = `${path}-${index}`; - switch (node.kind) { - case "text": - return <Fragment key={key}>{node.value}</Fragment>; - case "break": - return <br key={key} />; - case "code": - return ( - <code - key={key} - class="rounded bg-secondaryContainer px-1 py-0.5 font-mono text-[.9em]" - > - {node.value} - </code> - ); - case "emphasis": - return ( - <em key={key}>{inlineContent(node.children, onOpenLink, key)}</em> - ); - case "strong": - return ( - <strong key={key} class="font-semibold"> - {inlineContent(node.children, onOpenLink, key)} - </strong> - ); - case "link": { - const url = node.url; - return url ? ( - <button - key={key} - type="button" - value={url} - title={url} - class="inline cursor-pointer border-0 bg-transparent p-0 text-left align-baseline font-inherit text-primary underline underline-offset-2" - onClick={(event) => { - event.stopPropagation(); - onOpenLink(event.currentTarget.value); - }} - > - {inlineContent(node.children, onOpenLink, key)} - </button> - ) : ( - <Fragment key={key}> - {inlineContent(node.children, onOpenLink, key)} - </Fragment> - ); - } - } - }); -} - -function heading( - level: number, - children: ComponentChildren, - key: string, -): ComponentChildren { - switch (level) { - case 1: - return ( - <h1 key={key} class="text-2xl font-semibold"> - {children} - </h1> - ); - case 2: - return ( - <h2 key={key} class="text-xl font-semibold"> - {children} - </h2> - ); - case 3: - return ( - <h3 key={key} class="text-lg font-semibold"> - {children} - </h3> - ); - case 4: - return ( - <h4 key={key} class="font-semibold"> - {children} - </h4> - ); - case 5: - return ( - <h5 key={key} class="font-semibold"> - {children} - </h5> - ); - default: - return ( - <h6 key={key} class="font-semibold"> - {children} - </h6> - ); - } -} - -function blockContent( - blocks: BlockNode[], - onOpenLink: (url: string) => void, - path = "markdown", -): ComponentChildren { - return blocks.map((block, index) => { - const key = `${path}-${index}`; - switch (block.kind) { - case "heading": - return heading( - block.level, - inlineContent(block.children, onOpenLink, key), - key, - ); - case "paragraph": - return ( - <p key={key}>{inlineContent(block.children, onOpenLink, key)}</p> - ); - case "rule": - return <hr key={key} class="border-outlineVariant" />; - case "quote": - return ( - <blockquote - key={key} - class="space-y-3 border-l-4 border-outlineVariant pl-4 text-secondary" - > - {blockContent(block.children, onOpenLink, key)} - </blockquote> - ); - case "code": - return ( - <pre - key={key} - class="overflow-x-auto rounded-lg bg-secondaryContainer p-3 font-mono text-xs leading-5" - > - <code>{block.value}</code> - </pre> - ); - case "list": { - const items = block.items.map((item, itemIndex) => ( - <li key={`${key}-${itemIndex}`} class="space-y-2"> - {blockContent(item, onOpenLink, `${key}-${itemIndex}`)} - </li> - )); - return block.ordered ? ( - <ol - key={key} - start={block.start === 1 ? undefined : block.start} - class="list-decimal space-y-2 pl-6" - > - {items} - </ol> - ) : ( - <ul key={key} class="list-disc space-y-2 pl-6"> - {items} - </ul> - ); - } - } - }); -} - -export function isMarkdownContentType( - contentType: string | undefined, -): boolean { - return contentType?.split(";", 1)[0].trim().toLowerCase() === "text/markdown"; -} - -export function isSafeMarkdownSize(content: string): boolean { - return ( - new TextEncoder().encode(content).byteLength <= MAX_SAFE_MARKDOWN_BYTES - ); -} - -export function SafeMarkdown(props: { - content: string; - onOpenLink: (url: string) => void; -}) { - if (!isSafeMarkdownSize(props.content)) { - return ( - <p role="alert" class="text-sm text-error"> - {i18n.str`These terms are too large to display safely. Save the file to review them.`} - </p> - ); - } - const blocks = blockNodes(props.content.replaceAll("\r\n", "\n").split("\n")); - return ( - <div class="space-y-4 text-sm leading-6"> - {blockContent(blocks, props.onOpenLink)} - </div> - ); -} diff --git a/packages/wallet-webui/tailwind.config.mjs b/packages/wallet-webui/tailwind.config.mjs @@ -1,6 +1,9 @@ import { fileURLToPath } from "node:url"; const packageDirectory = fileURLToPath(new URL("./", import.meta.url)); +const webUtilDirectory = fileURLToPath( + new URL("../web-util/src/", import.meta.url), +); /** @type {import('tailwindcss').Config} */ export default { @@ -8,6 +11,7 @@ export default { content: [ `${packageDirectory}src/**/*.{ts,tsx}`, `${packageDirectory}static/**/*.html`, + `${webUtilDirectory}**/*.{ts,tsx}`, ], theme: { extend: { diff --git a/packages/wallet-webui/test/markdown.test.tsx b/packages/wallet-webui/test/markdown.test.tsx @@ -5,7 +5,7 @@ import { isMarkdownContentType, MAX_SAFE_MARKDOWN_BYTES, SafeMarkdown, -} from "../src/ui/SafeMarkdown.js"; +} from "@gnu-taler/web-util/browser"; import { TermsScreen } from "../src/screens/TermsScreen.js"; function installDom() { @@ -51,6 +51,7 @@ This follows a hard break. ~~~text literal **code** ~~~`} + tooLargeMessage="Too large" onOpenLink={() => {}} />, ); @@ -92,6 +93,7 @@ test("safe markdown keeps untrusted and non-web content inert", async () => {  <script>bad()</script> <https://example.org/help>`} + tooLargeMessage="Too large" onOpenLink={(url) => opened.push(url)} />, ); @@ -117,6 +119,7 @@ test("safe markdown does not create or activate nested links", async () => { const view = render( <SafeMarkdown content="[<https://inner.example/>](https://outer.example/)" + tooLargeMessage="Too large" onOpenLink={(url) => opened.push(url)} />, ); @@ -135,7 +138,13 @@ test("unmatched markdown delimiters are processed without quadratic scans", asyn const { render, cleanup } = await import("@testing-library/preact"); const content = "[".repeat(50_000); const started = performance.now(); - const view = render(<SafeMarkdown content={content} onOpenLink={() => {}} />); + const view = render( + <SafeMarkdown + content={content} + tooLargeMessage="Too large" + onOpenLink={() => {}} + />, + ); const elapsed = performance.now() - started; assert.equal(view.container.textContent, content); assert.ok(elapsed < 2_500, `render took ${elapsed.toFixed(0)}ms`); diff --git a/packages/web-util/package.json b/packages/web-util/package.json @@ -27,6 +27,7 @@ "scripts": { "build": "pnpm run clean && tsc && ./build.mjs", "build:with-deps": "pnpm --filter \"{.}...\" run build", + "test": "tsc && node --test 'lib/**/*.test.js'", "i18n:source2po": "pogen extract && pogen merge", "i18n:po2strings": "pogen emit", "clean": "rm -rf dist lib tsconfig.tsbuildinfo", @@ -36,6 +37,7 @@ "@gnu-taler/pogen": "workspace:*", "@gnu-taler/taler-util": "workspace:*", "@heroicons/react": "^2.0.17", + "@testing-library/preact": "^3.2.4", "@types/node": "^20.19.41", "@types/web": "^0.0.82", "@types/ws": "^8.5.3", @@ -44,6 +46,7 @@ "date-fns": "2.29.3", "esbuild": "^0.28.0", "h3": "^1.15.0", + "happy-dom": "^20.11.2", "postcss": "^8.4.23", "postcss-load-config": "^4.0.1", "preact": "10.11.3", diff --git a/packages/web-util/src/components/SafeMarkdown.tsx b/packages/web-util/src/components/SafeMarkdown.tsx @@ -0,0 +1,674 @@ +import { Fragment, h, type ComponentChildren } from "preact"; +import { isSafeExternalUrl } from "@gnu-taler/taler-util"; + +// Exchange terms are untrusted input. This deliberately small Markdown +// renderer creates Preact nodes only: it never interprets raw HTML or loads +// images, and it exposes only absolute HTTP(S) links to the host application. +const MAX_NESTING = 24; +export const MAX_SAFE_MARKDOWN_BYTES = 256 * 1024; + +type InlineNode = + | { kind: "text"; value: string } + | { kind: "break" } + | { kind: "code"; value: string } + | { kind: "emphasis"; children: InlineNode[] } + | { kind: "strong"; children: InlineNode[] } + | { kind: "link"; url?: string; children: InlineNode[] }; + +type BlockNode = + | { kind: "heading"; level: number; children: InlineNode[] } + | { kind: "paragraph"; children: InlineNode[] } + | { kind: "rule" } + | { kind: "quote"; children: BlockNode[] } + | { kind: "code"; value: string } + | { + kind: "list"; + ordered: boolean; + start: number; + items: BlockNode[][]; + }; + +type ListMarker = { + indent: number; + ordered: boolean; + start: number; + contentIndent: number; + content: string; +}; + +function normalizeUrl(value: string): string | undefined { + try { + const url = new URL(value); + return isSafeExternalUrl(url.href) ? url.href : undefined; + } catch { + return undefined; + } +} + +function appendText(nodes: InlineNode[], value: string): void { + if (!value) return; + const previous = nodes.at(-1); + if (previous?.kind === "text") previous.value += value; + else nodes.push({ kind: "text", value }); +} + +type InlineIndex = { + nextSquareClose: Int32Array; + nextAngleClose: Int32Array; + parenthesisClose: Int32Array; + nextMarker: Map<string, Int32Array>; + backtickClose: Int32Array; +}; + +function escapedCharacters(text: string): Uint8Array { + const escaped = new Uint8Array(text.length); + let slashes = 0; + for (let index = 0; index < text.length; index++) { + escaped[index] = slashes % 2; + slashes = text[index] === "\\" ? slashes + 1 : 0; + } + return escaped; +} + +function nextCharacterIndex( + text: string, + escaped: Uint8Array, + character: string, +): Int32Array { + const result = new Int32Array(text.length + 1); + let next = -1; + result[text.length] = -1; + for (let index = text.length - 1; index >= 0; index--) { + if (text[index] === character && !escaped[index]) next = index; + result[index] = next; + } + return result; +} + +function nextMarkerIndex( + text: string, + escaped: Uint8Array, + marker: string, +): Int32Array { + const result = new Int32Array(text.length + 1); + let next = -1; + result[text.length] = -1; + for (let index = text.length - 1; index >= 0; index--) { + if (text.startsWith(marker, index) && !escaped[index]) next = index; + result[index] = next; + } + return result; +} + +function inlineIndex(text: string): InlineIndex { + const escaped = escapedCharacters(text); + const parenthesisClose = new Int32Array(text.length + 1); + const backtickClose = new Int32Array(text.length + 1); + parenthesisClose.fill(-1); + backtickClose.fill(-1); + const parentheses: number[] = []; + const pendingBackticks = new Map<number, number>(); + for (let index = 0; index < text.length; index++) { + if (escaped[index]) continue; + if (text[index] === "(") parentheses.push(index); + else if (text[index] === ")") { + const opening = parentheses.pop(); + if (opening !== undefined) parenthesisClose[opening] = index; + } + if (text[index] !== "`") continue; + let runLength = 1; + while (text[index + runLength] === "`") runLength++; + const opening = pendingBackticks.get(runLength); + if (opening === undefined) pendingBackticks.set(runLength, index); + else { + backtickClose[opening] = index; + pendingBackticks.delete(runLength); + } + index += runLength - 1; + } + const nextMarker = new Map<string, Int32Array>(); + for (const marker of ["*", "**", "***", "_", "__", "___"]) + nextMarker.set(marker, nextMarkerIndex(text, escaped, marker)); + return { + nextSquareClose: nextCharacterIndex(text, escaped, "]"), + nextAngleClose: nextCharacterIndex(text, escaped, ">"), + parenthesisClose, + nextMarker, + backtickClose, + }; +} + +function linkAt( + text: string, + start: number, + index: InlineIndex, +): { label: string; destination: string; end: number } | undefined { + const labelEnd = index.nextSquareClose[start + 1] ?? -1; + if (labelEnd === -1 || text[labelEnd + 1] !== "(") return undefined; + const destinationEnd = index.parenthesisClose[labelEnd + 1] ?? -1; + if (destinationEnd === -1) return undefined; + let destination = text.slice(labelEnd + 2, destinationEnd).trim(); + if (destination.startsWith("<") && destination.endsWith(">")) + destination = destination.slice(1, -1); + return { + label: text.slice(start + 1, labelEnd), + destination, + end: destinationEnd + 1, + }; +} + +function inlineNodes(text: string, depth = 0, allowLinks = true): InlineNode[] { + if (depth >= MAX_NESTING) return [{ kind: "text", value: text }]; + const delimiters = inlineIndex(text); + const nodes: InlineNode[] = []; + let index = 0; + while (index < text.length) { + const character = text[index]; + if (character === "\n") { + nodes.push({ kind: "break" }); + index++; + continue; + } + if (character === "\\" && index + 1 < text.length) { + const escaped = text[index + 1]; + if (/^[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]$/.test(escaped)) { + appendText(nodes, escaped); + index += 2; + continue; + } + } + if (character === "`") { + let runLength = 1; + while (text[index + runLength] === "`") runLength++; + const end = delimiters.backtickClose[index] ?? -1; + if (end !== -1) { + let value = text + .slice(index + runLength, end) + .replace(/[\t\n ]+/g, " "); + if (value.startsWith(" ") && value.endsWith(" ") && value.trim()) + value = value.slice(1, -1); + nodes.push({ kind: "code", value }); + index = end + runLength; + continue; + } + } + if (text.startsWith("![", index)) { + const image = linkAt(text, index + 1, delimiters); + if (image) { + nodes.push(...inlineNodes(image.label, depth + 1, false)); + index = image.end; + continue; + } + } + if (allowLinks && character === "[") { + const link = linkAt(text, index, delimiters); + if (link) { + nodes.push({ + kind: "link", + url: normalizeUrl(link.destination), + children: inlineNodes(link.label, depth + 1, false), + }); + index = link.end; + continue; + } + } + if (allowLinks && character === "<") { + const end = delimiters.nextAngleClose[index + 1] ?? -1; + if (end !== -1) { + const candidate = text.slice(index + 1, end); + const url = normalizeUrl(candidate); + if (url) { + nodes.push({ + kind: "link", + url, + children: inlineNodes(candidate, depth + 1, false), + }); + index = end + 1; + continue; + } + } + } + const combinedMarker = text.startsWith("***", index) + ? "***" + : text.startsWith("___", index) + ? "___" + : undefined; + if (combinedMarker) { + const end = delimiters.nextMarker.get(combinedMarker)?.[index + 3] ?? -1; + if (end > index + 3) { + nodes.push({ + kind: "strong", + children: [ + { + kind: "emphasis", + children: inlineNodes(text.slice(index + 3, end), depth + 1), + }, + ], + }); + index = end + 3; + continue; + } + } + const strongMarker = text.startsWith("**", index) + ? "**" + : text.startsWith("__", index) + ? "__" + : undefined; + if (strongMarker) { + const end = delimiters.nextMarker.get(strongMarker)?.[index + 2] ?? -1; + if (end > index + 2) { + nodes.push({ + kind: "strong", + children: inlineNodes(text.slice(index + 2, end), depth + 1), + }); + index = end + 2; + continue; + } + } + if (character === "*" || character === "_") { + const end = delimiters.nextMarker.get(character)?.[index + 1] ?? -1; + if (end > index + 1) { + nodes.push({ + kind: "emphasis", + children: inlineNodes(text.slice(index + 1, end), depth + 1), + }); + index = end + 1; + continue; + } + } + appendText(nodes, character); + index++; + } + return nodes; +} + +function listMarker(line: string): ListMarker | undefined { + const match = line.match(/^( *)([-+*]|(\d+)[.)])(?:[\t ]+(.*))?$/); + if (!match) return undefined; + const indent = match[1].length; + const marker = match[2]; + return { + indent, + ordered: !!match[3], + start: match[3] ? Number(match[3]) : 1, + contentIndent: indent + marker.length + 1, + content: match[4] ?? "", + }; +} + +function isThematicBreak(line: string): boolean { + const compact = line.trim().replaceAll(/[\t ]/g, ""); + return ( + compact.length >= 3 && + (compact.split("").every((value) => value === "-") || + compact.split("").every((value) => value === "_") || + compact.split("").every((value) => value === "*")) + ); +} + +function startsBlock(line: string): boolean { + return ( + /^ {0,3}#{1,6}(?:[\t ]|$)/.test(line) || + /^ {0,3}(?:`{3,}|~{3,})/.test(line) || + /^ {0,3}>/.test(line) || + isThematicBreak(line) || + (listMarker(line)?.indent ?? 4) <= 3 + ); +} + +function paragraphText(lines: string[]): string { + return lines + .map((line, index) => { + if (index === lines.length - 1) return line; + if (/[\t ]{2,}$/.test(line)) return `${line.trimEnd()}\n`; + if (line.endsWith("\\")) return `${line.slice(0, -1)}\n`; + return `${line} `; + }) + .join(""); +} + +function parseList( + lines: string[], + startIndex: number, + depth: number, +): { block: BlockNode; nextIndex: number } { + const first = listMarker(lines[startIndex]); + if (!first) throw Error("list parser called without a list marker"); + const items: BlockNode[][] = []; + let index = startIndex; + while (index < lines.length) { + const marker = listMarker(lines[index]); + if ( + !marker || + marker.indent !== first.indent || + marker.ordered !== first.ordered + ) + break; + const itemLines = [marker.content]; + index++; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { + itemLines.push(""); + index++; + continue; + } + const nextMarker = listMarker(line); + if ( + nextMarker?.indent === first.indent && + nextMarker.ordered === first.ordered + ) + break; + const leading = line.match(/^ */)?.[0].length ?? 0; + if (leading <= first.indent) break; + itemLines.push(line.slice(Math.min(leading, marker.contentIndent))); + index++; + } + while (!itemLines.at(-1)?.trim()) itemLines.pop(); + items.push(blockNodes(itemLines, depth + 1)); + } + return { + block: { + kind: "list", + ordered: first.ordered, + start: first.start, + items, + }, + nextIndex: index, + }; +} + +function blockNodes(sourceLines: string[], depth = 0): BlockNode[] { + if (depth >= MAX_NESTING) + return [ + { + kind: "paragraph", + children: inlineNodes(sourceLines.join("\n"), depth), + }, + ]; + const lines = sourceLines.map((line) => line.replace(/\r$/, "")); + const blocks: BlockNode[] = []; + let index = 0; + while (index < lines.length) { + const line = lines[index]; + if (!line.trim()) { + index++; + continue; + } + const fence = line.match(/^ {0,3}(`{3,}|~{3,}).*$/); + if (fence) { + const marker = fence[1]; + const value: string[] = []; + index++; + const closing = new RegExp( + `^ {0,3}${marker[0]}{${marker.length},}[\\t ]*$`, + ); + while (index < lines.length && !closing.test(lines[index])) { + value.push(lines[index]); + index++; + } + if (index < lines.length) index++; + blocks.push({ kind: "code", value: value.join("\n") }); + continue; + } + const heading = line.match(/^ {0,3}(#{1,6})(?:[\t ]+(.*?)[\t ]*|[\t ]*)$/); + if (heading) { + blocks.push({ + kind: "heading", + level: heading[1].length, + children: inlineNodes((heading[2] ?? "").replace(/[\t ]+#+$/, "")), + }); + index++; + continue; + } + if ( + index + 1 < lines.length && + /^ {0,3}(?:=+|-+)[\t ]*$/.test(lines[index + 1]) + ) { + blocks.push({ + kind: "heading", + level: lines[index + 1].trimStart().startsWith("=") ? 1 : 2, + children: inlineNodes(line.trim()), + }); + index += 2; + continue; + } + if (isThematicBreak(line)) { + blocks.push({ kind: "rule" }); + index++; + continue; + } + if (/^ {0,3}>/.test(line)) { + const quoted: string[] = []; + while (index < lines.length) { + const quote = lines[index].match(/^ {0,3}>[\t ]?(.*)$/); + if (quote) { + quoted.push(quote[1]); + index++; + } else if (!lines[index].trim()) { + quoted.push(""); + index++; + } else break; + } + blocks.push({ kind: "quote", children: blockNodes(quoted, depth + 1) }); + continue; + } + const marker = listMarker(line); + if (marker && marker.indent <= 3) { + const list = parseList(lines, index, depth); + blocks.push(list.block); + index = list.nextIndex; + continue; + } + const paragraph: string[] = []; + while ( + index < lines.length && + lines[index].trim() && + (!paragraph.length || !startsBlock(lines[index])) + ) { + paragraph.push(lines[index]); + index++; + } + blocks.push({ + kind: "paragraph", + children: inlineNodes(paragraphText(paragraph)), + }); + } + return blocks; +} + +function inlineContent( + nodes: InlineNode[], + onOpenLink: (url: string) => void, + path: string, +): ComponentChildren { + return nodes.map((node, index) => { + const key = `${path}-${index}`; + switch (node.kind) { + case "text": + return <Fragment key={key}>{node.value}</Fragment>; + case "break": + return <br key={key} />; + case "code": + return ( + <code + key={key} + class="rounded bg-secondaryContainer px-1 py-0.5 font-mono text-[.9em]" + > + {node.value} + </code> + ); + case "emphasis": + return ( + <em key={key}>{inlineContent(node.children, onOpenLink, key)}</em> + ); + case "strong": + return ( + <strong key={key} class="font-semibold"> + {inlineContent(node.children, onOpenLink, key)} + </strong> + ); + case "link": { + const url = node.url; + return url ? ( + <button + key={key} + type="button" + value={url} + title={url} + class="inline cursor-pointer border-0 bg-transparent p-0 text-left align-baseline font-inherit text-primary underline underline-offset-2" + onClick={(event) => { + event.stopPropagation(); + onOpenLink(event.currentTarget.value); + }} + > + {inlineContent(node.children, onOpenLink, key)} + </button> + ) : ( + <Fragment key={key}> + {inlineContent(node.children, onOpenLink, key)} + </Fragment> + ); + } + } + }); +} + +function heading( + level: number, + children: ComponentChildren, + key: string, +): ComponentChildren { + switch (level) { + case 1: + return ( + <h1 key={key} class="text-2xl font-semibold"> + {children} + </h1> + ); + case 2: + return ( + <h2 key={key} class="text-xl font-semibold"> + {children} + </h2> + ); + case 3: + return ( + <h3 key={key} class="text-lg font-semibold"> + {children} + </h3> + ); + case 4: + return ( + <h4 key={key} class="font-semibold"> + {children} + </h4> + ); + case 5: + return ( + <h5 key={key} class="font-semibold"> + {children} + </h5> + ); + default: + return ( + <h6 key={key} class="font-semibold"> + {children} + </h6> + ); + } +} + +function blockContent( + blocks: BlockNode[], + onOpenLink: (url: string) => void, + path = "markdown", +): ComponentChildren { + return blocks.map((block, index) => { + const key = `${path}-${index}`; + switch (block.kind) { + case "heading": + return heading( + block.level, + inlineContent(block.children, onOpenLink, key), + key, + ); + case "paragraph": + return ( + <p key={key}>{inlineContent(block.children, onOpenLink, key)}</p> + ); + case "rule": + return <hr key={key} class="border-outlineVariant" />; + case "quote": + return ( + <blockquote + key={key} + class="space-y-3 border-l-4 border-outlineVariant pl-4 text-secondary" + > + {blockContent(block.children, onOpenLink, key)} + </blockquote> + ); + case "code": + return ( + <pre + key={key} + class="overflow-x-auto rounded-lg bg-secondaryContainer p-3 font-mono text-xs leading-5" + > + <code>{block.value}</code> + </pre> + ); + case "list": { + const items = block.items.map((item, itemIndex) => ( + <li key={`${key}-${itemIndex}`} class="space-y-2"> + {blockContent(item, onOpenLink, `${key}-${itemIndex}`)} + </li> + )); + return block.ordered ? ( + <ol + key={key} + start={block.start === 1 ? undefined : block.start} + class="list-decimal space-y-2 pl-6" + > + {items} + </ol> + ) : ( + <ul key={key} class="list-disc space-y-2 pl-6"> + {items} + </ul> + ); + } + } + }); +} + +export function isMarkdownContentType( + contentType: string | undefined, +): boolean { + return contentType?.split(";", 1)[0].trim().toLowerCase() === "text/markdown"; +} + +export function isSafeMarkdownSize(content: string): boolean { + return ( + new TextEncoder().encode(content).byteLength <= MAX_SAFE_MARKDOWN_BYTES + ); +} + +export function SafeMarkdown(props: { + content: string; + onOpenLink: (url: string) => void; + tooLargeMessage: ComponentChildren; +}) { + if (!isSafeMarkdownSize(props.content)) { + return ( + <p role="alert" class="text-sm text-error"> + {props.tooLargeMessage} + </p> + ); + } + const blocks = blockNodes(props.content.replaceAll("\r\n", "\n").split("\n")); + return ( + <div class="space-y-4 text-sm leading-6"> + {blockContent(blocks, props.onOpenLink)} + </div> + ); +} diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts @@ -13,3 +13,4 @@ export * from "./NotificationBanner.js"; export * from "./Time.js"; export * from "./RenderAmount.js"; export * from "./Pagination.js"; +export * from "./SafeMarkdown.js"; diff --git a/packages/web-util/src/context/translation.ts b/packages/web-util/src/context/translation.ts @@ -20,12 +20,10 @@ import { useContext, useEffect, useMemo } from "preact/hooks"; import { strings as webUtilStrings, StringsType } from "../i18n/strings.js"; import { useLang } from "../hooks/index.js"; import { Locale } from "date-fns"; -import { - es as esLocale, - enGB as enLocale, - fr as frLocale, - de as deLocale, -} from "date-fns/locale"; +import esLocale from "date-fns/locale/es/index.js"; +import enLocale from "date-fns/locale/en-GB/index.js"; +import frLocale from "date-fns/locale/fr/index.js"; +import deLocale from "date-fns/locale/de/index.js"; export type InternationalizationAPI = typeof i18n; diff --git a/packages/web-util/src/forms/AcceptTosForm.test.tsx b/packages/web-util/src/forms/AcceptTosForm.test.tsx @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { i18n, setupI18n, TalerFormAttributes } from "@gnu-taler/taler-util"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { useForm } from "../hooks/useForm.js"; +import { acceptTos } from "./gana/accept-tos.js"; +import { FormUI } from "./forms-ui.js"; + +setupI18n("en", {}); + +function installDom() { + const window = new Window({ url: "https://kyc.example/" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + HTMLInputElement: window.HTMLInputElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +const design = acceptTos(i18n, { + tos_url: "https://exchange.example/terms", + provider_name: "Example Exchange", + tos_version: "terms-v4", +}); + +async function eventually(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt++) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +test("acceptance is gated on displaying the terms and records the exact version", async () => { + const window = installDom(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response("# Example terms\n\nPlease read these terms.", { + headers: { "Content-Type": "text/markdown; charset=utf-8" }, + }); + const { cleanup, render } = await import("@testing-library/preact"); + let result: Record<string, unknown> = {}; + + function Harness() { + const form = useForm<Record<string, unknown>>(design, {}); + result = form.status.result; + return <FormUI design={design} model={form.model} />; + } + + const view = render(<Harness />); + const checkbox = view.getByRole("checkbox", { + name: /I have read and accept the terms of service/, + }) as HTMLInputElement; + assert.equal(checkbox.disabled, true); + + ( + view.getByRole("button", { name: "Read terms" }) as HTMLButtonElement + ).click(); + await view.findByRole("heading", { name: "Example terms" }); + await eventually(() => assert.equal(checkbox.disabled, false)); + assert.equal(result[TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE], true); + assert.equal( + result[TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE], + undefined, + ); + + checkbox.click(); + await eventually(() => + assert.equal( + result[TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE], + "terms-v4", + ), + ); + checkbox.click(); + await eventually(() => + assert.equal( + result[TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE], + undefined, + ), + ); + + cleanup(); + globalThis.fetch = originalFetch; + await window.happyDOM.abort(); +}); + +test("unsupported responses keep acceptance disabled and can be retried", async () => { + const window = installDom(); + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { + calls++; + return calls === 1 + ? new Response("%PDF", { + headers: { "Content-Type": "application/pdf" }, + }) + : new Response("Readable terms", { + headers: { "Content-Type": "text/plain" }, + }); + }; + const { cleanup, render } = await import("@testing-library/preact"); + + function Harness() { + const form = useForm<Record<string, unknown>>(design, {}); + return <FormUI design={design} model={form.model} />; + } + + const view = render(<Harness />); + const checkbox = view.getByRole("checkbox") as HTMLInputElement; + ( + view.getByRole("button", { name: "Read terms" }) as HTMLButtonElement + ).click(); + await view.findByText( + "The terms are not available as Markdown or plain text.", + ); + assert.equal(checkbox.disabled, true); + + ( + view.getByRole("button", { name: "Try again" }) as HTMLButtonElement + ).click(); + await view.findByText("Readable terms"); + await eventually(() => assert.equal(checkbox.disabled, false)); + assert.equal(calls, 2); + + cleanup(); + globalThis.fetch = originalFetch; + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/forms/AcceptTosForm.tsx b/packages/web-util/src/forms/AcceptTosForm.tsx @@ -0,0 +1,454 @@ +import { TalerFormAttributes } from "@gnu-taler/taler-util"; +import { Fragment, type ComponentChildren, h, type VNode } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { useTranslationContext } from "../context/translation.js"; +import type { FormModel } from "../hooks/useForm.js"; +import { + isMarkdownContentType, + isSafeMarkdownSize, + SafeMarkdown, +} from "../components/SafeMarkdown.js"; +import type { AcceptTosFormDesign } from "./forms-types.js"; + +type ReaderDocument = { + content: string; + contentType: string; +}; + +type ReaderError = "load" | "format" | "large"; + +function mediaType(contentType: string | null): string { + return contentType?.split(";", 1)[0].trim().toLowerCase() ?? ""; +} + +function focusableElements(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll<HTMLElement>( + 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + ), + ); +} + +function safePdfFileName(providerName: string | undefined): string { + const provider = providerName + ?.normalize("NFKD") + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `${provider || "Terms-of-Service"}-Terms-of-Service.pdf`; +} + +function TermsReaderDialog(props: { + design: AcceptTosFormDesign; + document: ReaderDocument | undefined; + loading: boolean; + error: ReaderError | undefined; + onRetry: () => void; + onClose: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const dialogRef = useRef<HTMLDivElement>(null); + const closeButtonRef = useRef<HTMLButtonElement>(null); + const previousFocus = useRef<HTMLElement | null>(null); + const [downloadState, setDownloadState] = useState< + "idle" | "loading" | "error" + >("idle"); + // Translators: Button that retries loading the terms-of-service document. + const retryLabel = i18n.str`Try again`; + // Translators: Status shown while the terms-of-service PDF is downloading. + const downloadingPdfLabel = i18n.str`Downloading PDF…`; + // Translators: Button that downloads the terms-of-service document as a PDF. + const downloadPdfLabel = i18n.str`Download PDF`; + // Translators: Button that closes the terms reader. It does not accept the + // terms. + const doneLabel = i18n.str`Done`; + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + previousFocus.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + closeButtonRef.current?.focus(); + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + props.onClose(); + return; + } + const dialog = dialogRef.current; + if (event.key !== "Tab" || !dialog) return; + const focusable = focusableElements(dialog); + if (focusable.length === 0) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = previousOverflow; + previousFocus.current?.focus(); + }; + }, []); + + async function downloadPdf(): Promise<void> { + setDownloadState("loading"); + try { + const response = await fetch(props.design.tosUrl, { + headers: { Accept: "application/pdf" }, + cache: "no-cache", + }); + if ( + !response.ok || + mediaType(response.headers.get("content-type")) !== "application/pdf" + ) { + throw Error("PDF response unavailable"); + } + const objectUrl = URL.createObjectURL(await response.blob()); + try { + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = safePdfFileName(props.design.providerName); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + } finally { + URL.revokeObjectURL(objectUrl); + } + setDownloadState("idle"); + } catch (error) { + console.error("Could not download terms of service as PDF", error); + setDownloadState("error"); + } + } + + let errorMessage: ComponentChildren; + switch (props.error) { + case "format": + errorMessage = i18n.str`The terms are not available as Markdown or plain text.`; + break; + case "large": + // Translators: “terms” means the terms-of-service document, not words or + // terminology. + errorMessage = i18n.str`These terms are too large to display safely.`; + break; + case "load": + errorMessage = i18n.str`The terms of service could not be loaded.`; + break; + default: + errorMessage = undefined; + } + + return ( + <div + class="fixed inset-0 z-50 flex bg-black/50 p-0 sm:items-center sm:justify-center sm:p-4" + role="presentation" + onMouseDown={(event) => { + if (event.target === event.currentTarget) props.onClose(); + }} + > + <div + ref={dialogRef} + role="dialog" + aria-modal="true" + aria-labelledby="tos-reader-title" + aria-describedby="tos-reader-description" + tabIndex={-1} + class="flex h-full w-full flex-col overflow-hidden bg-white shadow-xl sm:h-auto sm:max-h-[min(90vh,56rem)] sm:max-w-3xl sm:rounded-xl" + > + <div class="flex shrink-0 items-start justify-between gap-4 border-b border-gray-200 px-4 py-4 sm:px-6"> + <div class="min-w-0"> + <h2 + id="tos-reader-title" + class="text-lg font-semibold text-gray-900" + > + <i18n.Translate>Terms of service</i18n.Translate> + </h2> + <p + id="tos-reader-description" + class="mt-1 truncate text-sm text-gray-500" + > + {props.design.providerName ?? props.design.tosUrl} + </p> + </div> + <button + ref={closeButtonRef} + type="button" + aria-label={i18n.str`Close terms of service`} + class="grid h-11 w-11 shrink-0 place-items-center rounded-full text-2xl leading-none text-gray-600 hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={props.onClose} + > + <span aria-hidden="true">×</span> + </button> + </div> + + <div class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6"> + {props.loading ? ( + <div class="grid min-h-48 place-items-center text-center"> + <p role="status" class="text-sm text-gray-600"> + <i18n.Translate>Loading terms of service…</i18n.Translate> + </p> + </div> + ) : errorMessage ? ( + <div class="grid min-h-48 place-items-center text-center"> + <div> + <p role="alert" class="text-sm font-medium text-red-700"> + {errorMessage} + </p> + <button + type="button" + class="mt-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={props.onRetry} + > + {retryLabel} + </button> + </div> + </div> + ) : props.document ? ( + isMarkdownContentType(props.document.contentType) ? ( + <SafeMarkdown + content={props.document.content} + tooLargeMessage={i18n.str`These terms are too large to display safely.`} + onOpenLink={(url) => + window.open(url, "_blank", "noopener,noreferrer") + } + /> + ) : ( + <pre class="whitespace-pre-wrap break-words font-sans text-sm leading-6 text-gray-800"> + {props.document.content} + </pre> + ) + ) : null} + </div> + + <div class="shrink-0 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:flex sm:items-center sm:justify-between sm:gap-3 sm:px-6"> + <div> + {!props.design.linkOnly && ( + <button + type="button" + disabled={downloadState === "loading"} + class="w-full rounded-md border border-gray-300 bg-white px-4 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-50 disabled:cursor-wait disabled:opacity-50 sm:w-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={() => void downloadPdf()} + > + {downloadState === "loading" + ? downloadingPdfLabel + : downloadPdfLabel} + </button> + )} + {downloadState === "error" && ( + <p role="alert" class="mt-2 text-sm text-red-700"> + <i18n.Translate> + The PDF could not be downloaded. + </i18n.Translate> + </p> + )} + </div> + <button + type="button" + class="mt-3 w-full rounded-md bg-indigo-600 px-4 py-2.5 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:mt-0 sm:w-auto" + onClick={props.onClose} + > + {doneLabel} + </button> + </div> + </div> + </div> + ); +} + +export function AcceptTosForm(props: { + name: string; + design: AcceptTosFormDesign; + model: FormModel; + disabled?: boolean; + focus?: boolean; + onSubmit?: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const downloaded = props.model.getHandlerForAttributeKey( + TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE, + ); + const accepted = props.model.getHandlerForAttributeKey( + TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE, + ); + const [open, setOpen] = useState(false); + const [document, setDocument] = useState<ReaderDocument>(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState<ReaderError>(); + const request = useRef<AbortController>(); + const readButton = useRef<HTMLButtonElement>(null); + + useEffect( + () => () => { + request.current?.abort(); + }, + [], + ); + + async function loadTerms(): Promise<void> { + request.current?.abort(); + const controller = new AbortController(); + request.current = controller; + setOpen(true); + setLoading(true); + setError(undefined); + try { + const response = await fetch(props.design.tosUrl, { + headers: { Accept: "text/markdown, text/plain;q=0.9" }, + cache: "no-cache", + signal: controller.signal, + }); + if (!response.ok) throw Error(`HTTP ${response.status}`); + const type = mediaType(response.headers.get("content-type")); + if (type !== "text/markdown" && type !== "text/plain") { + setError("format"); + return; + } + const content = await response.text(); + if (!isSafeMarkdownSize(content)) { + setError("large"); + return; + } + setDocument({ content, contentType: type }); + downloaded.onChange(true); + } catch (cause) { + if (controller.signal.aborted) return; + console.error("Could not load terms of service", cause); + setError("load"); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + } + + function openReader(): void { + if (document) { + setOpen(true); + return; + } + void loadTerms(); + } + + function closeReader(): void { + request.current?.abort(); + setLoading(false); + setOpen(false); + } + + const checkboxId = `${props.name}-accepted-terms-of-service`; + const hintId = `${props.name}-accepted-terms-of-service-hint`; + const hasRead = downloaded.value === true; + const isAccepted = accepted.value === props.design.tosVersion; + // Translators: %1$s is the name of the organization or service provider + // whose terms the user must review. + const providerDescription = i18n.str`Read the terms provided by ${ + props.design.providerName ?? "" + } before recording your acceptance.`; + // Translators: Button that reopens a terms-of-service document the user has + // already viewed. + const readAgainLabel = i18n.str`Read terms again`; + // Translators: Button that opens the terms-of-service document in an in-app + // reader. + const readLabel = i18n.str`Read terms`; + // Translators: The accepted version is the exact version of the + // terms-of-service document that was opened in the reader. + const reviewedVersionHint = i18n.str`Your acceptance applies to the version shown above.`; + + return ( + <Fragment> + <form + name={props.name} + class="max-w-2xl rounded-lg bg-white p-5 shadow-sm ring-1 ring-gray-900/5 sm:p-6" + onSubmit={(event) => { + event.preventDefault(); + props.onSubmit?.(); + }} + > + <div class="max-w-2xl"> + <h2 class="text-lg font-semibold text-gray-900"> + <i18n.Translate>Review the terms of service</i18n.Translate> + </h2> + <p class="mt-2 text-sm leading-6 text-gray-600"> + {props.design.providerName + ? providerDescription + : i18n.str`Read the terms before recording your acceptance.`} + </p> + + <button + ref={readButton} + type="button" + disabled={props.disabled} + class="mt-5 inline-flex min-h-11 items-center justify-center rounded-md bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 disabled:cursor-default disabled:opacity-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={openReader} + autofocus={props.focus} + > + {hasRead ? readAgainLabel : readLabel} + </button> + + <div class="mt-6 border-t border-gray-200 pt-5"> + <label + for={checkboxId} + class={`flex items-start gap-3 rounded-md p-3 -m-3 ${ + hasRead && !props.disabled + ? "cursor-pointer hover:bg-gray-50" + : "cursor-not-allowed" + }`} + > + <input + id={checkboxId} + type="checkbox" + class="mt-0.5 h-5 w-5 shrink-0 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 disabled:opacity-50" + checked={isAccepted} + disabled={!hasRead || props.disabled} + aria-describedby={hintId} + onChange={(event) => { + accepted.onChange( + event.currentTarget.checked + ? props.design.tosVersion + : undefined, + ); + }} + /> + <span> + <span class="block text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate> + I have read and accept the terms of service + </i18n.Translate> + </span> + <span + id={hintId} + class="mt-0.5 block text-sm leading-5 text-gray-500" + > + {hasRead + ? reviewedVersionHint + : i18n.str`Open the terms before accepting them.`} + </span> + </span> + </label> + </div> + </div> + </form> + {open && ( + <TermsReaderDialog + design={props.design} + document={document} + loading={loading} + error={error} + onRetry={() => void loadTerms()} + onClose={closeReader} + /> + )} + </Fragment> + ); +} diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -35,7 +35,23 @@ import { codecForEither, } from "@gnu-taler/taler-util"; -export type FormDesign = DoubleColumnFormDesign | SingleColumnFormDesign; +export type FormDesign = + | DoubleColumnFormDesign + | SingleColumnFormDesign + | AcceptTosFormDesign; + +/** + * Purpose-built terms-of-service form. This is a form design rather than a + * collection of generic fields so that reading and accepting a legal document + * can have one coherent, accessible interaction. + */ +export type AcceptTosFormDesign = { + type: "accept-tos"; + tosUrl: string; + tosVersion: string; + providerName?: string; + linkOnly?: boolean; +}; /** * Form with multiple sections. @@ -594,11 +610,21 @@ const codecForSingleColumnFormDesign = (): Codec<SingleColumnFormDesign> => .property("fields", codecForList(codecForUiFormField())) .build("SingleColumnFormDesign"); +const codecForAcceptTosFormDesign = (): Codec<AcceptTosFormDesign> => + buildCodecForObject<AcceptTosFormDesign>() + .property("type", codecForConstString("accept-tos")) + .property("tosUrl", codecForStringURL()) + .property("tosVersion", codecForString()) + .property("providerName", codecOptional(codecForString())) + .property("linkOnly", codecOptional(codecForBoolean())) + .build("AcceptTosFormDesign"); + const codecForFormDesign = (): Codec<FormDesign> => buildCodecForUnion<FormDesign>() .discriminateOn("type") .alternative("double-column", codecForDoubleColumnFormDesign()) .alternative("single-column", codecForSingleColumnFormDesign()) + .alternative("accept-tos", codecForAcceptTosFormDesign()) .build<FormDesign>("FormDesign"); const codecForFormMetadata = (): Codec<FormMetadata> => diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -19,6 +19,7 @@ import { UIFormElementConfig, } from "./forms-types.js"; import { convertFormConfigToUiField } from "./forms-utils.js"; +import { AcceptTosForm } from "./AcceptTosForm.js"; export function DefaultForm<T>({ design, @@ -58,7 +59,7 @@ export function DefaultForm<T>({ )} </pre> <hr class="mt-3 mb-3" /> - {status.status !== "ok" ? ( + {status.status !== "ok" && design.type !== "accept-tos" ? ( <ErrorsSummary errors={status.errors} /> ) : undefined} </div> @@ -111,6 +112,18 @@ export function FormUI<T>({ onSubmit?: () => void; }): VNode { switch (design.type) { + case "accept-tos": { + return ( + <AcceptTosForm + name={name} + design={design} + model={model} + focus={focus} + onSubmit={onSubmit} + disabled={disabled} + /> + ); + } case "double-column": { const ui = design.sections.map((section, i) => { if (!section) return <Fragment key={i} />; diff --git a/packages/web-util/src/forms/gana/accept-tos.stories.tsx b/packages/web-util/src/forms/gana/accept-tos.stories.tsx @@ -35,4 +35,26 @@ export const EmptyForm = tests.createExample(DefaultForm, { }), }); +export const LinkOnly = tests.createExample(DefaultForm, { + initial: {}, + design: acceptTos(i18n, { + tos_url: "https://exchange.demo.taler.net/terms", + provider_name: "Taler Operations AG", + tos_version: "v1", + link_only: true, + }), +}); + +export const AlreadyAccepted = tests.createExample(DefaultForm, { + initial: { + DOWNLOADED_TERMS_OF_SERVICE: true, + ACCEPTED_TERMS_OF_SERVICE: "v1", + }, + design: acceptTos(i18n, { + tos_url: "https://exchange.demo.taler.net/terms", + provider_name: "Taler Operations AG", + tos_version: "v1", + }), +}); + export default { title: "accept tos" }; diff --git a/packages/web-util/src/forms/gana/accept-tos.ts b/packages/web-util/src/forms/gana/accept-tos.ts @@ -14,14 +14,10 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - TalerFormAttributes, - TalerProtocolDuration, -} from "@gnu-taler/taler-util"; +import { TalerProtocolDuration } from "@gnu-taler/taler-util"; import type { + AcceptTosFormDesign, InternationalizationAPI, - SingleColumnFormDesign, - UIFormElementConfig, } from "@gnu-taler/web-util/browser"; export type AcceptTermOfServiceContext = { @@ -43,9 +39,6 @@ export type AcceptTermOfServiceContext = { tosVersion?: string; }; -function normalize(str: string) { - return str.replace(/ /g, "-"); -} /** * * @param i18n @@ -53,59 +46,14 @@ function normalize(str: string) { * @returns */ export function acceptTos( - i18n: InternationalizationAPI, + _i18n: InternationalizationAPI, context: AcceptTermOfServiceContext, -): SingleColumnFormDesign { - const myFields: UIFormElementConfig[] = []; - const tosFileName = !context.provider_name - ? "TermsOfService.pdf" - : `${normalize(context.provider_name)}_TermsOfService.PDF`; - if (context.link_only) { - myFields.push({ - type: "external-link", - id: TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE, - required: true, - url: context.tos_url, - label: i18n.str`Terms of service`, - help: i18n.str`You must open/download the terms of service to proceed`, - }); - } else { - myFields.push( - { - type: "external-link", - id: TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE, - required: true, - url: context.tos_url, - label: i18n.str`View in Browser`, - }, - { - type: "download-link", - id: TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE, - url: context.tos_url, - label: i18n.str`Download PDF version`, - // required: true, - validator(text, form) { - return !text ? i18n.str`Click to download & read` : undefined; - }, - media: "application/pdf", - fileName: tosFileName, - help: i18n.str`You must download to proceed`, - }, - ); - } - +): AcceptTosFormDesign { return { - type: "single-column" as const, - fields: [ - ...myFields, - { - type: "toggle", - id: TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE, - required: true, - trueValue: context.tos_version ?? context.tosVersion, - onlyTrueValue: true, - label: i18n.str`Do you accept the terms of service?`, - }, - ], + type: "accept-tos", + tosUrl: context.tos_url, + tosVersion: context.tos_version ?? context.tosVersion ?? "", + providerName: context.provider_name, + linkOnly: context.link_only, }; } diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts @@ -35,6 +35,7 @@ import { form_vqf_902_9_customer } from "./gana/VQF_902_9_customer.js"; import { form_vqf_902_9_officer } from "./gana/VQF_902_9_officer.js"; export * from "./Calendar.js"; +export * from "./AcceptTosForm.js"; export * from "./Caption.js"; export * from "./Dialog.js"; export * from "./field-types.js"; diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -18,6 +18,7 @@ import { AbsoluteTime, AmountJson, assertUnreachable, + TalerFormAttributes, TalerExchangeApi, TranslatedString, } from "@gnu-taler/taler-util"; @@ -427,6 +428,56 @@ function constructFormHandler<T>( ); break; } + case "accept-tos": { + const fields = [ + { + name: TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE, + valid: (value: unknown) => value === true, + label: i18n.str`Terms of service`, + // Translators: Validation message shown until the user has opened and + // successfully loaded the terms-of-service document in the reader. + message: i18n.str`Read the terms of service before continuing.`, + }, + { + name: TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE, + valid: (value: unknown) => value === design.tosVersion, + // Translators: Validation-summary label for the checkbox that records + // acceptance of a specific terms-of-service version. + label: i18n.str`Terms acceptance`, + // Translators: Validation message shown until the terms-of-service + // acceptance checkbox is selected. + message: i18n.str`Accept the terms of service before continuing.`, + }, + ]; + for (const field of fields) { + const path = [field.name]; + const currentValue = getValueFromPath(formValue, path, undefined); + const currentError: ErrorAndLabel | undefined = field.valid( + currentValue, + ) + ? undefined + : { + label: field.label, + message: field.message, + section: undefined, + }; + if (currentError) { + errors = setValueIntoPath(errors, path, currentError); + } + const handler: UIFieldHandler = { + name: field.name, + value: currentValue, + error: currentError?.message, + formRootResult: result, + onChange: (newValue) => { + onValueChange(setValueIntoPath(formValue, path, newValue) ?? {}); + }, + }; + model.fieldHandlers[`accept-tos.${field.name}`] = handler; + result = setValueIntoPath(result, path, currentValue) ?? {}; + } + break; + } default: { assertUnreachable(design); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -718,6 +718,9 @@ importers: '@gnu-taler/taler-wallet-core': specifier: workspace:* version: link:../taler-wallet-core + '@gnu-taler/web-util': + specifier: workspace:* + version: link:../web-util '@preact/signals': specifier: ^2.3.1 version: 2.11.0(preact@10.29.8) @@ -798,6 +801,9 @@ importers: '@heroicons/react': specifier: ^2.0.17 version: 2.0.17(react@18.3.1) + '@testing-library/preact': + specifier: ^3.2.4 + version: 3.2.4(preact@10.11.3) '@types/node': specifier: ^20.19.41 version: 20.19.41 @@ -822,6 +828,9 @@ importers: h3: specifier: ^1.15.0 version: 1.15.11 + happy-dom: + specifier: ^20.11.2 + version: 20.11.6 postcss: specifier: ^8.4.23 version: 8.4.49 @@ -4597,6 +4606,11 @@ snapshots: lz-string: 1.5.0 pretty-format: 27.5.1 + '@testing-library/preact@3.2.4(preact@10.11.3)': + dependencies: + '@testing-library/dom': 8.20.1 + preact: 10.11.3 + '@testing-library/preact@3.2.4(preact@10.29.8)': dependencies: '@testing-library/dom': 8.20.1