From 1b70e1b1c6402c614f7fea62bb540c066ceba15d Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Thu, 29 Oct 2026 00:26:38 +0100 Subject: [PATCH 1/4] feat(design): add cookie consent and footer Add two components with a shared configuration (ComplianceConfig), so that apps add cookie consent and a compliance footer from configuration alone: - ecc-utils-design-cookie-consent: banner on the first visit (accept all, necessary only, customize), preferences dialog per category, consent in localStorage, asked again when the categories or the version change; hasConsent(), onConsentChange(), openCookiePreferences() and a window event for other code. - ecc-utils-design-compliance-footer: default legal items (privacy, terms, cookie policy, cookie preferences, imprint, accessibility, contact, privacy choices) that operators change, hide or extend; actions link, mailto, preferences, Markdown page (escaped subset) and https embed (sandboxed, optionally gated by consent). Configured URLs are checked (no javascript: links). Docs pages with previews and operator notes; tests for both components. Closes #458 Co-Authored-By: Claude Opus 5.5 --- .../pages/docs/design/components/_meta.json | 6 +- .../design/components/compliance-footer.mdx | 129 +++++++ .../docs/design/components/cookie-consent.mdx | 179 +++++++++ packages/ecc-utils-design/package.json | 1 + .../compliance-footer/compliance-footer.ts | 228 ++++++++++++ .../src/components/compliance-footer/index.ts | 22 ++ .../components/compliance-footer/markdown.ts | 132 +++++++ .../tests/compliance-footer.test.ts | 284 ++++++++++++++ .../src/components/cookie-consent/consent.ts | 350 ++++++++++++++++++ .../cookie-consent/cookie-consent.ts | 264 +++++++++++++ .../src/components/cookie-consent/index.ts | 24 ++ .../tests/cookie-consent.test.ts | 275 ++++++++++++++ .../src/components/cookie-consent/urls.ts | 65 ++++ .../ecc-utils-design/src/components/index.ts | 3 + .../events/ecc-compliance-item-selected.ts | 12 + .../src/events/ecc-cookie-consent-changed.ts | 12 + packages/ecc-utils-design/src/events/index.ts | 2 + web-test.runner-config.mjs | 3 + 18 files changed, 1989 insertions(+), 2 deletions(-) create mode 100644 apps/documentation/pages/docs/design/components/compliance-footer.mdx create mode 100644 apps/documentation/pages/docs/design/components/cookie-consent.mdx create mode 100644 packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts create mode 100644 packages/ecc-utils-design/src/components/compliance-footer/index.ts create mode 100644 packages/ecc-utils-design/src/components/compliance-footer/markdown.ts create mode 100644 packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts create mode 100644 packages/ecc-utils-design/src/components/cookie-consent/consent.ts create mode 100644 packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts create mode 100644 packages/ecc-utils-design/src/components/cookie-consent/index.ts create mode 100644 packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts create mode 100644 packages/ecc-utils-design/src/components/cookie-consent/urls.ts create mode 100644 packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts create mode 100644 packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts diff --git a/apps/documentation/pages/docs/design/components/_meta.json b/apps/documentation/pages/docs/design/components/_meta.json index e0ff1ffc..1e43cd57 100644 --- a/apps/documentation/pages/docs/design/components/_meta.json +++ b/apps/documentation/pages/docs/design/components/_meta.json @@ -14,5 +14,7 @@ "multi-select": "Multi-Select", "pagination": "Pagination", "tabs": "Tabs", - "table": "Table" -} \ No newline at end of file + "table": "Table", + "cookie-consent": "Cookie Consent", + "compliance-footer": "Compliance Footer" +} diff --git a/apps/documentation/pages/docs/design/components/compliance-footer.mdx b/apps/documentation/pages/docs/design/components/compliance-footer.mdx new file mode 100644 index 00000000..6e76e39d --- /dev/null +++ b/apps/documentation/pages/docs/design/components/compliance-footer.mdx @@ -0,0 +1,129 @@ +import { Callout, Tabs } from 'nextra/components' +import ComponentName from '@/components/common/ComponentName' +import ComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; + +# EccUtilsDesignComplianceFooter Component + + + +## Overview + +The `ecc-utils-design-compliance-footer` component renders the links to legal and regulatory information that operators must provide, configured per deployment. It reads the same configuration as the [cookie consent](/docs/design/components/cookie-consent), so an app adds both from configuration alone. + +Each item has an action: + +| Action | Configuration | Result | +|---------------|-------------------------------------------------------|------------------------------------------------------------| +| `link` | `{ type: 'link', href, newTab? }` | A link (`http(s)` or relative). | +| `mailto` | `{ type: 'mailto', email, subject? }` | Opens the mail client. | +| `preferences` | `{ type: 'preferences' }` | Opens the cookie preferences. | +| `page` | `{ type: 'page', markdown, title? }` | Shows a page from Markdown in a dialog. | +| `embed` | `{ type: 'embed', src, title?, consent? }` | Shows an `https` page in a frame in a dialog, e.g. a policy hosted by Iubenda, Termly or GetTerms; with `consent`, only once users consented to that cookie category. | + + + Operators are responsible for the content: which items their deployment needs, under which regulations, and what the pages say. The defaults are only a starting point. + + +## Usage + + + + + + + ```tsx showLineNumbers copy filename="footer.tsx" + import EccUtilsDesignComplianceFooter from '@elixir-cloud/design/react/compliance-footer'; + + const compliance = { + footer: { + items: [ + { id: 'privacy-policy', action: { type: 'link', href: '/privacy' } }, + { id: 'imprint', action: { type: 'page', markdown: 'Operated by **Example Institute**.' } }, + { id: 'contact', action: { type: 'mailto', email: 'support@example.org' } }, + { id: 'accessibility', hidden: true }, + ], + }, + }; + + export default function Footer() { + return ; + } + ``` + + + ```html showLineNumbers copy filename="footer.html" + + + + ``` + + + +## Properties + +| Property | Type | Default | Description | +|----------|--------------------|---------|------------------------------------------------------------------------------| +| `config` | `ComplianceConfig` | `{}` | Shared compliance configuration; this component uses `config.footer`, and `config.cookies` for embeds that need consent. | + +## Events + +| Event Name | React Event Name | Detail Type | Description | +|--------------------------------|--------------------------------|------------------------|-------------| +| `ecc-compliance-item-selected` | `onEccComplianceItemSelected` | `{ item: FooterItem }` | Fired when the user selects an item, before its action runs. Call `event.preventDefault()` to handle the item yourself, e.g. with the app's router. | + +## Configuration (`config.footer`) + +| Field | Type | Description | +|-------------------|----------------------|-------------------------------------------------------------------------------| +| `items` | `FooterItemConfig[]` | `{ id, label?, action?, hidden? }`. An entry with the `id` of a default item changes it (`label`, `action`) or removes it (`hidden: true`); other entries are added after the defaults, in their order. | +| `replaceDefaults` | `boolean` | Use only the configured items. | +| `label` | `string` | Accessible name of the footer's navigation, default "Legal". | + +Items without an action, or whose link, address or embed URL isn't valid, aren't shown. + +### Default items + +| ID | Label | Default action | +|----------------------|-------------------------|-----------------| +| `privacy-policy` | Privacy Policy | none | +| `terms` | Terms & Conditions | none | +| `cookie-policy` | Cookie Policy | none | +| `cookie-preferences` | Cookie Preferences | `preferences` | +| `imprint` | Imprint | none | +| `accessibility` | Accessibility Statement | none | +| `contact` | Contact Us | none | +| `privacy-choices` | Your Privacy Choices | none | + +So without configuration, the footer shows only "Cookie Preferences"; each item that the operator gives an action appears, in this order. + +### Markdown pages + +`page` items support a small subset of Markdown: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, `mailto` and relative URLs). All text is escaped; HTML in the Markdown is shown as text, not rendered. + +### Embeds + +- Only `https` URLs (and `http` on `localhost`) are embedded. The frame is sandboxed (`allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`) and sends no referrer. +- Allow the embed's origin in the app's Content Security Policy (`frame-src`). +- Embeds whose provider sets cookies should name a cookie category in `consent`; until users consent to it, the dialog shows a note and a button to the preferences instead of the frame. +- Script-based embeds (a ` + + + ``` + + + +## Properties + +| Property | Type | Default | Description | +|----------|--------------------|---------|-----------------------------------------------------------------------------| +| `config` | `ComplianceConfig` | `{}` | Shared compliance configuration; this component uses `config.cookies`. | + +## Methods + +| Method | Description | +|---------------------|--------------------------------------------------------------------| +| `openPreferences()` | Opens the preferences. `openCookiePreferences()` does the same from anywhere on the page. | + +## Events + +| Event Name | React Event Name | Detail Type | Description | +|------------------------------|-----------------------------|------------------------------|----------------------------------------------| +| `ecc-cookie-consent-changed` | `onEccCookieConsentChanged` | `{ consent: CookieConsent }` | Fired when the user saved their choices. It doesn't bubble; `saveConsent()` fires the same event on `window`. | + +## Configuration (`config.cookies`) + +| Field | Type | Description | +|--------------|-------------------------------|-----------------------------------------------------------------------------| +| `categories` | `CookieCategory[]` | `{ id, label, description?, required? }`. A required "necessary" category is added if none is required. | +| `version` | `string` | Version of the cookie policy; changing it asks users again. | +| `policyUrl` | `string` | Link to the cookie policy (`http(s)` or relative), shown in the banner and the preferences. | +| `notice` | `boolean` | Show the banner although there are no optional categories. | +| `storageKey` | `string` | `localStorage` key, default `ecc-cookie-consent`. | +| `texts` | `Partial` | Texts of the banner and the preferences, e.g. for translations: `bannerTitle`, `bannerText`, `policyLink`, `acceptAll`, `rejectAll`, `customize`, `preferencesTitle`, `preferencesText`, `save`, `close`, `alwaysOn`, `embedConsent`. | + +Changing a category's `id`, adding or removing a category, or changing `version` asks users again; changing texts or descriptions doesn't. + +## Loading scripts with consent + +The functions below come from `@elixir-cloud/design/components/cookie-consent/index.js` (also exported by the package's main entry). Pass the same `cookies` configuration that the component uses. + +```ts copy +import { + hasConsent, + onConsentChange, + openCookiePreferences, +} from '@elixir-cloud/design/components/cookie-consent/index.js'; + +const loadAnalytics = () => { /* add the analytics script */ }; + +if (hasConsent('analytics', compliance.cookies)) loadAnalytics(); +onConsentChange((consent) => { + if (consent.categories.analytics) loadAnalytics(); + // revoking consent: stop tracking and remove its cookies, or reload +}); + +// e.g. from a link of your own +openCookiePreferences(); +``` + +| Function | Description | +|----------------------------------------|-----------------------------------------------------------------| +| `hasConsent(category, config?)` | Whether users consented to a category (required ones: always). | +| `readConsent(config?)` | The stored `CookieConsent`, or `null` if users must be asked. | +| `saveConsent(config, choices)` | Stores choices and fires `ecc-cookie-consent-changed` on `window`. | +| `onConsentChange(listener)` | Calls `listener` on every change; returns a function to stop. | +| `openCookiePreferences()` | Asks the page's component to open the preferences. | + +## Notes for operators + +- List every category that your deployment uses, with a description users understand. Keep "Necessary" for what the site can't work without (e.g. login cookies); these need no consent under the GDPR and ePrivacy rules, but should be explained in the cookie policy. +- Bump `version` when the cookie policy changes in a way users should see. +- Provide the cookie policy (`policyUrl`, or a footer item) and load optional scripts only after `hasConsent()`. +- Consent is stored per browser and origin; it isn't sent to a server. diff --git a/packages/ecc-utils-design/package.json b/packages/ecc-utils-design/package.json index d41d2bb9..32defef3 100644 --- a/packages/ecc-utils-design/package.json +++ b/packages/ecc-utils-design/package.json @@ -36,6 +36,7 @@ "build": "node ../../scripts/tailwind/index.js && node ../../scripts/build.js -p ecc-utils-design-", "dev": "concurrently -r \"node ../../scripts/tailwind/index.js --watch\" \"node ../../scripts/build.js -p ecc-utils-design- --watch\" \"wds\"", "clean": "rm -rf dist node_modules custom-elements-manifest.config.js src/output.css src/tailwind.ts", + "test": "web-test-runner --group default --config ../../web-test.runner-config.mjs --port 8003", "test:component": "web-test-runner --watch --config ../../web-test.runner-config.mjs --group", "test:watch": "npm run test:component default", "lint": "npx eslint .", diff --git a/packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts b/packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts new file mode 100644 index 00000000..9dff0fd3 --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/compliance-footer.ts @@ -0,0 +1,228 @@ +import { LitElement, html, css, nothing } from "lit"; +import { property, query, state } from "lit/decorators.js"; +import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; +import { GlobalStyles } from "../../global.js"; +import { + ComplianceConfig, + CONSENT_CHANGED_EVENT, + cookieCategories, + DEFAULT_COOKIE_TEXTS, + FooterItem, + footerItems, + hasConsent, + openCookiePreferences, +} from "../cookie-consent/consent.js"; +import { mailtoHref, safeEmbedSrc, safeHref } from "../cookie-consent/urls.js"; +import { renderMarkdown } from "./markdown.js"; +import "../button/index.js"; + +type DialogItem = FooterItem & { + action: Extract; +}; + +/** + * @summary Footer with links to legal and regulatory information, configured + * by the shared compliance configuration. + * @since 2.0.0 + * + * @property {ComplianceConfig} config - Shared compliance configuration; the component uses `config.footer` (and `config.cookies` for embeds that need consent). + * + * @fires ecc-compliance-item-selected - Fired when the user selects an item (detail: `{ item }`), before its action runs. Cancel the event (`preventDefault()`) to handle the item yourself, e.g. with the app's router. + * + * @csspart nav - The navigation with the items. + * @csspart item - Each item. + * @csspart dialog - The dialog of pages and embeds. + */ +export class EccUtilsDesignComplianceFooter extends LitElement { + static styles = [ + css` + ${TailwindStyles} + `, + css` + ${GlobalStyles} + `, + css` + :host { + display: block; + } + dialog::backdrop { + background: rgb(0 0 0 / 0.5); + } + `, + ]; + + @property({ attribute: false }) config: ComplianceConfig = {}; + + /** The page or embed shown in the dialog. */ + @state() private openItem: DialogItem | null = null; + + /** Bumped when consent changes, so that embeds load once allowed. */ + @state() private consentVersion = 0; + + @query("dialog") private dialog?: HTMLDialogElement; + + private readonly onConsentChanged = () => { + this.consentVersion += 1; + }; + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener(CONSENT_CHANGED_EVENT, this.onConsentChanged); + } + + disconnectedCallback(): void { + window.removeEventListener(CONSENT_CHANGED_EVENT, this.onConsentChanged); + super.disconnectedCallback(); + } + + private select(item: FooterItem, event?: Event): boolean { + const selected = new CustomEvent("ecc-compliance-item-selected", { + detail: { item }, + bubbles: true, + composed: true, + cancelable: true, + }); + const proceed = this.dispatchEvent(selected); + if (!proceed) event?.preventDefault(); + return proceed; + } + + private async openDialog(item: DialogItem): Promise { + this.openItem = item; + await this.updateComplete; + if (this.dialog && !this.dialog.open) this.dialog.showModal(); + } + + private runAction(item: FooterItem): void { + const { action } = item; + if (!action || !this.select(item)) return; + if (action.type === "preferences") openCookiePreferences(); + else if (action.type === "page" || action.type === "embed") { + this.openDialog(item as DialogItem); + } + } + + private renderItem(item: FooterItem) { + const { action } = item; + const linkClasses = + "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"; + if (action?.type === "link" || action?.type === "mailto") { + const href = + action.type === "link" + ? safeHref(action.href) + : mailtoHref(action.email, action.subject); + if (!href) return nothing; + const newTab = action.type === "link" && action.newTab; + return html`
  • + this.select(item, event)} + >${item.label} +
  • `; + } + if (action?.type === "embed" && !safeEmbedSrc(action.src)) return nothing; + return html`
  • + +
  • `; + } + + private renderEmbed(item: DialogItem) { + if (item.action.type !== "embed") return nothing; + const { consent } = item.action; + const src = safeEmbedSrc(item.action.src); + if (!src) return nothing; + // read again when consent changes (consentVersion) + if ( + consent && + this.consentVersion >= 0 && + !hasConsent(consent, this.config?.cookies) + ) { + const texts = { ...DEFAULT_COOKIE_TEXTS, ...this.config?.cookies?.texts }; + const label = + cookieCategories(this.config?.cookies).find((c) => c.id === consent) + ?.label ?? consent; + return html`

    + ${texts.embedConsent.replace("{category}", label)} +

    + openCookiePreferences()} + >${texts.preferencesTitle}`; + } + return html``; + } + + private renderDialog() { + const item = this.openItem; + const { close } = DEFAULT_COOKIE_TEXTS; + return html` + { + this.openItem = null; + }} + > + ${item + ? html`

    + ${item.action.title ?? item.label} +

    +
    + ${item.action.type === "page" + ? renderMarkdown(item.action.markdown) + : this.renderEmbed(item)} +
    ` + : nothing} +
    + this.dialog?.close()} + >${this.config?.cookies?.texts?.close ?? + close} +
    +
    + `; + } + + render() { + const items = footerItems(this.config?.footer); + return html` + + ${this.renderDialog()} + `; + } +} + +export default EccUtilsDesignComplianceFooter; diff --git a/packages/ecc-utils-design/src/components/compliance-footer/index.ts b/packages/ecc-utils-design/src/components/compliance-footer/index.ts new file mode 100644 index 00000000..2d681a03 --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/index.ts @@ -0,0 +1,22 @@ +import EccUtilsDesignComplianceFooter from "./compliance-footer.js"; + +export * from "./compliance-footer.js"; +export default EccUtilsDesignComplianceFooter; + +// Define the custom element +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-utils-design-compliance-footer") +) { + window.customElements.define( + "ecc-utils-design-compliance-footer", + EccUtilsDesignComplianceFooter + ); +} + +declare global { + interface HTMLElementTagNameMap { + "ecc-utils-design-compliance-footer": EccUtilsDesignComplianceFooter; + } +} diff --git a/packages/ecc-utils-design/src/components/compliance-footer/markdown.ts b/packages/ecc-utils-design/src/components/compliance-footer/markdown.ts new file mode 100644 index 00000000..963f78bd --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/markdown.ts @@ -0,0 +1,132 @@ +import { html, nothing, TemplateResult } from "lit"; +import { safeHref } from "../cookie-consent/urls.js"; + +/* + * A small Markdown subset for legal pages, rendered with Lit templates, so + * that all text is escaped and no HTML from the configuration reaches the + * page: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), + * `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, + * `mailto` and relative URLs). + */ + +type Inline = string | TemplateResult; + +const INLINE = /\*\*([^*]+)\*\*|\*([^*]+)\*|\[([^\]]+)\]\(([^)\s]+)\)/g; + +/** + * Renders the inline Markdown of a line. + * @param text Text with inline Markdown + * @returns Strings and templates + */ +export function renderInline(text: string): Inline[] { + const parts: Inline[] = []; + let last = 0; + text.replace( + INLINE, + (match, bold, em, label, url, offset: number): string => { + if (offset > last) parts.push(text.slice(last, offset)); + if (bold) parts.push(html`${bold}`); + else if (em) parts.push(html`${em}`); + else { + const href = safeHref(url); + parts.push( + href + ? html`${label}` + : label + ); + } + last = offset + match.length; + return match; + } + ); + if (last < text.length) parts.push(text.slice(last)); + return parts; +} + +const HEADING_CLASSES = [ + "mt-4 text-xl font-semibold", + "mt-4 text-lg font-semibold", + "mt-3 font-semibold", +]; + +/** + * Renders Markdown (the subset above). + * @param markdown The Markdown + * @returns The rendered blocks + */ +export function renderMarkdown(markdown: string): TemplateResult[] { + const blocks: TemplateResult[] = []; + let paragraph: string[] = []; + let list: { ordered: boolean; items: string[] } | null = null; + + const flush = () => { + if (paragraph.length) { + blocks.push( + html`

    ${renderInline(paragraph.join(" "))}

    ` + ); + paragraph = []; + } + if (list) { + const items = list.items.map( + (item) => html`
  • ${renderInline(item)}
  • ` + ); + blocks.push( + list.ordered + ? html`
      + ${items} +
    ` + : html`
      + ${items} +
    ` + ); + list = null; + } + }; + + (typeof markdown === "string" ? markdown : "") + .split(/\r?\n/) + .forEach((raw) => { + const line = raw.trim(); + const heading = /^(#{1,3})\s+(.*)$/.exec(line); + const bullet = /^[-*]\s+(.*)$/.exec(line); + const numbered = /^\d+[.)]\s+(.*)$/.exec(line); + if (!line) { + flush(); + } else if (heading) { + flush(); + const level = heading[1].length; + const content = renderInline(heading[2]); + const classes = HEADING_CLASSES[level - 1]; + // h2 to h4: the dialog's title is the h2 of the page + if (level === 1) + blocks.push(html`

    ${content}

    `); + else if (level === 2) + blocks.push(html`

    ${content}

    `); + else blocks.push(html`
    ${content}
    `); + } else if (bullet || numbered) { + if (paragraph.length) { + const pending = paragraph; + paragraph = []; + blocks.push( + html`

    ${renderInline(pending.join(" "))}

    ` + ); + } + const ordered = !!numbered; + if (list && list.ordered !== ordered) flush(); + if (!list) list = { ordered, items: [] }; + const item = bullet ?? numbered; + if (item) list.items.push(item[1]); + } else { + if (list) flush(); + paragraph.push(line); + } + }); + flush(); + return blocks.length ? blocks : [html`${nothing}`]; +} diff --git a/packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts b/packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts new file mode 100644 index 00000000..24697a5d --- /dev/null +++ b/packages/ecc-utils-design/src/components/compliance-footer/tests/compliance-footer.test.ts @@ -0,0 +1,284 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import { render } from "lit"; +import sinon from "sinon"; +import "../index.js"; +import type EccUtilsDesignComplianceFooter from "../compliance-footer.js"; +import { + ComplianceConfig, + PREFERENCES_REQUESTED_EVENT, + footerItems, + saveConsent, +} from "../../cookie-consent/consent.js"; +import { renderMarkdown } from "../markdown.js"; + +const items = (el: EccUtilsDesignComplianceFooter) => + Array.from(el.shadowRoot?.querySelectorAll("[part=item]") ?? []); + +const item = (el: EccUtilsDesignComplianceFooter, label: string) => + items(el) + .map((li) => li.querySelector("a, button") as HTMLElement) + .find((node) => node.textContent?.trim() === label) as HTMLElement; + +const dialog = (el: EccUtilsDesignComplianceFooter) => + el.shadowRoot?.querySelector("dialog") as HTMLDialogElement; + +const COOKIES = { + categories: [{ id: "embeds", label: "Embedded content" }], +}; + +describe("footer items", () => { + it("shows only the defaults that have an action", () => { + expect(footerItems().map((i) => i.id)).to.deep.equal([ + "cookie-preferences", + ]); + }); + + it("changes, hides and adds items", () => { + const result = footerItems({ + items: [ + { + id: "privacy-policy", + action: { type: "link", href: "https://example.org/privacy" }, + }, + { id: "cookie-preferences", hidden: true }, + { + id: "status", + label: "Status", + action: { type: "link", href: "/status" }, + }, + { + id: "imprint", + label: "Impressum", + action: { type: "page", markdown: "# Impressum" }, + }, + ], + }); + + expect(result.map((i) => [i.id, i.label])).to.deep.equal([ + ["privacy-policy", "Privacy Policy"], + ["imprint", "Impressum"], + ["status", "Status"], + ]); + }); + + it("uses only configured items when asked", () => { + const result = footerItems({ + replaceDefaults: true, + items: [ + { id: "a", label: "A", action: { type: "preferences" } }, + { id: "b", label: "B" }, + ], + }); + + expect(result.map((i) => i.id)).to.deep.equal(["a"]); + }); +}); + +describe("markdown pages", () => { + const rendered = (markdown: string) => { + const container = document.createElement("div"); + render(renderMarkdown(markdown), container); + return container; + }; + + it("renders headings, paragraphs, lists and links", () => { + const container = rendered( + "# Privacy\n\nWe keep **little** data,\nfor *you*.\n\n- one\n- two\n\n1. first\n\nMail [us](mailto:dpo@example.org)." + ); + + expect(container.querySelector("h3")?.textContent).to.equal("Privacy"); + expect(container.querySelector("p strong")?.textContent).to.equal("little"); + expect(container.querySelector("p em")?.textContent).to.equal("you"); + expect(container.querySelectorAll("ul li")).to.have.length(2); + expect(container.querySelectorAll("ol li")).to.have.length(1); + expect(container.querySelector("a")?.getAttribute("href")).to.equal( + "mailto:dpo@example.org" + ); + }); + + it("escapes HTML and drops unsafe links", () => { + const container = rendered( + ' [click](javascript:alert(1))' + ); + + expect(container.querySelector("img")).to.equal(null); + expect(container.querySelector("a")).to.equal(null); + expect(container.textContent).to.contain(" { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("renders links, mail and preference items", async () => { + const config: ComplianceConfig = { + footer: { + items: [ + { + id: "terms", + action: { + type: "link", + href: "https://example.org/terms", + newTab: true, + }, + }, + { + id: "contact", + action: { + type: "mailto", + email: "help@example.org", + subject: "Hi there", + }, + }, + ], + }, + }; + const el = await fixture( + html`` + ); + + expect(item(el, "Terms & Conditions").getAttribute("href")).to.equal( + "https://example.org/terms" + ); + expect(item(el, "Terms & Conditions").getAttribute("target")).to.equal( + "_blank" + ); + expect(item(el, "Contact Us").getAttribute("href")).to.equal( + "mailto:help@example.org?subject=Hi%20there" + ); + expect( + el.shadowRoot?.querySelector("nav")?.getAttribute("aria-label") + ).to.equal("Legal"); + + const requested = sinon.spy(); + window.addEventListener(PREFERENCES_REQUESTED_EVENT, requested); + item(el, "Cookie Preferences").click(); + window.removeEventListener(PREFERENCES_REQUESTED_EVENT, requested); + expect(requested.callCount).to.equal(1); + }); + + it("leaves out items with unsafe targets", async () => { + const el = await fixture( + html`` + ); + + expect(items(el)).to.have.length(1); // cookie preferences + }); + + it("shows a Markdown page in a dialog", async () => { + const el = await fixture( + html`` + ); + + item(el, "Imprint").click(); + await waitUntil(() => dialog(el).open); + + expect(dialog(el).querySelector("h2")?.textContent?.trim()).to.equal( + "Imprint" + ); + expect(dialog(el).querySelector("strong")?.textContent).to.equal("Example"); + dialog(el).close(); + }); + + it("lets apps handle items themselves", async () => { + const el = await fixture( + html` + event.preventDefault()} + >` + ); + + const selected = oneEvent(el, "ecc-compliance-item-selected"); + item(el, "Imprint").click(); + const { detail } = await selected; + await el.updateComplete; + + expect(detail.item.id).to.equal("imprint"); + expect(dialog(el).open).to.equal(false); + }); + + it("loads embeds only with consent", async () => { + const config: ComplianceConfig = { + cookies: COOKIES, + footer: { + items: [ + { + id: "privacy-policy", + action: { + type: "embed", + src: "https://policies.example.org/privacy", + consent: "embeds", + }, + }, + ], + }, + }; + const el = await fixture( + html`` + ); + + item(el, "Privacy Policy").click(); + await waitUntil(() => dialog(el).open); + expect(dialog(el).querySelector("iframe")).to.equal(null); + expect(dialog(el).textContent).to.contain("Embedded content"); + + saveConsent(COOKIES, { embeds: true }); + await el.updateComplete; + const frame = dialog(el).querySelector("iframe"); + expect(frame?.getAttribute("src")).to.equal( + "https://policies.example.org/privacy" + ); + expect(frame?.getAttribute("sandbox")).to.contain("allow-scripts"); + dialog(el).close(); + }); +}); diff --git a/packages/ecc-utils-design/src/components/cookie-consent/consent.ts b/packages/ecc-utils-design/src/components/cookie-consent/consent.ts new file mode 100644 index 00000000..72438a1b --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/consent.ts @@ -0,0 +1,350 @@ +/** + * Cookie consent: configuration, storage and checks, shared by + * `ecc-utils-design-cookie-consent`, `ecc-utils-design-compliance-footer` + * and any other code, e.g. code that loads analytics only with consent. + * Framework independent: everything goes through `localStorage` and + * events on `window`. + */ + +/** A category of cookies (and similar storage) that users consent to. */ +export interface CookieCategory { + /** Stable identifier, e.g. "analytics"; changing it asks users again. */ + id: string; + /** Name shown to users, e.g. "Analytics". */ + label: string; + /** What the category is used for, shown in the preferences. */ + description?: string; + /** Strictly necessary: always on, can't be switched off. */ + required?: boolean; +} + +/** Texts of the banner and the preferences. */ +export interface CookieConsentTexts { + bannerTitle: string; + bannerText: string; + policyLink: string; + acceptAll: string; + rejectAll: string; + customize: string; + preferencesTitle: string; + preferencesText: string; + save: string; + close: string; + alwaysOn: string; + /** Shown instead of an embed without consent; `{category}` is replaced. */ + embedConsent: string; +} + +/** Configuration of the cookie consent. */ +export interface CookieConsentConfig { + /** + * Version of the cookie policy; changing it asks users again, as adding, + * removing or renaming categories does. + */ + version?: string; + /** `localStorage` key of the stored consent. */ + storageKey?: string; + /** + * Categories; a required "necessary" category is added if none is + * required. Without optional categories, the banner is shown only if + * `notice` is set, as nothing needs consent. + */ + categories?: CookieCategory[]; + /** Show the banner even if there are no optional categories. */ + notice?: boolean; + /** Texts; each has an English default. */ + texts?: Partial; + /** Link to the cookie policy, shown in the banner and preferences. */ + policyUrl?: string; +} + +/** Stored consent. */ +export interface CookieConsent { + /** Consent per category ID. */ + categories: Record; + /** Identifies the configuration that the consent was given for. */ + signature: string; + /** When the consent was given (ISO 8601). */ + updatedAt: string; +} + +/** What a footer item does when it is selected. */ +export type FooterItemAction = + /** Follow a link (`http(s)` or relative). */ + | { type: "link"; href: string; newTab?: boolean } + /** Open the mail client. */ + | { type: "mailto"; email: string; subject?: string } + /** Open the cookie preferences. */ + | { type: "preferences" } + /** Show a page from Markdown (headings, paragraphs, lists, links, bold). */ + | { type: "page"; markdown: string; title?: string } + /** + * Show an embedded document (`https` URL) in a frame, e.g. a policy + * hosted by Iubenda, Termly or GetTerms. With `consent`, the frame loads + * only once users consented to that category. + */ + | { type: "embed"; src: string; title?: string; consent?: string }; + +/** An item of the compliance footer. */ +export interface FooterItem { + /** Stable identifier, e.g. "privacy-policy". */ + id: string; + /** Text of the item. */ + label: string; + /** What the item does; items without an action are not shown. */ + action?: FooterItemAction; +} + +/** An item in the configuration: overrides the default with its ID. */ +export interface FooterItemConfig extends Partial { + id: string; + /** Removes the (default) item. */ + hidden?: boolean; +} + +/** Configuration of the compliance footer. */ +export interface ComplianceFooterConfig { + /** + * Items: entries with the ID of a default item change or hide it, others + * are added after the defaults, in their order. + */ + items?: FooterItemConfig[]; + /** Use only the configured items, not the defaults. */ + replaceDefaults?: boolean; + /** Accessible name of the footer's navigation. */ + label?: string; +} + +/** Shared configuration of the compliance components. */ +export interface ComplianceConfig { + cookies?: CookieConsentConfig; + footer?: ComplianceFooterConfig; +} + +/** Name of the `window` event fired when the consent was saved. */ +export const CONSENT_CHANGED_EVENT = "ecc-cookie-consent-changed"; + +/** Name of the `window` event that asks to open the cookie preferences. */ +export const PREFERENCES_REQUESTED_EVENT = "ecc-cookie-preferences-requested"; + +/** Default `localStorage` key. */ +export const DEFAULT_STORAGE_KEY = "ecc-cookie-consent"; + +/** The default required category. */ +export const NECESSARY_CATEGORY: CookieCategory = { + id: "necessary", + label: "Necessary", + description: + "Needed for the site to work, e.g. to keep you logged in and to remember these choices.", + required: true, +}; + +/** Default texts. */ +export const DEFAULT_COOKIE_TEXTS: CookieConsentTexts = { + bannerTitle: "Cookies", + bannerText: + "We use cookies and similar technologies. Some are necessary for this site to work; others are used only with your consent.", + policyLink: "Cookie Policy", + acceptAll: "Accept all", + rejectAll: "Necessary only", + customize: "Customize", + preferencesTitle: "Cookie preferences", + preferencesText: + "Choose which cookies we may use. You can change your choice at any time.", + save: "Save choices", + close: "Close", + alwaysOn: "Always on", + embedConsent: + "This content is shown only with your consent to {category} cookies.", +}; + +/** Default footer items; those without an action are shown once configured. */ +export const DEFAULT_FOOTER_ITEMS: readonly FooterItem[] = Object.freeze([ + { id: "privacy-policy", label: "Privacy Policy" }, + { id: "terms", label: "Terms & Conditions" }, + { id: "cookie-policy", label: "Cookie Policy" }, + { + id: "cookie-preferences", + label: "Cookie Preferences", + action: { type: "preferences" }, + }, + { id: "imprint", label: "Imprint" }, + { id: "accessibility", label: "Accessibility Statement" }, + { id: "contact", label: "Contact Us" }, + { id: "privacy-choices", label: "Your Privacy Choices" }, +]); + +/** + * The categories of a configuration, with a required one first. + * @param config Cookie consent configuration + * @returns The categories + */ +export function cookieCategories( + config?: CookieConsentConfig +): CookieCategory[] { + const categories = (config?.categories ?? []).filter( + (category) => category && typeof category.id === "string" && category.id + ); + return categories.some((category) => category.required) + ? categories + : [NECESSARY_CATEGORY, ...categories]; +} + +/** + * Identifies a configuration for stored consent: its version and category + * IDs, so that consent is asked again when they change. + * @param config Cookie consent configuration + * @returns The signature + */ +export function consentSignature(config?: CookieConsentConfig): string { + const ids = cookieCategories(config) + .map((category) => category.id) + .sort(); + return JSON.stringify([config?.version ?? "", ids]); +} + +const storageKey = (config?: CookieConsentConfig) => + config?.storageKey || DEFAULT_STORAGE_KEY; + +// consent of this page if `localStorage` can't be used, e.g. if blocked +const memory = new Map(); + +function readItem(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return memory.get(key) ?? null; + } +} + +function writeItem(key: string, value: string): void { + memory.set(key, value); + try { + window.localStorage.setItem(key, value); + } catch { + // kept in memory for this page + } +} + +/** + * The stored consent, if it was given for this configuration. + * @param config Cookie consent configuration + * @returns The consent, or `null` if users must be asked (again) + */ +export function readConsent( + config?: CookieConsentConfig +): CookieConsent | null { + const raw = readItem(storageKey(config)); + if (!raw) return null; + let stored: unknown; + try { + stored = JSON.parse(raw); + } catch { + return null; + } + if (!stored || typeof stored !== "object") return null; + const consent = stored as Partial; + if (consent.signature !== consentSignature(config)) return null; + if (!consent.categories || typeof consent.categories !== "object") { + return null; + } + const categories: Record = {}; + cookieCategories(config).forEach((category) => { + categories[category.id] = + !!category.required || consent.categories?.[category.id] === true; + }); + return { + categories, + signature: consent.signature, + updatedAt: String(consent.updatedAt ?? ""), + }; +} + +/** + * Whether users consented to a category. Required categories always are; + * unknown ones and those without a stored consent never are. + * @param category Category ID, e.g. "analytics" + * @param config Cookie consent configuration + * @returns Whether code may use the category + */ +export function hasConsent( + category: string, + config?: CookieConsentConfig +): boolean { + const known = cookieCategories(config).find((c) => c.id === category); + if (!known) return false; + if (known.required) return true; + return readConsent(config)?.categories[category] === true; +} + +/** + * Stores the consent and fires `ecc-cookie-consent-changed` on `window`. + * @param config Cookie consent configuration + * @param choices Consent per category ID; required ones are always on + * @returns The stored consent + */ +export function saveConsent( + config: CookieConsentConfig | undefined, + choices: Record +): CookieConsent { + const categories: Record = {}; + cookieCategories(config).forEach((category) => { + categories[category.id] = + !!category.required || choices[category.id] === true; + }); + const consent: CookieConsent = { + categories, + signature: consentSignature(config), + updatedAt: new Date().toISOString(), + }; + writeItem(storageKey(config), JSON.stringify(consent)); + window.dispatchEvent( + new CustomEvent(CONSENT_CHANGED_EVENT, { detail: { consent } }) + ); + return consent; +} + +/** + * Calls `listener` whenever consent is saved, in this page. + * @param listener Called with the new consent + * @returns A function that removes the listener + */ +export function onConsentChange( + listener: (consent: CookieConsent) => void +): () => void { + const handler = (event: Event) => { + const { detail } = event as CustomEvent<{ consent: CookieConsent }>; + if (detail?.consent) listener(detail.consent); + }; + window.addEventListener(CONSENT_CHANGED_EVENT, handler); + return () => window.removeEventListener(CONSENT_CHANGED_EVENT, handler); +} + +/** Asks the page's cookie consent component to open the preferences. */ +export function openCookiePreferences(): void { + window.dispatchEvent(new CustomEvent(PREFERENCES_REQUESTED_EVENT)); +} + +/** + * The footer items of a configuration: the defaults, changed, hidden or + * extended by the configured items, without those that have no valid + * action. + * @param config Footer configuration + * @returns The items to show + */ +export function footerItems(config?: ComplianceFooterConfig): FooterItem[] { + const configured = (config?.items ?? []).filter( + (item) => item && typeof item.id === "string" && item.id + ); + const byId = new Map(configured.map((item) => [item.id, item])); + const defaults = config?.replaceDefaults ? [] : DEFAULT_FOOTER_ITEMS; + const defaultIds = new Set(defaults.map((item) => item.id)); + const merged: (FooterItemConfig | FooterItem)[] = [ + ...defaults.map((item) => ({ ...item, ...byId.get(item.id) })), + ...configured.filter((item) => !defaultIds.has(item.id)), + ]; + return merged + .filter((item) => !(item as FooterItemConfig).hidden) + .filter((item): item is FooterItem => !!item.label && !!item.action) + .map(({ id, label, action }) => ({ id, label, action })); +} diff --git a/packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts b/packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts new file mode 100644 index 00000000..7872de40 --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/cookie-consent.ts @@ -0,0 +1,264 @@ +import { LitElement, html, css, nothing } from "lit"; +import { property, query, state } from "lit/decorators.js"; +import { ComponentStyles as TailwindStyles } from "./tw-styles.js"; +import { GlobalStyles } from "../../global.js"; +import { + ComplianceConfig, + CookieCategory, + CookieConsent, + DEFAULT_COOKIE_TEXTS, + PREFERENCES_REQUESTED_EVENT, + cookieCategories, + readConsent, + saveConsent, +} from "./consent.js"; +import { safeHref } from "./urls.js"; +import "../button/index.js"; + +/** + * @summary Cookie consent banner and preferences, configured by the shared + * compliance configuration. + * @since 2.0.0 + * + * @property {ComplianceConfig} config - Shared compliance configuration; the component uses `config.cookies`. + * + * @fires ecc-cookie-consent-changed - Fired when the user saved their choices (detail: `{ consent }`). The event doesn't bubble; the same event fires on `window` for any code on the page. + * + * @csspart banner - The banner. + * @csspart dialog - The preferences dialog. + */ +export class EccUtilsDesignCookieConsent extends LitElement { + static styles = [ + css` + ${TailwindStyles} + `, + css` + ${GlobalStyles} + `, + css` + :host { + display: contents; + } + dialog::backdrop { + background: rgb(0 0 0 / 0.5); + } + `, + ]; + + @property({ attribute: false }) config: ComplianceConfig = {}; + + /** Whether the banner is shown. */ + @state() private bannerOpen = false; + + /** Choices in the preferences, per category ID. */ + @state() private choices: Record = {}; + + @query("dialog") private dialog?: HTMLDialogElement; + + private readonly onPreferencesRequested = () => this.openPreferences(); + + connectedCallback(): void { + super.connectedCallback(); + window.addEventListener( + PREFERENCES_REQUESTED_EVENT, + this.onPreferencesRequested + ); + } + + disconnectedCallback(): void { + window.removeEventListener( + PREFERENCES_REQUESTED_EVENT, + this.onPreferencesRequested + ); + super.disconnectedCallback(); + } + + protected willUpdate(changed: Map): void { + if (changed.has("config")) { + const consent = readConsent(this.config?.cookies); + const optional = this.categories.some((category) => !category.required); + this.bannerOpen = + !consent && (optional || !!this.config?.cookies?.notice); + this.choices = this.currentChoices(consent); + } + } + + /** Opens the preferences, e.g. from a "Cookie Preferences" link. */ + async openPreferences(): Promise { + this.choices = this.currentChoices(readConsent(this.config?.cookies)); + await this.updateComplete; + if (this.dialog && !this.dialog.open) this.dialog.showModal(); + } + + private get categories(): CookieCategory[] { + return cookieCategories(this.config?.cookies); + } + + private get texts() { + return { ...DEFAULT_COOKIE_TEXTS, ...this.config?.cookies?.texts }; + } + + private currentChoices(consent: CookieConsent | null) { + const choices: Record = {}; + this.categories.forEach((category) => { + choices[category.id] = + !!category.required || consent?.categories[category.id] === true; + }); + return choices; + } + + private save(choices: Record): void { + const consent = saveConsent(this.config?.cookies, choices); + this.choices = this.currentChoices(consent); + this.bannerOpen = false; + if (this.dialog?.open) this.dialog.close(); + this.dispatchEvent( + // not bubbling: `saveConsent()` fires the same event on `window` + new CustomEvent("ecc-cookie-consent-changed", { detail: { consent } }) + ); + } + + private all(on: boolean): Record { + const choices: Record = {}; + this.categories.forEach((category) => { + choices[category.id] = on || !!category.required; + }); + return choices; + } + + private toggle(id: string, event: Event): void { + const { checked } = event.target as HTMLInputElement; + this.choices = { ...this.choices, [id]: checked }; + } + + private renderPolicyLink() { + const href = safeHref(this.config?.cookies?.policyUrl); + if (!href) return nothing; + return html`${this.texts.policyLink}`; + } + + private renderBanner() { + if (!this.bannerOpen) return nothing; + const { texts } = this; + return html` +
    +
    +
    + +

    ${texts.bannerText} ${this.renderPolicyLink()}

    +
    +
    + this.openPreferences()} + >${texts.customize} + this.save(this.all(false))} + >${texts.rejectAll} + this.save(this.all(true))} + >${texts.acceptAll} +
    +
    +
    + `; + } + + private renderCategory(category: CookieCategory) { + const inputId = `ecc-cookie-category-${category.id}`; + return html` +
  • + this.toggle(category.id, event)} + /> + +
  • + `; + } + + private renderDialog() { + const { texts } = this; + return html` + + +

    + ${texts.preferencesText} ${this.renderPolicyLink()} +

    +
      + ${this.categories.map((category) => this.renderCategory(category))} +
    +
    + this.dialog?.close()} + >${texts.close} + this.save(this.all(false))} + >${texts.rejectAll} + this.save(this.all(true))} + >${texts.acceptAll} + this.save(this.choices)} + >${texts.save} +
    +
    + `; + } + + render() { + return html`${this.renderBanner()}${this.renderDialog()}`; + } +} + +export default EccUtilsDesignCookieConsent; diff --git a/packages/ecc-utils-design/src/components/cookie-consent/index.ts b/packages/ecc-utils-design/src/components/cookie-consent/index.ts new file mode 100644 index 00000000..335062bd --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/index.ts @@ -0,0 +1,24 @@ +import EccUtilsDesignCookieConsent from "./cookie-consent.js"; + +export * from "./cookie-consent.js"; +export * from "./consent.js"; +export * from "./urls.js"; +export default EccUtilsDesignCookieConsent; + +// Define the custom element +if ( + typeof window !== "undefined" && + window.customElements && + !window.customElements.get("ecc-utils-design-cookie-consent") +) { + window.customElements.define( + "ecc-utils-design-cookie-consent", + EccUtilsDesignCookieConsent + ); +} + +declare global { + interface HTMLElementTagNameMap { + "ecc-utils-design-cookie-consent": EccUtilsDesignCookieConsent; + } +} diff --git a/packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts b/packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts new file mode 100644 index 00000000..39c3f4b7 --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/tests/cookie-consent.test.ts @@ -0,0 +1,275 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { expect, fixture, html, oneEvent, waitUntil } from "@open-wc/testing"; +import sinon from "sinon"; +import "../index.js"; +import type EccUtilsDesignCookieConsent from "../cookie-consent.js"; +import { + CONSENT_CHANGED_EVENT, + ComplianceConfig, + CookieConsentConfig, + DEFAULT_STORAGE_KEY, + consentSignature, + cookieCategories, + hasConsent, + onConsentChange, + openCookiePreferences, + readConsent, + saveConsent, +} from "../consent.js"; + +const COOKIES: CookieConsentConfig = { + version: "1", + categories: [ + { id: "necessary", label: "Necessary", required: true }, + { id: "analytics", label: "Analytics", description: "Usage statistics" }, + ], +}; +const CONFIG: ComplianceConfig = { cookies: COOKIES }; + +const banner = (el: EccUtilsDesignCookieConsent) => + el.shadowRoot?.querySelector("[part=banner]") ?? null; + +const dialog = (el: EccUtilsDesignCookieConsent) => + el.shadowRoot?.querySelector("dialog") as HTMLDialogElement; + +const button = (root: ParentNode, action: string) => + root.querySelector(`[data-action=${action}]`) as HTMLElement; + +describe("cookie consent store", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("adds a required category if none is", () => { + const categories = cookieCategories({ + categories: [{ id: "analytics", label: "Analytics" }], + }); + + expect(categories.map((c) => c.id)).to.deep.equal([ + "necessary", + "analytics", + ]); + expect(categories[0].required).to.equal(true); + }); + + it("has no consent before users chose", () => { + expect(readConsent(COOKIES)).to.equal(null); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + expect(hasConsent("necessary", COOKIES)).to.equal(true); + }); + + it("stores consent and tells listeners", () => { + const listener = sinon.spy(); + const stop = onConsentChange(listener); + + saveConsent(COOKIES, { analytics: true, unknown: true, necessary: false }); + stop(); + saveConsent(COOKIES, { analytics: false }); + + expect(listener.callCount).to.equal(1); + expect(listener.firstCall.args[0].categories).to.deep.equal({ + necessary: true, + analytics: true, + }); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + expect(hasConsent("unknown", COOKIES)).to.equal(false); + }); + + it("asks again when the categories or the version change", () => { + saveConsent(COOKIES, { analytics: true }); + + expect(readConsent(COOKIES)).not.to.equal(null); + expect(readConsent({ ...COOKIES, version: "2" })).to.equal(null); + expect( + readConsent({ + ...COOKIES, + categories: [ + ...(COOKIES.categories ?? []), + { id: "ads", label: "Ads" }, + ], + }) + ).to.equal(null); + // texts don't count + expect( + readConsent({ ...COOKIES, texts: { bannerTitle: "Cookies!" } }) + ).not.to.equal(null); + }); + + it("ignores stored values it can't use", () => { + window.localStorage.setItem(DEFAULT_STORAGE_KEY, "not JSON"); + expect(readConsent(COOKIES)).to.equal(null); + + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ signature: consentSignature(COOKIES), categories: 1 }) + ); + expect(readConsent(COOKIES)).to.equal(null); + + window.localStorage.setItem( + DEFAULT_STORAGE_KEY, + JSON.stringify({ + signature: consentSignature(COOKIES), + categories: { analytics: "yes" }, + }) + ); + expect(hasConsent("analytics", COOKIES)).to.equal(false); + }); + + it("uses the configured storage key", () => { + saveConsent({ ...COOKIES, storageKey: "my-consent" }, { analytics: true }); + + expect(window.localStorage.getItem("my-consent")).to.contain("analytics"); + expect(window.localStorage.getItem(DEFAULT_STORAGE_KEY)).to.equal(null); + }); + + it("keeps consent for the page if storage fails", () => { + sinon.stub(Storage.prototype, "setItem").throws(new Error("blocked")); + sinon.stub(Storage.prototype, "getItem").throws(new Error("blocked")); + try { + saveConsent({ ...COOKIES, storageKey: "blocked" }, { analytics: true }); + + expect( + hasConsent("analytics", { ...COOKIES, storageKey: "blocked" }) + ).to.equal(true); + } finally { + sinon.restore(); + } + }); +}); + +describe("ecc-utils-design-cookie-consent", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("shows the banner until users chose", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)).not.to.equal(null); + + const changed = oneEvent(el, "ecc-cookie-consent-changed"); + button(banner(el) as Element, "accept").click(); + const { detail } = await changed; + await el.updateComplete; + + expect(detail.consent.categories.analytics).to.equal(true); + expect(banner(el)).to.equal(null); + expect(hasConsent("analytics", COOKIES)).to.equal(true); + }); + + it("lets users reject optional cookies as easily", async () => { + const el = await fixture( + html`` + ); + const onWindow = sinon.spy(); + window.addEventListener(CONSENT_CHANGED_EVENT, onWindow); + + button(banner(el) as Element, "reject").click(); + await el.updateComplete; + window.removeEventListener(CONSENT_CHANGED_EVENT, onWindow); + + expect(hasConsent("analytics", COOKIES)).to.equal(false); + expect(readConsent(COOKIES)).not.to.equal(null); + expect(onWindow.callCount).to.equal(1); + expect(banner(el)).to.equal(null); + }); + + it("doesn't show the banner after a choice", async () => { + saveConsent(COOKIES, { analytics: false }); + const el = await fixture( + html`` + ); + + expect(banner(el)).to.equal(null); + }); + + it("needs no banner without optional categories", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)).to.equal(null); + + el.config = { cookies: { notice: true } }; + await el.updateComplete; + expect(banner(el)).not.to.equal(null); + }); + + it("saves the choices of the preferences", async () => { + const el = await fixture( + html`` + ); + + openCookiePreferences(); + await waitUntil(() => dialog(el).open); + const boxes = dialog(el).querySelectorAll( + "input[type=checkbox]" + ); + expect(boxes).to.have.length(2); + expect(boxes[0].disabled).to.equal(true); + expect(boxes[0].checked).to.equal(true); + boxes[1].click(); + await el.updateComplete; + button(dialog(el), "save").click(); + await el.updateComplete; + + expect(dialog(el).open).to.equal(false); + expect(hasConsent("analytics", COOKIES)).to.equal(true); + expect(banner(el)).to.equal(null); + + // reopened, it shows the stored choice + await el.openPreferences(); + const reopened = dialog(el).querySelectorAll( + "input[type=checkbox]" + ); + expect(reopened[1].checked).to.equal(true); + dialog(el).close(); + }); + + it("links only safe policy URLs", async () => { + const el = await fixture( + html`` + ); + expect(banner(el)?.querySelector("a")).to.equal(null); + + el.config = { cookies: { ...COOKIES, policyUrl: "/cookies" } }; + await el.updateComplete; + expect(banner(el)?.querySelector("a")?.getAttribute("href")).to.equal( + "/cookies" + ); + }); + + it("uses configured texts", async () => { + const el = await fixture( + html`` + ); + + expect(button(banner(el) as Element, "accept").textContent).to.contain( + "Alle" + ); + }); +}); diff --git a/packages/ecc-utils-design/src/components/cookie-consent/urls.ts b/packages/ecc-utils-design/src/components/cookie-consent/urls.ts new file mode 100644 index 00000000..49c69fc4 --- /dev/null +++ b/packages/ecc-utils-design/src/components/cookie-consent/urls.ts @@ -0,0 +1,65 @@ +/** + * Checks of configured URLs, so that configuration can't run scripts + * (e.g. `javascript:` links). + */ + +const SAFE_LINK_PROTOCOLS = ["http:", "https:", "mailto:"]; + +/** + * The URL if it is safe as a link: `http(s)`, `mailto` or relative. + * @param url URL from the configuration or a Markdown page + * @returns The URL, or `undefined` if it isn't safe + */ +export function safeHref(url: unknown): string | undefined { + if (typeof url !== "string") return undefined; + const trimmed = url.trim(); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed, window.location.href); + return SAFE_LINK_PROTOCOLS.includes(parsed.protocol) ? trimmed : undefined; + } catch { + return undefined; + } +} + +/** + * The URL if it may be embedded in a frame: absolute `https`, or relative + * on a page loaded over `https` or from localhost. + * @param url URL from the configuration + * @returns The URL, or `undefined` if it isn't allowed + */ +export function safeEmbedSrc(url: unknown): string | undefined { + if (typeof url !== "string") return undefined; + const trimmed = url.trim(); + if (!trimmed) return undefined; + try { + const parsed = new URL(trimmed, window.location.href); + const local = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname); + return parsed.protocol === "https:" || + (parsed.protocol === "http:" && local) + ? parsed.href + : undefined; + } catch { + return undefined; + } +} + +/** + * A `mailto:` link for an address, or `undefined` if it isn't one. + * @param email E-mail address + * @param subject Optional subject + * @returns The link + */ +export function mailtoHref( + email: unknown, + subject?: unknown +): string | undefined { + if (typeof email !== "string" || !/^[^\s@?&#/]+@[^\s@?&#/]+$/.test(email)) { + return undefined; + } + const query = + typeof subject === "string" && subject + ? `?subject=${encodeURIComponent(subject)}` + : ""; + return `mailto:${email}${query}`; +} diff --git a/packages/ecc-utils-design/src/components/index.ts b/packages/ecc-utils-design/src/components/index.ts index c7d74c1c..380fd2d4 100644 --- a/packages/ecc-utils-design/src/components/index.ts +++ b/packages/ecc-utils-design/src/components/index.ts @@ -14,3 +14,6 @@ export { default as EccUtilsDesignCard } from "./card/index.js"; export { default as EccUtilsDesignSeparator } from "./separator/index.js"; export { default as EccUtilsDesignCode } from "./code/index.js"; export { default as EccUtilsDesignTextarea } from "./textarea/index.js"; +export { default as EccUtilsDesignCookieConsent } from "./cookie-consent/index.js"; +export { default as EccUtilsDesignComplianceFooter } from "./compliance-footer/index.js"; +export * from "./cookie-consent/consent.js"; diff --git a/packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts b/packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts new file mode 100644 index 00000000..e0e98b0b --- /dev/null +++ b/packages/ecc-utils-design/src/events/ecc-compliance-item-selected.ts @@ -0,0 +1,12 @@ +import type { FooterItem } from "../components/cookie-consent/consent.js"; + +export type EccComplianceItemSelectedEvent = CustomEvent<{ + /** The selected item. */ + item: FooterItem; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "ecc-compliance-item-selected": EccComplianceItemSelectedEvent; + } +} diff --git a/packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts b/packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts new file mode 100644 index 00000000..f0e9db2f --- /dev/null +++ b/packages/ecc-utils-design/src/events/ecc-cookie-consent-changed.ts @@ -0,0 +1,12 @@ +import type { CookieConsent } from "../components/cookie-consent/consent.js"; + +export type EccCookieConsentChangedEvent = CustomEvent<{ + /** The saved consent. */ + consent: CookieConsent; +}>; + +declare global { + interface GlobalEventHandlersEventMap { + "ecc-cookie-consent-changed": EccCookieConsentChangedEvent; + } +} diff --git a/packages/ecc-utils-design/src/events/index.ts b/packages/ecc-utils-design/src/events/index.ts index 8ed63d3d..19c622a6 100644 --- a/packages/ecc-utils-design/src/events/index.ts +++ b/packages/ecc-utils-design/src/events/index.ts @@ -4,3 +4,5 @@ export type { EccCollapsibleToggledEvent } from "./ecc-collapsible-toggled.js"; export type { EccPaginationChangedEvent } from "./ecc-pagination-changed.js"; export type { EccButtonClickedEvent } from "./ecc-button-clicked.js"; export type { EccInputChangedEvent } from "./ecc-input-changed.js"; +export type { EccCookieConsentChangedEvent } from "./ecc-cookie-consent-changed.js"; +export type { EccComplianceItemSelectedEvent } from "./ecc-compliance-item-selected.js"; diff --git a/web-test.runner-config.mjs b/web-test.runner-config.mjs index 02392d6c..7e6d5667 100644 --- a/web-test.runner-config.mjs +++ b/web-test.runner-config.mjs @@ -19,6 +19,9 @@ export default { esbuildPlugin({ ts: true, target: "es2020", + // Lit decorators need the package's experimentalDecorators setting + // (relative to the working directory: run tests from the package) + tsconfig: "./tsconfig.json", }), ], browsers: [ From b42e8d8c469a3869a63a5f339ded27babca678a3 Mon Sep 17 00:00:00 2001 From: Alex Kanitz Date: Thu, 29 Oct 2026 01:26:45 +0100 Subject: [PATCH 2/4] fix(design): harden consent storage and embeds Review fixes for the compliance components: - Consent signature includes whether a category is required, so a category that becomes optional asks users again. - Consent that can't be written takes precedence over an older stored choice, which is removed, so revoking works with full storage. - onConsentChange() takes the config and also reports changes from other tabs (storage events); both components follow them. - The cookie consent keeps unsaved choices when an equal config object is passed (e.g. on every React render). - mailto items accept only plain addresses (no percent-encoding). - Same-origin embeds get no allow-same-origin in their sandbox. - Markdown links open a new tab only for other origins. - Footer dialog texts (texts.close); listeners removed on disconnect; URL tests; docs on consent-less embeds and Cookie Preferences. Refs #458 Co-Authored-By: Claude Opus 5.5 --- .../design/components/compliance-footer.mdx | 11 +- .../docs/design/components/cookie-consent.mdx | 12 +- .../compliance-footer/compliance-footer.ts | 56 +++++--- .../components/compliance-footer/markdown.ts | 40 +++--- .../tests/compliance-footer.test.ts | 50 +++++++ .../src/components/cookie-consent/consent.ts | 70 ++++++--- .../cookie-consent/cookie-consent.ts | 51 +++++-- .../tests/cookie-consent.test.ts | 133 ++++++++++++++++++ .../cookie-consent/tests/urls.test.ts | 59 ++++++++ .../src/components/cookie-consent/urls.ts | 35 ++++- 10 files changed, 438 insertions(+), 79 deletions(-) create mode 100644 packages/ecc-utils-design/src/components/cookie-consent/tests/urls.test.ts diff --git a/apps/documentation/pages/docs/design/components/compliance-footer.mdx b/apps/documentation/pages/docs/design/components/compliance-footer.mdx index 6e76e39d..3a3ed617 100644 --- a/apps/documentation/pages/docs/design/components/compliance-footer.mdx +++ b/apps/documentation/pages/docs/design/components/compliance-footer.mdx @@ -19,7 +19,7 @@ Each item has an action: | Action | Configuration | Result | |---------------|-------------------------------------------------------|------------------------------------------------------------| | `link` | `{ type: 'link', href, newTab? }` | A link (`http(s)` or relative). | -| `mailto` | `{ type: 'mailto', email, subject? }` | Opens the mail client. | +| `mailto` | `{ type: 'mailto', email, subject? }` | Opens the mail client; `email` is a plain address (letters, digits, `.`, `_`, `+`, `-`). | | `preferences` | `{ type: 'preferences' }` | Opens the cookie preferences. | | `page` | `{ type: 'page', markdown, title? }` | Shows a page from Markdown in a dialog. | | `embed` | `{ type: 'embed', src, title?, consent? }` | Shows an `https` page in a frame in a dialog, e.g. a policy hosted by Iubenda, Termly or GetTerms; with `consent`, only once users consented to that cookie category. | @@ -99,6 +99,7 @@ Each item has an action: | `items` | `FooterItemConfig[]` | `{ id, label?, action?, hidden? }`. An entry with the `id` of a default item changes it (`label`, `action`) or removes it (`hidden: true`); other entries are added after the defaults, in their order. | | `replaceDefaults` | `boolean` | Use only the configured items. | | `label` | `string` | Accessible name of the footer's navigation, default "Legal". | +| `texts` | `{ close?: string }` | Texts of the footer's dialog, e.g. for translations; `close` defaults to `config.cookies.texts.close`, then "Close". | Items without an action, or whose link, address or embed URL isn't valid, aren't shown. @@ -115,15 +116,15 @@ Items without an action, or whose link, address or embed URL isn't valid, aren't | `contact` | Contact Us | none | | `privacy-choices` | Your Privacy Choices | none | -So without configuration, the footer shows only "Cookie Preferences"; each item that the operator gives an action appears, in this order. +So without configuration, the footer shows only "Cookie Preferences"; each item that the operator gives an action appears, in this order. "Cookie Preferences" opens the preferences of the [cookie consent](/docs/design/components/cookie-consent) component, so add that component to the page too, or hide the item (`{ id: 'cookie-preferences', hidden: true }`). ### Markdown pages -`page` items support a small subset of Markdown: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, `mailto` and relative URLs). All text is escaped; HTML in the Markdown is shown as text, not rendered. +`page` items support a small subset of Markdown: headings (`#` to `###`), paragraphs, lists (`-`, `*`, `1.`), `**bold**`, `*emphasis*` and links (`[text](url)`, only `http(s)`, `mailto` and relative URLs; links to other origins open in a new tab). All text is escaped; HTML in the Markdown is shown as text, not rendered. ### Embeds -- Only `https` URLs (and `http` on `localhost`) are embedded. The frame is sandboxed (`allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`) and sends no referrer. +- Only `https` URLs (and `http` on `localhost`) are embedded. The frame is sandboxed (`allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox`; without `allow-same-origin` for pages of the app's own origin, which couldn't be isolated otherwise) and sends no referrer. For pages of your own, prefer `page` or `link`. - Allow the embed's origin in the app's Content Security Policy (`frame-src`). -- Embeds whose provider sets cookies should name a cookie category in `consent`; until users consent to it, the dialog shows a note and a button to the preferences instead of the frame. +- An embed loads its frame when users open the item, whether or not they chose their cookies yet. Embeds whose provider sets cookies or tracks visitors (check the provider's documentation) must therefore name a cookie category in `consent`; until users consent to it, the dialog shows a note and a button to the preferences instead of the frame. - Script-based embeds (a `