Back to Etherpad Lite

GDPR PR4 — Privacy Banner Implementation Plan

docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md

2.7.317.7 KB
Original Source

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 [email protected] 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 sectionTask
privacyBanner settings block1
getPublicSettings() exposure1
clientVars.privacyBanner wiring2
Template DOM3
Client JS (textContent, link, close button)4
Styling5
Playwright tests6
Docs7

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.