feat(gdpr): configurable privacy banner (PR4 of #6701) (#7549)

* docs: PR4 GDPR privacy banner design spec

* docs: PR4 GDPR privacy banner implementation plan

* feat(gdpr): typed privacyBanner setting block + public getter exposure

* feat(gdpr): send privacyBanner config to the browser via clientVars

* feat(gdpr): privacy banner DOM (hidden by default)

* feat(gdpr): render privacy banner on pad load when enabled

* style(gdpr): privacy banner layout

* test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override

* docs(gdpr): privacyBanner configuration section

* fix(gdpr): reject unsafe learnMoreUrl schemes

Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl
directly to <a href>, so a misconfigured settings.privacyBanner.
learnMoreUrl of `javascript:alert(1)` or `data:…<script>…` would run
script on click. Validate via URL parsing and allow only http(s) /
mailto; everything else yields no link. Playwright regression guards
the four cases (javascript, data, https, mailto).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(privacy-banner): drop unneeded !important on [hidden] rule

Class+attribute selector already outranks `.privacy-banner { display: flex }`
on specificity (0,2,0 vs 0,1,0), so `!important` was redundant. Adds a
comment explaining why so a future reader doesn't put it back.

Per Sam's review on #7549.

* refactor(privacy-banner): render as a persistent gritter, not custom DOM

Drops the bespoke #privacy-banner template + ~50 lines of popup.css and
delegates to $.gritter.add({sticky: true, position: 'bottom'}). The
notice now matches every other gritter on the pad (theme variables,
shadow, animation, (X) close), sits in the bottom corner instead of
above the editor, and inherits dark-mode handling for free.

The two dismissal modes survive intact:
  - dismissible: gritter closes on (X); before_close persists a flag
    in localStorage so the notice is suppressed on subsequent loads.
  - sticky: closes for the current session only; never persists; the
    next pad load shows it again.

learnMoreUrl still goes through the same safeUrl() filter so a
javascript:/data:/vbscript: URL can't smuggle a script handler into the
anchor (Qodo's review concern remains addressed).

Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives
the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__
test hook and asserts against the rendered gritter, instead of the
previous tests that mutated DOM by hand and never exercised the
function under test. Coverage adds: enabled=false short-circuit,
dismissible-flag-respected on subsequent show, sticky-ignores-flag,
sticky-close-does-not-persist, javascript: rejection, data: rejection,
and mailto: allow-list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(privacy-banner): noreferrer + validate dismissal (Qodo)

Two follow-ups from Qodo's review on #7549:

1. The Learn-more link now sets `rel="noreferrer noopener"` (was just
   `noopener`). Without `noreferrer` the browser sends the pad URL as a
   Referer to the operator-configured external policy site, which leaks
   pad identifiers to a third party.  Matches the rel pattern already
   used by pad_utils.ts.

2. `privacyBanner.dismissal` is now validated in reloadSettings(): an
   unknown value falls back to 'dismissible' with a `logger.warn`, in
   the same shape as the existing ipLogging validation a few lines up.
   The client also guards defensively (treats anything other than the
   exact string 'sticky' as 'dismissible') so that hot-reload paths
   that skip the server validator can't silently degrade a typo'd
   'sticky' into "no close button persisted, no localStorage suppression".

Test added: spec asserts the rel attribute, and a new test exercises
the dismissal fallback (sets dismissal:'wat', asserts the gritter is
shown, the (X) closes it, and the dismissal flag is persisted — i.e.
the unknown value is treated like 'dismissible').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior

Two follow-ups from Qodo's second review on #7549.

Rule violation: __etherpad_privacyBanner__ was published on every pad
load even when privacyBanner.enabled was false, so the disabled-by-
default feature still added an observable global. Gate the assignment
on `navigator.webdriver` — Playwright/ChromeDriver/Selenium set this
to true; production browsers do not — so the hook is only present for
tests and the disabled path is genuinely zero-side-effect.

Bug 3 (sticky still closable): doc/privacy.md previously claimed
`dismissal: "sticky"` removes the close button, but the gritter
implementation always renders (X). Aligning the doc with reality —
sticky now means "shows on every load, but closable for the session"
— rather than adding bespoke CSS to a vanilla gritter (matches the
"don't style it differently than other gritter messages" preference
that drove the gritter migration in 906e145).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(privacy-banner): allow-list keys before sending to clientVars (Qodo)

storeSettings() merges nested objects with _.defaults() and preserves
unknown nested keys, and TypeScript's Pick<> doesn't strip at runtime.
The previous wire path forwarded settings.privacyBanner by reference
into both clientVars and getPublicSettings(), so any extra keys an
operator typed (or pasted) under privacyBanner — credentials, internal
notes, anything — would have shipped to every browser on every pad
load.

Adds getPublicPrivacyBanner() in Settings.ts that returns a literal
with only {enabled, title, body, learnMoreUrl, dismissal}, and uses it
from both leak sites (PadMessageHandler.ts clientVars and
getPublicSettings()). Single source of truth for the wire shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-03 13:59:38 +08:00 committed by GitHub
parent a0b85dd8b3
commit 487842006c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1229 additions and 2 deletions

View File

@ -59,3 +59,38 @@ See
[`docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md`](https://github.com/ether/etherpad/blob/develop/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md)
for the deletion-token mechanism. Full author erasure is tracked as a
follow-up in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
## 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 as a persistent gritter notification at the
bottom of the page (it inherits the same look as every other gritter
on the pad — no custom skin needed). The body is plain text (HTML is
escaped); each line becomes its own paragraph.
`dismissal` controls how the close (×) is handled:
- `"dismissible"` (default) — when the user closes the gritter, the
choice is persisted in `localStorage` per origin and the banner is
not shown again on subsequent pad loads.
- `"sticky"` — closing the gritter only hides it for the current
session; the next pad load shows it again. (The close control is
not removed; for an operator-enforced non-closable notice, render
the policy out-of-band — e.g., a skin override or a reverse-proxy
ribbon.)
Unknown `dismissal` values are coerced to `"dismissible"` with a
`logger.warn` at settings load.

View File

@ -0,0 +1,595 @@
# GDPR PR4 — Privacy Banner Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let operators configure a short privacy notice via `settings.json` that shows as a dismissible (or sticky) banner on pad load. Default off, opt-in.
**Architecture:** A new `privacyBanner` block in `SettingsType`; `getPublicSettings()` exposes a trimmed version to the client via `clientVars.privacyBanner`. `pad.html` has a hidden `<div id="privacy-banner">`. A new `privacy_banner.ts` module, called from `pad.ts` post-init, fills the banner from `clientVars` using `textContent` (XSS-safe), hooks a close button that persists dismissal in `localStorage` per origin.
**Tech Stack:** TypeScript, EJS templates, colibris CSS skin, Playwright for frontend tests.
---
## File Structure
**Created:**
- `src/static/js/privacy_banner.ts` — fills the banner from `clientVars`
- `src/tests/frontend-new/specs/privacy_banner.spec.ts` — Playwright coverage
**Modified:**
- `settings.json.template`, `settings.json.docker` — add the `privacyBanner` block
- `src/node/utils/Settings.ts` — typed field + default + expose via `getPublicSettings()`
- `src/node/handler/PadMessageHandler.ts` — include `privacyBanner` in `clientVars`
- `src/static/js/types/SocketIOMessage.ts` — add `privacyBanner` to `ClientVarPayload`
- `src/templates/pad.html` — hidden banner markup
- `src/static/js/pad.ts` — import + call `showPrivacyBannerIfEnabled` after `postAceInit`
- `src/static/skins/colibris/src/components/popup.css` (or appropriate skin file) — styling
- `doc/privacy.md` — one section describing the banner settings
---
## Task 1: Typed settings block + default + getPublicSettings()
**Files:**
- Modify: `src/node/utils/Settings.ts`
- Modify: `settings.json.template`
- Modify: `settings.json.docker`
- [ ] **Step 1: Extend `SettingsType`**
Add to the interface (near `enableDarkMode`):
```typescript
privacyBanner: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
```
- [ ] **Step 2: Extend the default `settings` object**
Add next to `enableDarkMode: true`:
```typescript
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',
},
```
- [ ] **Step 3: Expose via `getPublicSettings()`**
Locate the `getPublicSettings` function (around line 658 in Settings.ts). Add a `privacyBanner` key to both the returned object and the `Pick<>` type right above it:
```typescript
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "privacyBanner">,
```
And in the returned object:
```typescript
privacyBanner: settings.privacyBanner,
```
- [ ] **Step 4: `settings.json.template` block**
Append (near the `enableDarkMode` block):
```jsonc
/*
* 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; blank lines 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"
},
```
- [ ] **Step 5: `settings.json.docker` mirror**
```jsonc
"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}"
},
```
- [ ] **Step 6: Type check + commit**
```bash
pnpm --filter ep_etherpad-lite run ts-check
git add src/node/utils/Settings.ts settings.json.template settings.json.docker
git commit -m "feat(gdpr): typed privacyBanner setting block + public getter exposure"
```
---
## Task 2: Wire `privacyBanner` through `clientVars`
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts`
- Modify: `src/static/js/types/SocketIOMessage.ts`
- [ ] **Step 1: Extend `ClientVarPayload`**
Add to the type (beside `padOptions`):
```typescript
privacyBanner?: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
```
- [ ] **Step 2: Include it in the `clientVars` literal**
In `PadMessageHandler.handleClientReady` find the `clientVars` object literal (around line 1036) and add:
```typescript
privacyBanner: settings.privacyBanner,
```
- [ ] **Step 3: Type check + commit**
```bash
pnpm --filter ep_etherpad-lite run ts-check
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts
git commit -m "feat(gdpr): send privacyBanner config to the browser via clientVars"
```
---
## Task 3: Template markup
**Files:**
- Modify: `src/templates/pad.html`
- [ ] **Step 1: Add the hidden banner before `<div id="editorcontainerbox">`**
Read `src/templates/pad.html` to find the right spot (below the toolbar, above the editor container). Insert:
```html
<div id="privacy-banner" class="privacy-banner" hidden>
<div class="privacy-banner-content">
<strong class="privacy-banner-title"></strong>
<div class="privacy-banner-body"></div>
<div class="privacy-banner-link"></div>
</div>
<button id="privacy-banner-close" type="button"
class="privacy-banner-close" aria-label="Dismiss" hidden>×</button>
</div>
```
- [ ] **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 <PR-number> --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`.

View File

@ -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 <br>.
*/
"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 `<div id="privacy-banner" hidden>` to `src/templates/pad.html`,
styled by the colibris skin. Collapsed by default.
- Contents: title `<strong>`, body `<p>` (each line becomes a `<p>` so
newlines behave), optional `<a target="_blank" rel="noopener">`,
and a `<button id="privacy-banner-close">` that's rendered only if
`dismissal === "dismissible"`.
- Body text is written via textContent (not innerHTML) to avoid XSS.
### Client JS
New `src/static/js/privacy_banner.ts`:
```typescript
'use strict';
type BannerConfig = {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
};
const storageKey = (url: string): string =>
`etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
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' &&
localStorage.getItem(storageKey(location.href)) === '1') {
return;
}
(banner.querySelector('.privacy-banner-title') as HTMLElement).textContent =
config.title;
const bodyHost = banner.querySelector('.privacy-banner-body') as HTMLElement;
bodyHost.textContent = '';
for (const line of config.body.split(/\r?\n/)) {
const p = document.createElement('p');
p.textContent = line;
bodyHost.appendChild(p);
}
const linkHost = banner.querySelector('.privacy-banner-link') as HTMLElement;
if (config.learnMoreUrl) {
const a = document.createElement('a');
a.href = config.learnMoreUrl;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Learn more';
linkHost.replaceChildren(a);
} else {
linkHost.replaceChildren();
}
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLElement | null;
if (config.dismissal === 'dismissible' && closeBtn) {
closeBtn.hidden = false;
closeBtn.addEventListener('click', () => {
banner.hidden = true;
try { localStorage.setItem(storageKey(location.href), '1'); } catch (_e) { /* best effort */ }
});
} else if (closeBtn) {
closeBtn.hidden = true;
}
banner.hidden = false;
};
```
Called from `pad.ts` once after `postAceInit`, with
`clientVars.privacyBanner`.
### Tests
- **Settings unit** (`src/tests/backend/specs/privacyBanner.ts`):
default shape matches, malformed `dismissal` falls back to
`'dismissible'` on load.
- **Playwright**
(`src/tests/frontend-new/specs/privacy_banner.spec.ts`):
- disabled (default) → `#privacy-banner` stays `hidden`.
- enabled + `sticky` → banner visible on load, no close button.
- enabled + `dismissible` → close button toggles banner hidden and
persists across reload via localStorage.
- `learnMoreUrl``<a>` rendered with the right href, absent when
null.
- Body with two `\n\n` paragraphs → two `<p>` children.
Tests flip `settings.privacyBanner.enabled` at runtime and navigate to
a fresh pad; no server restart needed.
### Docs
- Add a short section to `doc/privacy.md` describing the banner and
how to configure it.
- Add a one-line pointer from `doc/settings.md`'s existing layout to
the privacy doc if `settings.md` has a section for this kind of
block; otherwise leave `settings.json.template`'s inline comments as
the authoritative reference.
## Risk / migration
- Default `enabled: false` keeps the UI quiet for every existing
instance.
- Plain-text + textContent rendering avoids XSS even if operators
copy-paste raw HTML into `body`.
- localStorage key is scoped per-origin, so multi-tenant proxy setups
won't cross-contaminate dismissal state.

View File

@ -241,6 +241,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
*

View File

@ -725,6 +725,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
*

View File

@ -33,6 +33,7 @@ import padutils from '../../static/js/pad_utils';
import readOnlyManager from '../db/ReadOnlyManager';
import settings, {
exportAvailable,
getPublicPrivacyBanner,
sofficeAvailable
} from '../utils/Settings';
import {anonymizeIp} from '../utils/anonymizeIp';
@ -1157,6 +1158,10 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
padDeletionToken,
// Allow-listed copy — settings.privacyBanner could carry extra nested
// keys from a hand-edited settings.json; sending those by reference
// would leak them to every browser. See getPublicPrivacyBanner().
privacyBanner: getPublicPrivacyBanner(),
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
initialRevisionList: [],
initialOptions: pad.getPadSettings(),

View File

@ -176,6 +176,13 @@ export type SettingsType = {
enableDarkMode: boolean,
enablePadWideSettings: boolean,
allowPadDeletionByAllUsers: boolean,
privacyBanner: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
skinName: string | null,
skinVariants: string,
ip: string,
@ -313,7 +320,7 @@ export type SettingsType = {
requireAdminForStatus: boolean,
},
adminEmail: string | null,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings">,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "privacyBanner">,
}
const settings: SettingsType = {
@ -361,6 +368,14 @@ const settings: SettingsType = {
enableDarkMode: true,
enablePadWideSettings: false,
allowPadDeletionByAllUsers: 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.
*
@ -718,11 +733,25 @@ const settings: SettingsType = {
skinName: settings.skinName,
skinVariants: settings.skinVariants,
enablePadWideSettings: settings.enablePadWideSettings,
privacyBanner: getPublicPrivacyBanner(),
}
},
gitVersion: getGitCommit(),
}
// Build the wire-shape of `privacyBanner` for clientVars / getPublicSettings().
// The settings file is operator-controlled and `_.defaults()` (used by
// storeSettings) preserves unknown nested keys at runtime. Returning a literal
// instead of `settings.privacyBanner` itself stops a typo or copy-paste from
// shipping arbitrary extra keys to every browser.
export const getPublicPrivacyBanner = () => ({
enabled: settings.privacyBanner.enabled,
title: settings.privacyBanner.title,
body: settings.privacyBanner.body,
learnMoreUrl: settings.privacyBanner.learnMoreUrl,
dismissal: settings.privacyBanner.dismissal,
});
export default settings;
// CJS compatibility: plugins use require('ep_etherpad-lite/node/utils/Settings')
// and expect settings properties directly on the module object, not under .default
@ -1033,6 +1062,21 @@ export const reloadSettings = () => {
settings.ipLogging = 'anonymous';
}
// Validate `privacyBanner.dismissal`. The client treats every value other
// than the exact strings 'dismissible' and 'sticky' as "no special
// handling", which silently degrades a misconfigured 'sticky' to a
// dismissible-shaped notice (and vice versa). Coerce to the safer default
// and warn so the operator sees the typo.
const validDismissal = ['dismissible', 'sticky'];
if (settings.privacyBanner != null
&& !validDismissal.includes(settings.privacyBanner.dismissal as any)) {
logger.warn(
`privacyBanner.dismissal="${settings.privacyBanner.dismissal}" is ` +
`not one of ${validDismissal.join(', ')}; falling back to ` +
`"dismissible".`);
settings.privacyBanner.dismissal = 'dismissible';
}
// Init logging config
settings.logconfig = defaultLogConfig(
settings.loglevel ? settings.loglevel : defaultLogLevel,

View File

@ -53,6 +53,7 @@ import {randomString} from "./pad_utils";
const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks');
import {showPrivacyBannerIfEnabled} from './privacy_banner';
import './pad_version_badge';
@ -717,6 +718,7 @@ const pad = {
}
showDeletionTokenModalIfPresent();
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
};

View File

@ -0,0 +1,106 @@
'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';
}
};
// Only http(s) and mailto: are allowed for the "Learn more" link, so a
// misconfigured privacyBanner.learnMoreUrl cannot smuggle a javascript:,
// data:, or vbscript: URL into the anchor and execute script on click.
const SAFE_URL_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
const safeUrl = (href: string | null | undefined): string | null => {
if (typeof href !== 'string' || href === '') return null;
let parsed: URL;
try {
parsed = new URL(href, location.href);
} catch (_e) {
return null;
}
if (!SAFE_URL_SCHEMES.has(parsed.protocol)) return null;
return parsed.href;
};
// Build a jQuery DOM fragment for the gritter `text` parameter. Each line of
// the body becomes its own <p> (mirrors what the original config supports), and
// an optional "Learn more" anchor is appended only after the URL has passed
// through safeUrl().
const buildBody = (config: BannerConfig): JQuery => {
const $ = (window as any).$;
const wrap = $('<div>');
for (const line of (config.body || '').split(/\r?\n/)) {
wrap.append($('<p>').text(line));
}
const safeHref = safeUrl(config.learnMoreUrl);
if (safeHref != null) {
// `noreferrer` matches the existing pattern in pad_utils.ts so the pad
// URL doesn't leak to the operator-configured external policy site as a
// Referer header. `noopener` keeps target=_blank from sharing the
// window.opener handle.
wrap.append($('<p>').append(
$('<a>')
.attr('href', safeHref)
.attr('target', '_blank')
.attr('rel', 'noreferrer noopener')
.text('Learn more')));
}
return wrap;
};
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
if (!config || !config.enabled) return;
const $ = (window as any).$;
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
// Server-side reloadSettings() coerces unknown values to 'dismissible' with a
// warn, but if a custom build / hot-reload path skips that validation we
// still must not fall through to "treats unknown as sticky" (which is the
// less safe interpretation — an operator who fat-fingered "dismisable"
// probably meant the dismissable mode they wrote).
const dismissal = config.dismissal === 'sticky' ? 'sticky' : 'dismissible';
if (dismissal === 'dismissible') {
try {
if (localStorage.getItem(storageKey(location.href)) === '1') return;
} catch (_e) { /* proceed without persistence */ }
}
// Reused class lets the Playwright spec target this specific gritter without
// affecting its appearance — the gritter looks like every other gritter on
// the page.
$.gritter.add({
title: config.title || '',
text: buildBody(config),
sticky: true,
position: 'bottom',
class_name: 'privacy-notice',
before_close: () => {
if (dismissal !== 'dismissible') return;
try {
localStorage.setItem(storageKey(location.href), '1');
} catch (_e) { /* best-effort */ }
},
});
};
// End-to-end test hook. The privacy_banner module is bundled into pad.js so
// the Playwright spec at src/tests/frontend-new/specs/privacy_banner.spec.ts
// has no other way to reach into the real showPrivacyBannerIfEnabled — without
// this it can only toy with the DOM and never proves the config-to-DOM wiring.
// Gated on navigator.webdriver so the global is invisible in real browsers
// (Playwright/ChromeDriver/Selenium set webdriver=true; humans don't), keeping
// the disabled-by-default feature genuinely zero-side-effect in production.
if (typeof navigator !== 'undefined' && (navigator as any).webdriver) {
(globalThis as any).__etherpad_privacyBanner__ = {show: showPrivacyBannerIfEnabled};
}

View File

@ -62,6 +62,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,
colorPalette: string[],
accountPrivs: {

View File

@ -175,4 +175,3 @@
font-family: monospace;
padding: 0.4rem;
}

View File

@ -0,0 +1,222 @@
import {expect, test, Page} from '@playwright/test';
import {randomUUID} from 'node:crypto';
type BannerConfig = {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
};
const STORAGE_PREFIX = 'etherpad.privacyBanner.dismissed:';
// All gritters render into #gritter-container.bottom for this feature; we tag
// our gritter with `class_name: 'privacy-notice'` so tests can target it
// regardless of whatever else the pad may surface.
const NOTICE = '#gritter-container.bottom .gritter-item.privacy-notice';
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');
// Drop any persisted dismissal flag from a previous test run on this origin
// so dismissible scenarios start from a clean state regardless of order.
await page.evaluate((prefix) => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k && k.startsWith(prefix)) localStorage.removeItem(k);
}
}, STORAGE_PREFIX);
return padId;
};
const showBanner = (page: Page, config: BannerConfig) =>
page.evaluate((cfg) => {
(window as any).__etherpad_privacyBanner__.show(cfg);
}, config);
test.describe('privacy banner (gritter-based)', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('disabled by default — no privacy gritter is shown', async ({page}) => {
await freshPad(page);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('enabled=false leaves the page free of a privacy gritter', async ({page}) => {
await freshPad(page);
await showBanner(page, {
enabled: false,
title: 'Should not render',
body: 'Should not render',
learnMoreUrl: null,
dismissal: 'sticky',
});
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('renders title, body paragraphs, and link as a sticky bottom gritter',
async ({page}) => {
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'First paragraph.\nSecond paragraph.',
learnMoreUrl: 'https://example.com/privacy',
dismissal: 'sticky',
});
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await expect(item).toHaveClass(/sticky/);
await expect(item.locator('.gritter-title')).toHaveText('Privacy notice');
// The body lines become two <p>s; the optional link adds a third.
const paragraphs = item.locator('.gritter-content > p, .gritter-content div p');
await expect(paragraphs).toHaveCount(3);
await expect(paragraphs.nth(0)).toHaveText('First paragraph.');
await expect(paragraphs.nth(1)).toHaveText('Second paragraph.');
const link = item.locator('a');
await expect(link).toHaveAttribute('href', 'https://example.com/privacy');
await expect(link).toHaveAttribute('rel', 'noreferrer noopener');
await expect(link).toHaveAttribute('target', '_blank');
});
test('dismissible — clicking gritter close persists flag in localStorage',
async ({page}) => {
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: null,
dismissal: 'dismissible',
});
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await item.locator('.gritter-close').click();
await expect(page.locator(NOTICE)).toHaveCount(0);
const flag = await page.evaluate(
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
STORAGE_PREFIX);
expect(flag).toBe('1');
});
test('dismissible — pre-existing localStorage flag suppresses the gritter',
async ({page}) => {
await freshPad(page);
await page.evaluate(
(prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'),
STORAGE_PREFIX);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: null,
dismissal: 'dismissible',
});
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('sticky — closing the gritter does NOT persist a dismissal flag',
async ({page}) => {
// sticky mode means "show on every load"; the close button still
// works for the current session but must not store a flag.
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: null,
dismissal: 'sticky',
});
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await item.locator('.gritter-close').click();
await expect(page.locator(NOTICE)).toHaveCount(0);
const flag = await page.evaluate(
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
STORAGE_PREFIX);
expect(flag).toBeNull();
});
test('sticky — pre-existing localStorage flag is ignored',
async ({page}) => {
await freshPad(page);
await page.evaluate(
(prefix) => localStorage.setItem(`${prefix}${location.origin}`, '1'),
STORAGE_PREFIX);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: null,
dismissal: 'sticky',
});
await expect(page.locator(NOTICE)).toBeVisible();
});
test('javascript: learnMoreUrl is rejected — no anchor rendered',
async ({page}) => {
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: 'javascript:alert(1)',
dismissal: 'sticky',
});
await expect(page.locator(`${NOTICE} a`)).toHaveCount(0);
});
test('data: learnMoreUrl is rejected — no anchor rendered', async ({page}) => {
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: 'data:text/html,<script>alert(1)</script>',
dismissal: 'sticky',
});
await expect(page.locator(`${NOTICE} a`)).toHaveCount(0);
});
test('unknown dismissal value is treated as dismissible (defense-in-depth)',
async ({page}) => {
// Server-side reloadSettings() coerces unknown strings to
// 'dismissible' with a warn, but the client guards too in case a
// hot-reload or custom build path skips that validation.
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: null,
dismissal: 'wat' as any,
});
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await item.locator('.gritter-close').click();
await expect(page.locator(NOTICE)).toHaveCount(0);
const flag = await page.evaluate(
(prefix) => localStorage.getItem(`${prefix}${location.origin}`),
STORAGE_PREFIX);
expect(flag).toBe('1');
});
test('mailto: learnMoreUrl is allowed', async ({page}) => {
await freshPad(page);
await showBanner(page, {
enabled: true,
title: 'Privacy notice',
body: 'Body.',
learnMoreUrl: 'mailto:privacy@example.com',
dismissal: 'sticky',
});
await expect(page.locator(`${NOTICE} a`))
.toHaveAttribute('href', 'mailto:privacy@example.com');
});
});