feat(gdpr): configurable privacy banner (PR4 of #6701)#7549
feat(gdpr): configurable privacy banner (PR4 of #6701)#7549JohnMcLear merged 15 commits intodevelopfrom
Conversation
Review Summary by Qodo(Agentic_describe updated until commit 906e145)Add configurable GDPR privacy banner with dismissible/sticky modes
WalkthroughsDescription• Add configurable privacy banner via settings.json with title, body, and optional "Learn more" link • Banner renders on pad load when enabled; content set via textContent to prevent XSS • Support two dismissal modes: dismissible (localStorage-persisted per-origin) and sticky (always shown) • Wire banner config through clientVars and render as gritter notification in bottom corner • Comprehensive Playwright test coverage for all banner states and URL scheme validation Diagramflowchart LR
Settings["settings.json<br/>privacyBanner block"]
SettingsType["SettingsType<br/>typed config"]
PublicSettings["getPublicSettings()<br/>expose to client"]
ClientVars["clientVars<br/>privacyBanner"]
PadTS["pad.ts<br/>postAceInit"]
PrivacyBanner["privacy_banner.ts<br/>showPrivacyBannerIfEnabled"]
Gritter["$.gritter.add()<br/>render banner"]
LocalStorage["localStorage<br/>dismissal flag"]
Settings --> SettingsType
SettingsType --> PublicSettings
PublicSettings --> ClientVars
ClientVars --> PadTS
PadTS --> PrivacyBanner
PrivacyBanner --> Gritter
PrivacyBanner --> LocalStorage
File Changes1. src/node/utils/Settings.ts
|
Code Review by Qodo
1.
|
ac03929 to
006618b
Compare
SamTV12345
left a comment
There was a problem hiding this comment.
Besides the important flag nothing strikes my eyes. Great job :)
|
|
||
| /* GDPR privacy banner (PR4) */ | ||
| .privacy-banner[hidden] { | ||
| display: none !important; |
There was a problem hiding this comment.
Do we really need important here? Maybe a more specific rule can fix this?
There was a problem hiding this comment.
Changed to use gritter, is nicer.
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.
|
/review |
|
Persistent review updated to latest commit 5118870 |
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>
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.
5118870 to
8891d10
Compare
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>
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
Persistent review updated to latest commit 906e145 |
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>
…ky 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>
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>
Summary
privacyBannerblock insettings.json(enabled / title / body / learnMoreUrl / dismissal). Defaults to disabled so existing instances are unchanged.clientVars.privacyBanner; client renders it withtextContent(HTML is escaped).dismissiblestores a per-origin flag inlocalStorageso the user sees the notice once;stickyshows it every load.Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already in flight. PR5 (author erasure) is the last.
Design:
docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.mdPlan:
docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.mdTest plan
pnpm --filter ep_etherpad-lite run ts-check