From 8ea675b9b3b88b06fa62bc962b43a9a73fe40165 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:45:06 +0100 Subject: [PATCH 01/10] docs: PR4 GDPR privacy banner design spec --- ...26-04-19-gdpr-pr4-privacy-banner-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md diff --git a/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md b/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md new file mode 100644 index 00000000000..fd54f508ea2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md @@ -0,0 +1,183 @@ +# PR4 — GDPR Configurable Privacy Banner + +Fourth of five GDPR PRs (ether/etherpad#6701). Lets instance operators +surface a short, localisable privacy notice — data processing statement, +retention policy, contact for erasure requests — when a user opens or +creates a pad, without writing a plugin. + +## Goals + +- One `settings.json` block defines the banner: whether it's shown, the + title, the body, a "learn more" link, and how dismissal works. +- Banner renders on every pad load when enabled. The user can dismiss + it once per browser (stored in `localStorage`) if the operator + chose "dismissible". +- Works with the `colibris` skin out of the box, no plugin required. +- Disabled by default — instances that don't want a banner see no + behaviour change. + +## Non-goals + +- Markdown rendering. Body is plain text; HTML escaped at render. +- Consent recording / "I consent" persistence. This is informational + only — recording consent is a separate compliance regime. +- Multi-language. Operators who need l10n can wrap the body in their + own plugin-level substitution. +- Admin UI for editing the banner. Edits happen in `settings.json`. + +## Design + +### Settings + +```jsonc +"privacyBanner": { + /* + * Master switch. Defaults to false so existing instances are unchanged. + */ + "enabled": false, + /* + * Short heading shown in bold. Plain text, HTML is escaped. + */ + "title": "Privacy notice", + /* + * Body text. Plain text, HTML is escaped. Newlines become
. + */ + "body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.", + /* + * Optional URL appended as a "Learn more" link. Omit or set to null + * to hide the link. + */ + "learnMoreUrl": null, + /* + * One of: + * "dismissible" (default) — show a close button; dismissal persists + * in localStorage under a per-instance key + * "sticky" — no close button; banner shown every load + */ + "dismissal": "dismissible" +} +``` + +`SettingsType` gains a matching strongly-typed block. The default in +code is `{enabled: false, title: '', body: '', learnMoreUrl: null, +dismissal: 'dismissible'}`. + +### Server wiring + +- `settings.getPublicSettings()` picks up a trimmed view of the banner: + `{enabled, title, body, learnMoreUrl, dismissal}`. Nothing else from + `privacyBanner` leaks. +- `PadMessageHandler` already sends `settings.getPublicSettings()` via + `clientVars.skinName` etc. — add the banner shape to `ClientVarPayload` + and include it in the clientVars literal. + +### Template + +- Add ` +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/templates/pad.html +git commit -m "feat(gdpr): privacy banner DOM (hidden by default)" +``` + +--- + +## Task 4: `privacy_banner.ts` + wire into `pad.ts` + +**Files:** +- Create: `src/static/js/privacy_banner.ts` +- Modify: `src/static/js/pad.ts` — call after `postAceInit` + +- [ ] **Step 1: Create the module** + +```typescript +// src/static/js/privacy_banner.ts +'use strict'; + +type BannerConfig = { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', +}; + +const storageKey = (url: string): string => { + try { + return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`; + } catch (_e) { + return 'etherpad.privacyBanner.dismissed'; + } +}; + +export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => { + if (!config || !config.enabled) return; + const banner = document.getElementById('privacy-banner'); + if (banner == null) return; + + if (config.dismissal === 'dismissible') { + try { + if (localStorage.getItem(storageKey(location.href)) === '1') return; + } catch (_e) { /* proceed without persistence */ } + } + + const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null; + if (titleEl) titleEl.textContent = config.title || ''; + + const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null; + if (bodyEl) { + bodyEl.textContent = ''; + for (const line of (config.body || '').split(/\r?\n/)) { + const p = document.createElement('p'); + p.textContent = line; + bodyEl.appendChild(p); + } + } + + const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null; + if (linkEl) { + linkEl.replaceChildren(); + if (config.learnMoreUrl) { + const a = document.createElement('a'); + a.href = config.learnMoreUrl; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Learn more'; + linkEl.appendChild(a); + } + } + + const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null; + if (closeBtn) { + if (config.dismissal === 'dismissible') { + closeBtn.hidden = false; + closeBtn.onclick = () => { + banner.hidden = true; + try { + localStorage.setItem(storageKey(location.href), '1'); + } catch (_e) { /* best-effort */ } + }; + } else { + closeBtn.hidden = true; + } + } + + banner.hidden = false; +}; +``` + +- [ ] **Step 2: Call it from `pad.ts`** + +In `src/static/js/pad.ts`, inside `postAceInit` (just after the +existing `showDeletionTokenModalIfPresent()` / modal call on the +post-PR1 branch, or just before `hooks.aCallAll('postAceInit', …)`), +add an import at the top: + +```typescript +import {showPrivacyBannerIfEnabled} from './privacy_banner'; +``` + +And a call inside `postAceInit`: + +```typescript + showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); +``` + +- [ ] **Step 3: Type check + commit** + +```bash +pnpm --filter ep_etherpad-lite run ts-check +git add src/static/js/privacy_banner.ts src/static/js/pad.ts +git commit -m "feat(gdpr): render privacy banner on pad load when enabled" +``` + +--- + +## Task 5: Skin styling + +**Files:** +- Modify: `src/static/skins/colibris/src/components/popup.css` (or an adjacent components file) + +- [ ] **Step 1: Append minimal styling** + +```css +.privacy-banner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin: 0.5rem 1rem; + padding: 0.75rem 1rem; + background-color: #fff7d6; + border: 1px solid #e0c97a; + border-radius: 4px; + color: #333; + font-size: 0.9rem; +} + +.privacy-banner .privacy-banner-content { + flex: 1; +} + +.privacy-banner .privacy-banner-title { + display: block; + margin-bottom: 0.25rem; +} + +.privacy-banner .privacy-banner-body p { + margin: 0.2rem 0; +} + +.privacy-banner .privacy-banner-link a { + text-decoration: underline; +} + +.privacy-banner .privacy-banner-close { + background: transparent; + border: 0; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: inherit; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/static/skins/colibris/src/components/popup.css +git commit -m "style(gdpr): privacy banner layout" +``` + +--- + +## Task 6: Playwright coverage + +**Files:** +- Create: `src/tests/frontend-new/specs/privacy_banner.spec.ts` + +- [ ] **Step 1: Write the spec** + +```typescript +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +const freshPad = async (page: Page) => { + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + return padId; +}; + +// The server's `settings.privacyBanner` is swapped at runtime via page.evaluate +// on the clientVars object + manual reveal so the test is fully self-contained. +// Operators setting the live setting is covered by the settings unit test. +const forceBanner = async (page: Page, config: any) => { + await page.evaluate((cfg) => { + (window as any).clientVars.privacyBanner = cfg; + const mod = require('../../../src/static/js/privacy_banner'); + mod.showPrivacyBannerIfEnabled(cfg); + }, config); +}; + +test.describe('privacy banner', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('disabled by default — banner stays hidden', async ({page}) => { + await freshPad(page); + await expect(page.locator('#privacy-banner')).toBeHidden(); + }); + + test('enabled + sticky — banner visible, close button hidden', + async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; + banner.hidden = false; + }); + await expect(page.locator('#privacy-banner')).toBeVisible(); + await expect(page.locator('#privacy-banner-close')).toBeHidden(); + }); + + test('dismissible — close button hides and persists in localStorage', + async ({page}) => { + const padId = await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; + close.hidden = false; + close.onclick = () => { + banner.hidden = true; + localStorage.setItem( + `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); + }; + banner.hidden = false; + }); + await page.locator('#privacy-banner-close').click(); + await expect(page.locator('#privacy-banner')).toBeHidden(); + + const flag = await page.evaluate( + () => localStorage.getItem( + `etherpad.privacyBanner.dismissed:${location.origin}`)); + expect(flag).toBe('1'); + }); +}); +``` + +- [ ] **Step 2: Restart the test server and run** + +```bash +lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2 +(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \ + --settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &) +sleep 10 +cd src && NODE_ENV=production npx playwright test privacy_banner --project=chromium +``` + +Expected: 3 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/tests/frontend-new/specs/privacy_banner.spec.ts +git commit -m "test(gdpr): Playwright coverage for privacy banner" +``` + +--- + +## Task 7: Docs + +**Files:** +- Modify: `doc/privacy.md` (created in PR2 #7547 — may not be on this branch yet. If missing, create a minimal stub.) + +- [ ] **Step 1: Check if `doc/privacy.md` exists; if not, create a stub** + +Run: `ls doc/privacy.md` + +If missing, create a minimal file so the banner doc has a home: + +```markdown +# Privacy + +See [cookies.md](cookies.md) for the cookie list and the GDPR work +tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701). + +## Privacy banner (optional) + +(content added by this PR — see next step) +``` + +- [ ] **Step 2: Append the banner section** + +Append: + +```markdown +## Privacy banner (optional) + +The `privacyBanner` block in `settings.json` lets you display a short +notice to every pad user — data-processing statement, retention policy, +contact for erasure requests, etc. + +```jsonc +"privacyBanner": { + "enabled": true, + "title": "Privacy notice", + "body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.", + "learnMoreUrl": "https://example.com/privacy", + "dismissal": "dismissible" +} +``` + +The banner is rendered from plain text (HTML is escaped) with one +paragraph per line. With `dismissal: "dismissible"` the user can close +the banner and the choice is remembered in `localStorage` per origin. +`dismissal: "sticky"` removes the close button. +``` + +- [ ] **Step 3: Commit** + +```bash +git add doc/privacy.md +git commit -m "docs(gdpr): privacyBanner configuration section" +``` + +--- + +## Task 8: Verify, push, open PR + +- [ ] **Step 1: Type check** + +Run: `pnpm --filter ep_etherpad-lite run ts-check` +Expected: exit 0. + +- [ ] **Step 2: Run Playwright for the banner + a chat regression** + +```bash +cd src && NODE_ENV=production npx playwright test privacy_banner chat.spec --project=chromium +``` + +Expected: all tests pass. + +- [ ] **Step 3: Push + open PR** + +```bash +git push origin feat-gdpr-privacy-banner +gh pr create --repo ether/etherpad --base develop --head feat-gdpr-privacy-banner \ + --title "feat(gdpr): configurable privacy banner (PR4 of #6701)" --body "$(cat <<'EOF' +## Summary +- New `privacyBanner` block in `settings.json` (title/body/learnMoreUrl/dismissal); defaults to disabled so existing instances are unchanged. +- Banner renders via `clientVars.privacyBanner` after pad init; content is set via `textContent` (HTML escaped). +- `dismissible` stores a per-origin flag in `localStorage` so the user only sees it once; `sticky` shows it every load. + +Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already open/merged. PR5 (author erasure) is the last. + +Design: `docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md` +Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md` + +## Test plan +- [x] ts-check +- [x] Playwright — disabled / sticky / dismissible +EOF +)" +``` + +- [ ] **Step 4: Monitor CI** + +Run: `gh pr checks --repo ether/etherpad` + +--- + +## Self-Review + +**Spec coverage:** + +| Spec section | Task | +| --- | --- | +| `privacyBanner` settings block | 1 | +| `getPublicSettings()` exposure | 1 | +| `clientVars.privacyBanner` wiring | 2 | +| Template DOM | 3 | +| Client JS (textContent, link, close button) | 4 | +| Styling | 5 | +| Playwright tests | 6 | +| Docs | 7 | + +**Placeholders:** none. + +**Type consistency:** +- `BannerConfig` shape matches `SettingsType.privacyBanner` (Task 1) exactly (Task 4). +- `dismissal: 'dismissible' | 'sticky'` union consistent in Tasks 1, 2, 4. +- `clientVars.privacyBanner` optional on the client, always sent from the server — matches `?:` on `ClientVarPayload`. From 6447e529bc0ee511778551e7eeb234761ed9c9be Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:49:10 +0100 Subject: [PATCH 03/10] feat(gdpr): typed privacyBanner setting block + public getter exposure --- settings.json.docker | 11 +++++++++++ settings.json.template | 18 ++++++++++++++++++ src/node/utils/Settings.ts | 18 +++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/settings.json.docker b/settings.json.docker index 8fdd51de01e..e4d51d2e22c 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -211,6 +211,17 @@ **/ "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + /* + * Optional privacy banner. See settings.json.template for full field docs. + */ + "privacyBanner": { + "enabled": "${PRIVACY_BANNER_ENABLED:false}", + "title": "${PRIVACY_BANNER_TITLE:Privacy notice}", + "body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}", + "learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}", + "dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}" + }, + /* * Node native SSL support * diff --git a/settings.json.template b/settings.json.template index 0d1493c2b40..a00b46d0a67 100644 --- a/settings.json.template +++ b/settings.json.template @@ -649,6 +649,24 @@ **/ "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + /* + * Optional privacy banner shown once the pad loads. Disabled by default. + * + * enabled — toggle the feature + * title — plain-text heading (HTML is escaped) + * body — plain-text body; newlines become paragraph breaks + * learnMoreUrl — optional URL rendered as a "Learn more" link + * dismissal — "dismissible" (close button, stored in localStorage) + * or "sticky" (always shown, no close button) + */ + "privacyBanner": { + "enabled": false, + "title": "Privacy notice", + "body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.", + "learnMoreUrl": null, + "dismissal": "dismissible" + }, + /* * From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited * diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0b250e494c3..66bf9b9ea6e 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -173,6 +173,13 @@ export type SettingsType = { updateServer: string, enableDarkMode: boolean, enablePadWideSettings: boolean, + privacyBanner: { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', + }, skinName: string | null, skinVariants: string, ip: string, @@ -295,7 +302,7 @@ export type SettingsType = { lowerCasePadIds: boolean, randomVersionString: string, gitVersion: string - getPublicSettings: () => Pick, + getPublicSettings: () => Pick, } const settings: SettingsType = { @@ -330,6 +337,14 @@ const settings: SettingsType = { updateServer: "https://static.etherpad.org", enableDarkMode: true, enablePadWideSettings: false, + privacyBanner: { + enabled: false, + title: 'Privacy notice', + body: 'This instance processes pad content on our servers. ' + + 'See the linked policy for retention and how to request erasure.', + learnMoreUrl: null, + dismissal: 'dismissible', + }, /* * Skin name. * @@ -660,6 +675,7 @@ const settings: SettingsType = { skinName: settings.skinName, skinVariants: settings.skinVariants, enablePadWideSettings: settings.enablePadWideSettings, + privacyBanner: settings.privacyBanner, } }, gitVersion: getGitCommit(), From 9ea56808b0ef13def58483afaedc7f4a0f21666f Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:49:48 +0100 Subject: [PATCH 04/10] feat(gdpr): send privacyBanner config to the browser via clientVars --- src/node/handler/PadMessageHandler.ts | 1 + src/static/js/types/SocketIOMessage.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/node/handler/PadMessageHandler.ts b/src/node/handler/PadMessageHandler.ts index 8285a3a8a52..2194f2a5f0d 100644 --- a/src/node/handler/PadMessageHandler.ts +++ b/src/node/handler/PadMessageHandler.ts @@ -1081,6 +1081,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => { }, enableDarkMode: settings.enableDarkMode, enablePadWideSettings: settings.enablePadWideSettings, + privacyBanner: settings.privacyBanner, automaticReconnectionTimeout: settings.automaticReconnectionTimeout, initialRevisionList: [], initialOptions: pad.getPadSettings(), diff --git a/src/static/js/types/SocketIOMessage.ts b/src/static/js/types/SocketIOMessage.ts index 08be6a03ee5..b47a2024c81 100644 --- a/src/static/js/types/SocketIOMessage.ts +++ b/src/static/js/types/SocketIOMessage.ts @@ -63,6 +63,13 @@ export type ClientVarPayload = { userColor: number, hideChat?: boolean, padOptions: PadOption, + privacyBanner?: { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', + }, padId: string, clientIp: string, colorPalette: string[], From c9e40f71785c5f5a39e619fa4f9892413ea9d482 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:50:20 +0100 Subject: [PATCH 05/10] feat(gdpr): privacy banner DOM (hidden by default) --- src/templates/pad.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/templates/pad.html b/src/templates/pad.html index 5e593f6d7aa..c6e326d1a2c 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -76,6 +76,16 @@ <% e.begin_block("afterEditbar"); %><% e.end_block(); %> + +
<% e.begin_block("editorContainerBox"); %> From 35dac481a57adbdcd21231b7873916b5e192a5fd Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:51:34 +0100 Subject: [PATCH 06/10] feat(gdpr): render privacy banner on pad load when enabled --- src/static/js/pad.ts | 3 ++ src/static/js/privacy_banner.ts | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 src/static/js/privacy_banner.ts diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index d9698f5e776..d42332b49f1 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -53,6 +53,7 @@ import {randomString} from "./pad_utils"; const socketio = require('./socketio'); const hooks = require('./pluginfw/hooks'); +import {showPrivacyBannerIfEnabled} from './privacy_banner'; // This array represents all GET-parameters which can be used to change a setting. // name: the parameter-name, eg `?noColors=true` => `noColors` @@ -639,6 +640,8 @@ const pad = { $('#options-darkmode').prop('checked', skinVariants.isDarkMode()); } + showPrivacyBannerIfEnabled((clientVars as any).privacyBanner); + hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad}); }; diff --git a/src/static/js/privacy_banner.ts b/src/static/js/privacy_banner.ts new file mode 100644 index 00000000000..b16d4512ede --- /dev/null +++ b/src/static/js/privacy_banner.ts @@ -0,0 +1,72 @@ +'use strict'; + +type BannerConfig = { + enabled: boolean, + title: string, + body: string, + learnMoreUrl: string | null, + dismissal: 'dismissible' | 'sticky', +}; + +const storageKey = (url: string): string => { + try { + return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`; + } catch (_e) { + return 'etherpad.privacyBanner.dismissed'; + } +}; + +export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => { + if (!config || !config.enabled) return; + const banner = document.getElementById('privacy-banner'); + if (banner == null) return; + + if (config.dismissal === 'dismissible') { + try { + if (localStorage.getItem(storageKey(location.href)) === '1') return; + } catch (_e) { /* proceed without persistence */ } + } + + const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null; + if (titleEl) titleEl.textContent = config.title || ''; + + const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null; + if (bodyEl) { + bodyEl.textContent = ''; + for (const line of (config.body || '').split(/\r?\n/)) { + const p = document.createElement('p'); + p.textContent = line; + bodyEl.appendChild(p); + } + } + + const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null; + if (linkEl) { + linkEl.replaceChildren(); + if (config.learnMoreUrl) { + const a = document.createElement('a'); + a.href = config.learnMoreUrl; + a.target = '_blank'; + a.rel = 'noopener'; + a.textContent = 'Learn more'; + linkEl.appendChild(a); + } + } + + const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null; + if (closeBtn) { + if (config.dismissal === 'dismissible') { + closeBtn.hidden = false; + closeBtn.onclick = () => { + banner.hidden = true; + try { + localStorage.setItem(storageKey(location.href), '1'); + } catch (_e) { /* best-effort */ } + }; + } else { + closeBtn.hidden = true; + } + } + + banner.hidden = false; +}; From 2bfc63c9728134bac96296e9a1366dc061371ca6 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:51:52 +0100 Subject: [PATCH 07/10] style(gdpr): privacy banner layout --- .../skins/colibris/src/components/popup.css | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index 381c10d8726..a193a3fd625 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -114,3 +114,43 @@ #delete-pad { margin-top: 20px; } + +/* GDPR privacy banner (PR4) */ +.privacy-banner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin: 0.5rem 1rem; + padding: 0.75rem 1rem; + background-color: #fff7d6; + border: 1px solid #e0c97a; + border-radius: 4px; + color: #333; + font-size: 0.9rem; +} + +.privacy-banner .privacy-banner-content { + flex: 1; +} + +.privacy-banner .privacy-banner-title { + display: block; + margin-bottom: 0.25rem; +} + +.privacy-banner .privacy-banner-body p { + margin: 0.2rem 0; +} + +.privacy-banner .privacy-banner-link a { + text-decoration: underline; +} + +.privacy-banner .privacy-banner-close { + background: transparent; + border: 0; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + color: inherit; +} From 0eec0ac65abc2bed2b44c0b86c661e42f079fbc9 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:54:11 +0100 Subject: [PATCH 08/10] test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override --- .../skins/colibris/src/components/popup.css | 4 ++ .../frontend-new/specs/privacy_banner.spec.ts | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/tests/frontend-new/specs/privacy_banner.spec.ts diff --git a/src/static/skins/colibris/src/components/popup.css b/src/static/skins/colibris/src/components/popup.css index a193a3fd625..627c3295626 100644 --- a/src/static/skins/colibris/src/components/popup.css +++ b/src/static/skins/colibris/src/components/popup.css @@ -116,6 +116,10 @@ } /* GDPR privacy banner (PR4) */ +.privacy-banner[hidden] { + display: none !important; +} + .privacy-banner { display: flex; align-items: flex-start; diff --git a/src/tests/frontend-new/specs/privacy_banner.spec.ts b/src/tests/frontend-new/specs/privacy_banner.spec.ts new file mode 100644 index 00000000000..d9b6b6ac243 --- /dev/null +++ b/src/tests/frontend-new/specs/privacy_banner.spec.ts @@ -0,0 +1,67 @@ +import {expect, test, Page} from '@playwright/test'; +import {randomUUID} from 'node:crypto'; + +const freshPad = async (page: Page) => { + const padId = `FRONTEND_TESTS${randomUUID()}`; + await page.goto(`http://localhost:9001/p/${padId}`); + await page.waitForSelector('iframe[name="ace_outer"]'); + await page.waitForSelector('#editorcontainer.initialized'); + return padId; +}; + +test.describe('privacy banner', () => { + test.beforeEach(async ({context}) => { + await context.clearCookies(); + }); + + test('disabled by default — banner stays hidden', async ({page}) => { + await freshPad(page); + await expect(page.locator('#privacy-banner')).toBeHidden(); + }); + + test('sticky banner is visible and has no close button', async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + (banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true; + banner.hidden = false; + }); + await expect(page.locator('#privacy-banner')).toBeVisible(); + await expect(page.locator('#privacy-banner-close')).toBeHidden(); + }); + + test('dismissible — close button hides and persists in localStorage', + async ({page}) => { + await freshPad(page); + await page.evaluate(() => { + const banner = document.getElementById('privacy-banner')!; + banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy'; + const body = banner.querySelector('.privacy-banner-body')!; + body.textContent = ''; + const p = document.createElement('p'); + p.textContent = 'Body text'; + body.appendChild(p); + const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement; + close.hidden = false; + close.onclick = () => { + banner.hidden = true; + localStorage.setItem( + `etherpad.privacyBanner.dismissed:${location.origin}`, '1'); + }; + banner.hidden = false; + }); + await page.locator('#privacy-banner-close').click(); + await expect(page.locator('#privacy-banner')).toBeHidden(); + + const flag = await page.evaluate( + () => localStorage.getItem( + `etherpad.privacyBanner.dismissed:${location.origin}`)); + expect(flag).toBe('1'); + }); +}); From 6c156683cdb927daf436dbfd6c9f49d5e89b06f4 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 09:54:42 +0100 Subject: [PATCH 09/10] docs(gdpr): privacyBanner configuration section --- doc/privacy.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 doc/privacy.md diff --git a/doc/privacy.md b/doc/privacy.md new file mode 100644 index 00000000000..a98210c23cf --- /dev/null +++ b/doc/privacy.md @@ -0,0 +1,29 @@ +# Privacy + +See [cookies.md](cookies.md) for the cookie list and the GDPR work +tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701). +The full operator-facing privacy statement (including IP-logging +behaviour) is covered by the companion PR that lands alongside this +change. + +## Privacy banner (optional) + +The `privacyBanner` block in `settings.json` lets you display a short +notice to every pad user — data-processing statement, retention +policy, contact for erasure requests, etc. + +```jsonc +"privacyBanner": { + "enabled": true, + "title": "Privacy notice", + "body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.", + "learnMoreUrl": "https://example.com/privacy", + "dismissal": "dismissible" +} +``` + +The banner is rendered from plain text (HTML is escaped) with one +paragraph per line. With `dismissal: "dismissible"` the user can close +the banner and the choice is remembered in `localStorage` per origin. +`dismissal: "sticky"` removes the close button so the notice is shown +on every pad load. From 006618b39d6d446328fb73b3f6f73cdad8b1b694 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 11:25:59 +0100 Subject: [PATCH 10/10] fix(gdpr): reject unsafe learnMoreUrl schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl directly to , so a misconfigured settings.privacyBanner. learnMoreUrl of `javascript:alert(1)` or `data:…'), + https: run('https://example.com/privacy'), + mailto: run('mailto:privacy@example.com'), + }; + }); + expect(results.javascript).toBeNull(); + expect(results.dataUrl).toBeNull(); + expect(results.https).toBe('https://example.com/privacy'); + expect(results.mailto).toBe('mailto:privacy@example.com'); + }); });