From b6a1f38101dc7bcd2688adc11920449bea0486ef Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 18:05:28 +0100 Subject: [PATCH 01/17] =?UTF-8?q?docs(gdpr):=20admin=20UI=20for=20author?= =?UTF-8?q?=20erasure=20=E2=80=94=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR5 (#7550): adds an in-product /admin/authors page so operators can search by name or external mapper, preview the impact of an Art. 17 erasure (token mappings, mapper bindings, chat messages, affected pads), and commit it without crafting a curl. Backend uses three new admin-socket events on settings_admin (not REST), so the existing public REST endpoint and its gdprAuthorErasure.enabled flag keep their current single meaning. The page stays discoverable when the flag is off — banner + disabled buttons explain how to enable it. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-03-gdpr-admin-author-erasure-ui-design.md | 286 ++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md diff --git a/docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md b/docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md new file mode 100644 index 00000000000..4347e6aa941 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md @@ -0,0 +1,286 @@ +# Admin UI for GDPR Art. 17 author erasure + +Follow-up to PR5 of #6701 (`feat-gdpr-author-erasure`, merged via #7550). +PR5 shipped the `anonymizeAuthor` capability as a REST endpoint only. +This spec adds an in-product surface so an operator can find and erase +an author from `/admin` without crafting a `curl`. + +## Problem + +After PR5, erasing an author requires: + +1. Knowing the opaque `authorID` (e.g. `a.XXXXXXXXXXXXXXXX`). +2. Holding admin credentials (apikey / JWT). +3. Running a `curl` against `/api/1.3.1/anonymizeAuthor` with the + correct settings flag enabled. + +For instances handling real GDPR Art. 17 requests this is too much +friction and too easy to mis-target (the only check before destruction +is "did you paste the right ID?"). Operators have asked for the same +"search → click → confirm" flow they already have for pads. + +## Goals + +1. Admins can locate an author by display name **or** by external + mapper (SSO subject, token-binding key) — the two identifiers a + GDPR request typically arrives carrying. +2. Before the irreversible erasure runs, the admin sees a server-side + preview of what will be touched (mappings, chat messages, affected + pads). +3. The page itself is discoverable even when the feature flag is off, + so admins know the capability exists and where to enable it. +4. No new public API surface; the public REST endpoint is unchanged + and its single feature flag (`gdprAuthorErasure.enabled`) keeps its + existing meaning. + +## Non-goals + +- **Pad-context discovery** (drilling from a pad to its contributors). + Possible follow-up; not in this spec. +- **Bulk erase / multi-select.** GDPR requests are per-subject. +- **Audit-log export of erasures.** Operators already have log4js + + the existing `anonymizeAuthor` log line. +- **Undo / recovery.** Erasure is irreversible by design. +- **Refactoring `PadPage.tsx`** into a shared list-page component. + After this lands there will be two real consumers; the abstraction + comes then, not before. +- **Backfill migration for the new `lastSeen` field.** New-on-touch + only; pre-existing records show `—` until they are touched again. + +## UX + +A new admin page at `/admin/authors`, sidebar entry between Pads and +Shout (icon: `Users` from lucide). + +Layout mirrors `PadPage.tsx`: + +- Search field — substring match on `name` OR `mapper`. +- Toggle "Show erased authors" (off by default). +- Sortable table: + | Color | Name | Mapper | Last seen | Author ID | Actions | + - Color renders as an inline `` with `background-color`. + - Author ID column shows the full ID (copyable). + - Mapper column renders the first mapper string; if an author has + more than one (multi-SSO accounts, rare), append `+N` and show + the full list in a `title` tooltip. + - Actions column has a single `Trash2` "Erase" button per row. +- Pagination — 12 rows per page (matches Pads). +- Cap warning — when the server reports `cappedAt`, render a banner + "Showing first 1000 authors. Narrow your search to see more." + +### Erasure flow (two-step) + +Clicking "Erase" opens a Radix `Dialog.Root` with two phases held in +local state (`'preview' | 'committing' | 'closed'`): + +1. **Preview** — open emits `anonymizeAuthorPreview`. While waiting + the modal shows a spinner. On `results:anonymizeAuthorPreview`, + counters render: + > About to erase author **``** (`a.XXXX`). + > Will clear: **N** token mappings, **M** mapper bindings, **K** + > chat messages, across **P** pads. + > **This cannot be undone.** + + Buttons: Cancel · Continue. + +2. **Commit** — Continue emits `anonymizeAuthor`. On + `results:anonymizeAuthor` the modal closes, a success toast + renders, and the row is replaced in-place with a greyed + "(erased)" stub. + +If `results:anonymizeAuthor` carries `error`, the modal stays open +and surfaces the error inline (no destructive close-on-error). + +### Disabled-flag UX + +When `gdprAuthorErasure.enabled = false`: + +- The page renders normally — table, search, sort and pagination + all work (read-only browse is harmless). +- A persistent banner at the top reads: + > Author erasure is disabled. Set `"gdprAuthorErasure": {"enabled": + > true}` in `settings.json` to enable. +- Every Erase button is disabled with the same message as a + `title` tooltip. +- The dry-run preview event remains usable from the admin socket + (it is read-only and admin-authed) — but the UI does not invoke it + while the live action is disabled, to avoid implying an action is + about to happen. + +## Backend + +Three new admin-socket events on the existing `settings_admin` socket +(parallel to `deletePad` / `cleanupPadRevisions`). **Not REST.** +Rationale: matches the existing admin pattern, reuses the admin-auth +middleware, and keeps the public REST surface unchanged so +`gdprAuthorErasure.enabled` keeps its single meaning ("expose the +public REST endpoint"). + +| Event in | Payload | Event out | Result shape | +|---|---|---|---| +| `authorLoad` | `{offset, limit, pattern, sortBy, ascending, includeErased}` | `results:authorLoad` | `{total, cappedAt?, results: [{authorID, name, colorId, mapper, lastSeen, erased}]}` | +| `anonymizeAuthorPreview` | `{authorID}` | `results:anonymizeAuthorPreview` | `{authorID, name, removedTokenMappings, removedExternalMappings, clearedChatMessages, affectedPads}` | +| `anonymizeAuthor` | `{authorID}` | `results:anonymizeAuthor` | `{authorID, ...counters} \| {authorID, error: 'disabled' \| 'unknown' \| }` | + +### `authorManager.searchAuthors(query)` + +New helper. Algorithm: + +1. `keys = await db.findKeys('globalAuthor:*', null)`. +2. `mapperIndex = Map` built once via + `db.findKeys('mapper2author:*', null)` + a single batch read of + the values. (`mapper2author:` → `{authorID}`.) +3. For each `globalAuthor:` record: + - read the record; + - skip if `erased` and `!includeErased`; + - filter on `pattern` (substring match on `name` OR any mapper in + `mapperIndex.get(authorID) ?? []`); + - emit `{authorID, name, colorId, mapper: mapperIndex.get(...) ?? + [], lastSeen, erased}`. +4. Sort the in-memory list by `sortBy` (`name` | `lastSeen`), + ascending or descending. +5. If pre-pagination length > 1000, slice to 1000 and set `cappedAt: + 1000`. +6. Apply `offset`/`limit` for pagination; return `{total, cappedAt?, + results}` where `total` is the post-filter, post-cap count. + +Performance is acceptable for the typical instance size and is bounded +by the cap. A proper indexed scan can replace this if anyone hits the +cap regularly — explicit follow-up, not now. + +### `lastSeen` field + +Added to `globalAuthor:`. Set to `Date.now()` on the existing +write paths in `AuthorManager` that already touch the record +(`setAuthorName`, `setAuthorColorId`, `createAuthor*`) — i.e. when +an author actively does something the system records. Read paths are +not modified to avoid an extra write per page load. New-on-touch +only; no migration sweep. Surfaced as ISO-8601 in the search result +and rendered as `toLocaleString()` in the UI. Records without +`lastSeen` render as `—`. + +### Dry-run plumbing + +`authorManager.anonymizeAuthor(authorID, {dryRun: true})` returns the +same counter shape without writing. Implementation: walk the same +loops, count, return — no `db.set` / `db.remove`. Same admin-auth gate +on the socket layer. The `gdprAuthorErasure.enabled` flag does NOT +gate the dry-run path (read-only, admin-authed); it only gates the +live `anonymizeAuthor` socket event (matching the public REST +endpoint's behaviour). + +### Settings flag delivery to client + +The `settingsSocket` already streams an `init` payload to the admin +on connect. Add `gdprAuthorErasure: settings.gdprAuthorErasure` to it +and have `App.tsx` populate `gdprAuthorErasureEnabled` in the store +once on connect. The page renders the disabled banner when false. + +## Frontend + +### New files + +- `admin/src/pages/AuthorPage.tsx` — page component, mirrors + `PadPage.tsx` shape. +- `admin/src/utils/AuthorSearch.ts` — `AuthorSearchQuery`, + `AuthorSearchResult`, `AuthorRow` types. +- `admin/src/components/ColorSwatch.tsx` — small `` with inline + `background-color`. Reusable. + +### Edited files + +- `admin/src/store/store.ts` — `authors`, `setAuthors`, + `gdprAuthorErasureEnabled`. +- `admin/src/main.tsx` — register `}/>`. +- `admin/src/App.tsx` (or whichever file owns the sidebar) — new + "Authors" link between Pads and Shout. +- `admin/src/localization/locales/en.json` — see i18n keys below. +- `src/node/hooks/express/admin.ts` — extend the `init` payload with + `gdprAuthorErasure`. +- `src/node/hooks/express/settings_admin.ts` (or equivalent) — wire + the three new socket events. + +### i18n keys + +All user-visible strings go through `Trans` / `t()` per the project's +i18n rule. New keys: + +- `ep_admin_authors:title` +- `ep_admin_authors:search-placeholder` +- `ep_admin_authors:column.color` +- `ep_admin_authors:column.name` +- `ep_admin_authors:column.mapper` +- `ep_admin_authors:column.last-seen` +- `ep_admin_authors:column.author-id` +- `ep_admin_authors:column.actions` +- `ep_admin_authors:show-erased` +- `ep_admin_authors:erase` +- `ep_admin_authors:erased-stub` +- `ep_admin_authors:cap-warning` +- `ep_admin_authors:feature-disabled-banner` +- `ep_admin_authors:confirm-preview-title` +- `ep_admin_authors:confirm-preview-counters` +- `ep_admin_authors:confirm-irreversible` +- `ep_admin_authors:cancel` +- `ep_admin_authors:continue` +- `ep_admin_authors:erase-success-toast` +- `ep_admin_authors:erase-error-toast` + +Other locales fall back to English until translated. + +## Testing + +Per the project rule, both backend and frontend suites ship with the +PR. + +### Backend (`mocha --import=tsx`) + +- **`src/tests/backend/specs/admin/authorSearch.ts`** — covers + `authorManager.searchAuthors`: + - empty store → `{total: 0, results: []}` + - 3 authors, no filter → all 3, sorted by name asc + - search by name substring matches + - search by mapper substring matches (joins `mapper2author`) + - `includeErased: false` (default) hides erased; `true` includes + - sort by `lastSeen` asc / desc + - cap-at-1000: insert 1100, assert `results.length === 1000` and + `cappedAt === 1000`. +- **`src/tests/backend/specs/anonymizeAuthor.ts`** (extend existing): + - dry-run returns the same counter shape as the live path without + mutating `globalAuthor:` + - dry-run on an unknown authorID returns zeros without throwing. +- **`src/tests/backend/specs/admin/anonymizeAuthorSocket.ts`** — + admin-socket integration: + - opens `settings_admin` with admin creds; + - `authorLoad` round-trip; + - `anonymizeAuthorPreview` round-trip; asserts `erased` is NOT + flipped on the record; + - live `anonymizeAuthor` round-trip when flag enabled; + - live `anonymizeAuthor` returns `{error: 'disabled'}` when flag + off; + - dry-run preview still works when flag off. + +### Frontend (Playwright, `src/tests/frontend-new/specs/`) + +- **`admin_authors_page.spec.ts`**: + - navigates to `/admin/authors` via the existing admin auth + fixture; + - seeds two authors via the existing API helpers; + - asserts the localized header string (`t('ep_admin_authors:title')`) + renders — not just element presence (per project rule); + - search by name filters the table to one row; + - clicking Erase opens the modal; preview counters render; + Continue commits; row shows the localized "(erased)" stub; + success toast text matches the localized string; + - with the feature flag toggled off via the test settings hook, + the localized banner renders and the Erase button is disabled. + +## Backwards compatibility + +- The admin socket gains three new events; absent admin builds + ignore them. +- The public REST endpoint and its flag are unchanged. +- Adding `lastSeen` to `globalAuthor` is additive — older record + readers ignore unknown fields. +- No DB migration required. From b4c0714344d01c94eee242873b45a67dc3533ead Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 18:25:09 +0100 Subject: [PATCH 02/17] =?UTF-8?q?docs(gdpr):=20admin=20UI=20for=20author?= =?UTF-8?q?=20erasure=20=E2=80=94=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-by-step TDD plan for the /admin/authors follow-up to PR5 (#7550). Nine tasks covering: lastSeen field on globalAuthor writes, anonymizeAuthor({dryRun}), authorManager.searchAuthors helper, three new admin-socket events (authorLoad / anonymizeAuthorPreview / anonymizeAuthor) + settings-flag delivery, frontend types/swatch/ i18n, store/route/sidebar wiring, AuthorPage.tsx with two-step modal and disabled-flag banner, Playwright coverage, and the PR/ Qodo workflow. References the spec at docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-05-03-gdpr-admin-author-erasure-ui.md | 1627 +++++++++++++++++ 1 file changed, 1627 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md diff --git a/docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md b/docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md new file mode 100644 index 00000000000..a2d97f57073 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.md @@ -0,0 +1,1627 @@ +# Admin UI for GDPR Art. 17 Author Erasure — 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:** Add an in-product `/admin/authors` page that lets operators search authors by name or external mapper, preview the impact of an Art. 17 erasure, and commit it — without crafting a `curl`. + +**Architecture:** Three new admin-socket events on `io.of('/settings')` (parallel to the existing `padLoad`/`deletePad`/`cleanupPadRevisions` handlers in `adminsettings.ts`). New helper `authorManager.searchAuthors()` enumerates `globalAuthor:*` keys, joins with `mapper2author:*` for the mapper column, and applies in-memory filter/sort/pagination capped at 1000 rows pre-pagination. `anonymizeAuthor` gains a `{dryRun}` option that walks the same loops without writing. Frontend mirrors `PadPage.tsx`: a Radix-based table with a two-step erase modal (preview counters → commit). The existing `gdprAuthorErasure.enabled` flag gates only the live erasure (admin-socket and REST); the read-only browse and dry-run preview always work for authenticated admins. When the flag is off the page renders a banner and disables the Erase button. + +**Tech Stack:** TypeScript, Node.js, socket.io, React 18, Radix UI Dialog, Zustand, react-i18next, lucide-react icons, Playwright (frontend tests), Mocha + tsx (backend tests). + +**Branch:** `feat-gdpr-admin-author-erasure` (off ether/etherpad develop). Spec already committed at `docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md`. + +## File Structure + +**Backend — modify:** +- `src/node/db/AuthorManager.ts` — add `lastSeen` writes on existing write paths; extend `anonymizeAuthor` with optional `{dryRun}` arg; add `searchAuthors` helper. +- `src/node/hooks/express/adminsettings.ts` — add three socket handlers + extend the connect-time settings push so the client knows whether `gdprAuthorErasure.enabled` is true. + +**Backend — create:** +- `src/tests/backend/specs/admin/authorSearch.ts` — unit-level coverage of `searchAuthors` (all the filter/sort/cap branches). +- `src/tests/backend/specs/admin/anonymizeAuthorSocket.ts` — socket integration: round-trip the three new events and assert flag-disabled / dry-run-survives-disabled behaviour. + +**Backend — extend:** +- `src/tests/backend/specs/anonymizeAuthor.ts` — two new specs covering `dryRun: true`. + +**Frontend — modify:** +- `admin/src/store/store.ts` — add `authors` slice and `gdprAuthorErasureEnabled` flag. +- `admin/src/main.tsx` — register `/authors` route. +- `admin/src/App.tsx` — sidebar link + listen for the flag in the existing `settingSocket.on('settings', …)` handler. + +**Frontend — create:** +- `admin/src/utils/AuthorSearch.ts` — `AuthorSearchQuery`, `AuthorSearchResult`, `AuthorRow` types. +- `admin/src/components/ColorSwatch.tsx` — small inline-style swatch. +- `admin/src/pages/AuthorPage.tsx` — page component (table, search, sort, pagination, disabled banner, two-step erase modal). +- `admin/public/ep_admin_authors/en.json` — i18n keys for the new page (loaded via the existing `ep_admin_authors` namespace pattern). +- `src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts` — Playwright coverage of the page. + +--- + +## Task 1: `lastSeen` field on `globalAuthor:` + +**Files:** +- Modify: `src/node/db/AuthorManager.ts:198-247` +- Test: `src/tests/backend/specs/anonymizeAuthor.ts` (extend existing file) + +**Why:** The new admin search needs a `lastSeen` column. Stamping it on the existing write paths (createAuthor, setAuthorName, setAuthorColorId) is additive — no migration, no read-path overhead. + +- [ ] **Step 1: Write the failing test** — append to `src/tests/backend/specs/anonymizeAuthor.ts`: + +```typescript + it('lastSeen is stamped when an author is created and on identity writes', + async function () { + const before = Date.now(); + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`, 'Dora'); + const created = await DB.db.get(`globalAuthor:${authorID}`); + assert.ok(typeof created.lastSeen === 'number', + `lastSeen=${created.lastSeen}`); + assert.ok(created.lastSeen >= before); + + await new Promise((r) => setTimeout(r, 5)); + await authorManager.setAuthorName(authorID, 'Dora2'); + const renamed = await DB.db.get(`globalAuthor:${authorID}`); + assert.ok(renamed.lastSeen > created.lastSeen, + `renamed=${renamed.lastSeen} created=${created.lastSeen}`); + + await new Promise((r) => setTimeout(r, 5)); + await authorManager.setAuthorColorId(authorID, '12'); + const recolored = await DB.db.get(`globalAuthor:${authorID}`); + assert.ok(recolored.lastSeen > renamed.lastSeen); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run from `src/`: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/anonymizeAuthor.ts` + +Expected: the new spec fails with `lastSeen=undefined`. + +- [ ] **Step 3: Stamp `lastSeen` in `createAuthor`** — in `src/node/db/AuthorManager.ts`, replace the body of `exports.createAuthor`: + +```typescript +exports.createAuthor = async (name: string) => { + const author = `a.${randomString(16)}`; + const now = Date.now(); + const authorObj = { + colorId: Math.floor(Math.random() * (exports.getColorPalette().length)), + name, + timestamp: now, + lastSeen: now, + }; + await db.set(`globalAuthor:${author}`, authorObj); + return {authorID: author}; +}; +``` + +- [ ] **Step 4: Stamp `lastSeen` in `setAuthorColorId` and `setAuthorName`** — replace the two one-liner exports: + +```typescript +exports.setAuthorColorId = async (author: string, colorId: string) => { + await db.setSub(`globalAuthor:${author}`, ['colorId'], colorId); + await db.setSub(`globalAuthor:${author}`, ['lastSeen'], Date.now()); +}; + +exports.setAuthorName = async (author: string, name: string) => { + await db.setSub(`globalAuthor:${author}`, ['name'], name); + await db.setSub(`globalAuthor:${author}`, ['lastSeen'], Date.now()); +}; +``` + +- [ ] **Step 5: Re-run test to verify it passes** + +Same command as Step 2. Expected: all `anonymizeAuthor.ts` specs pass (5 existing + 1 new = 6 passing). + +- [ ] **Step 6: Commit** + +```bash +git add src/node/db/AuthorManager.ts src/tests/backend/specs/anonymizeAuthor.ts +git commit -m "feat(authors): stamp lastSeen on globalAuthor writes + +Adds a lastSeen timestamp to the globalAuthor record on createAuthor, +setAuthorName, and setAuthorColorId. Read paths are not modified to +keep the write cost zero per page load. Pre-existing records gain the +field on their next identity write — no migration sweep, callers that +read the field tolerate undefined. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `anonymizeAuthor({dryRun})` option + +**Files:** +- Modify: `src/node/db/AuthorManager.ts:328-415` +- Test: `src/tests/backend/specs/anonymizeAuthor.ts` (extend) + +**Why:** The admin UI needs a server-side preview of how many things an erasure would touch. Reusing the live function with a `dryRun` flag keeps the counter shape identical and avoids drift. + +- [ ] **Step 1: Write two failing tests** — append to `src/tests/backend/specs/anonymizeAuthor.ts`: + +```typescript + it('dryRun returns the same counter shape but does not mutate the record', + async function () { + const mapper = `mapper-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const {authorID} = + await authorManager.createAuthorIfNotExistsFor(mapper, 'Eve'); + const before = await DB.db.get(`globalAuthor:${authorID}`); + + const preview = + await authorManager.anonymizeAuthor(authorID, {dryRun: true}); + + assert.ok(preview.removedExternalMappings >= 1, + `removedExternalMappings=${preview.removedExternalMappings}`); + const after = await DB.db.get(`globalAuthor:${authorID}`); + assert.equal(after.name, 'Eve', 'name should be untouched'); + assert.equal(after.erased, undefined, + 'erased flag should not be set on dry run'); + assert.equal(await DB.db.get(`mapper2author:${mapper}`), authorID, + 'mapper binding should still resolve after dry run'); + assert.deepEqual( + Object.keys(before.padIDs || {}).sort(), + Object.keys(after.padIDs || {}).sort()); + }); + + it('dryRun on an unknown authorID returns zero counters without throwing', + async function () { + const res = await authorManager.anonymizeAuthor( + 'a.does-not-exist-xxxxxxxxxxxx', {dryRun: true}); + assert.deepEqual(res, { + affectedPads: 0, + removedTokenMappings: 0, + removedExternalMappings: 0, + clearedChatMessages: 0, + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/anonymizeAuthor.ts` + +Expected: both new specs fail (current signature ignores the second arg and mutates the record). + +- [ ] **Step 3: Refactor `anonymizeAuthor` to accept `{dryRun}`** — in `src/node/db/AuthorManager.ts`, replace the function body. The signature becomes: + +```typescript +exports.anonymizeAuthor = async ( + authorID: string, + opts: {dryRun?: boolean} = {}, +): Promise<{ + affectedPads: number, + removedTokenMappings: number, + removedExternalMappings: number, + clearedChatMessages: number, +}> => { + const dryRun = opts.dryRun === true; + const padManager = require('./PadManager'); + const existing = await db.get(`globalAuthor:${authorID}`); + if (existing == null || existing.erased) { + return { + affectedPads: 0, + removedTokenMappings: 0, + removedExternalMappings: 0, + clearedChatMessages: 0, + }; + } + + let removedTokenMappings = 0; + const tokenKeys: string[] = await db.findKeys('token2author:*', null); + for (const key of tokenKeys) { + if (await db.get(key) === authorID) { + if (!dryRun) await db.remove(key); + removedTokenMappings++; + } + } + let removedExternalMappings = 0; + const mapperKeys: string[] = await db.findKeys('mapper2author:*', null); + for (const key of mapperKeys) { + if (await db.get(key) === authorID) { + if (!dryRun) await db.remove(key); + removedExternalMappings++; + } + } + + if (!dryRun) { + await db.set(`globalAuthor:${authorID}`, { + colorId: 0, + name: null, + timestamp: Date.now(), + padIDs: existing.padIDs || {}, + }); + } + + const padIDs = Object.keys(existing.padIDs || {}); + let clearedChatMessages = 0; + for (const padID of padIDs) { + if (!await padManager.doesPadExist(padID)) continue; + const pad = await padManager.getPad(padID); + const chatHead = pad.chatHead; + if (typeof chatHead !== 'number' || chatHead < 0) continue; + for (let i = 0; i <= chatHead; i++) { + const chatKey = `pad:${padID}:chat:${i}`; + const msg = await db.get(chatKey); + if (msg != null && msg.authorId === authorID) { + if (!dryRun) { + msg.authorId = null; + await db.set(chatKey, msg); + } + clearedChatMessages++; + } + } + } + + if (!dryRun) { + await db.set(`globalAuthor:${authorID}`, { + colorId: 0, + name: null, + timestamp: Date.now(), + padIDs: existing.padIDs || {}, + erased: true, + erasedAt: new Date().toISOString(), + }); + } + + return { + affectedPads: padIDs.length, + removedTokenMappings, + removedExternalMappings, + clearedChatMessages, + }; +}; +``` + +- [ ] **Step 4: Re-run all anonymizeAuthor specs to verify both new and existing pass** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/anonymizeAuthor.ts` + +Expected: 8 passing (5 existing + lastSeen + 2 dryRun). + +- [ ] **Step 5: Commit** + +```bash +git add src/node/db/AuthorManager.ts src/tests/backend/specs/anonymizeAuthor.ts +git commit -m "feat(authors): anonymizeAuthor({dryRun}) for preview + +Adds an opt-in dryRun option that walks the same token/mapper/chat +loops and returns identical counter shape without touching the +database. The public REST endpoint is unchanged (it never passes the +flag), so production behaviour is identical. Used by the upcoming +admin-UI two-step erase modal to show 'will clear: N mappings, K +chat messages' before the irreversible commit. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: `authorManager.searchAuthors(query)` + +**Files:** +- Modify: `src/node/db/AuthorManager.ts` (append after `anonymizeAuthor`) +- Test: `src/tests/backend/specs/admin/authorSearch.ts` (new) + +**Why:** Backend half of the search-and-list page. In-memory scan with cap is plenty for typical instances; a dedicated index is a follow-up if anyone hits the cap. + +- [ ] **Step 1: Create the test directory + file** + +```bash +mkdir -p src/tests/backend/specs/admin +``` + +Create `src/tests/backend/specs/admin/authorSearch.ts`: + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; + +const common = require('../../common'); +const authorManager = require('../../../../node/db/AuthorManager'); +const DB = require('../../../../node/db/DB'); + +describe(__filename, function () { + before(async function () { + this.timeout(60000); + await common.init(); + }); + + // Each spec seeds its own authors with unique mappers so they don't + // collide with parallel runs or with whatever the rest of the suite + // happened to leave in the dirty.db. + const seed = async (name: string, mapper: string) => + (await authorManager.createAuthorIfNotExistsFor(mapper, name)).authorID; + + it('returns an empty page when the pattern matches nothing', async function () { + const res = await authorManager.searchAuthors({ + pattern: `nonexistent-${Date.now()}-${Math.random()}`, + offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(res.total, 0); + assert.deepEqual(res.results, []); + }); + + it('matches by name substring', async function () { + const tag = `findme-${Date.now()}`; + await seed(`Alice ${tag}`, `m-${tag}-1`); + await seed(`Bob ${tag}`, `m-${tag}-2`); + const res = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(res.total, 2); + assert.equal(res.results[0].name, `Alice ${tag}`); + assert.equal(res.results[1].name, `Bob ${tag}`); + }); + + it('matches by mapper substring (joins mapper2author)', async function () { + const tag = `mapper-tag-${Date.now()}`; + await seed('Carol', `${tag}-x`); + const res = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.ok(res.results.some((r: any) => r.name === 'Carol' && + r.mapper.some((m: string) => m.includes(tag))), + `results=${JSON.stringify(res.results)}`); + }); + + it('hides erased authors by default and includes them when asked', + async function () { + const tag = `era-${Date.now()}`; + const id = await seed(`Erasable ${tag}`, `m-${tag}`); + await authorManager.anonymizeAuthor(id); + + const hidden = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(hidden.total, 0, + `expected erased author hidden, got ${JSON.stringify(hidden)}`); + + const shown = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: true, + }); + assert.equal(shown.total, 1); + assert.equal(shown.results[0].erased, true); + }); + + it('sorts by lastSeen', async function () { + const tag = `sort-${Date.now()}`; + const a = await seed(`SortA ${tag}`, `m-${tag}-a`); + await new Promise((r) => setTimeout(r, 10)); + const b = await seed(`SortB ${tag}`, `m-${tag}-b`); + const asc = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'lastSeen', ascending: true, + includeErased: false, + }); + assert.equal(asc.results[0].authorID, a); + assert.equal(asc.results[1].authorID, b); + const desc = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'lastSeen', ascending: false, + includeErased: false, + }); + assert.equal(desc.results[0].authorID, b); + }); + + it('caps results at 1000 and reports cappedAt', async function () { + this.timeout(120000); + const tag = `cap-${Date.now()}`; + // Seed 1100 authors directly via DB to keep this fast (~1s vs minutes + // through createAuthorIfNotExistsFor). + const seeded: string[] = []; + for (let i = 0; i < 1100; i++) { + const id = `a.${tag}-${i.toString().padStart(5, '0')}`; + await DB.db.set(`globalAuthor:${id}`, { + colorId: 0, name: `cap ${tag} ${i}`, timestamp: Date.now(), + lastSeen: Date.now(), + }); + seeded.push(id); + } + const res = await authorManager.searchAuthors({ + pattern: tag, offset: 0, limit: 12, sortBy: 'name', ascending: true, + includeErased: false, + }); + assert.equal(res.cappedAt, 1000, + `expected cappedAt=1000, got ${res.cappedAt}`); + assert.equal(res.total, 1000); + }); +}); +``` + +- [ ] **Step 2: Run the new spec to verify it fails** + +Run from `src/`: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/authorSearch.ts` + +Expected: every spec fails with `TypeError: authorManager.searchAuthors is not a function`. + +- [ ] **Step 3: Add `searchAuthors` to `AuthorManager.ts`** — append at the end of the file (after the `anonymizeAuthor` function): + +```typescript +/** + * Admin-side author listing for the /admin/authors page. Enumerates + * `globalAuthor:*`, joins with `mapper2author:*` for the mapper column, + * applies in-memory filter/sort/pagination. Capped at 1000 rows pre- + * pagination so a runaway scan can't OOM the admin process — callers + * surface the cap via `cappedAt`. + * + * @param query.pattern substring match against name OR any mapper + * @param query.offset pagination offset + * @param query.limit pagination limit + * @param query.sortBy 'name' | 'lastSeen' + * @param query.ascending sort direction + * @param query.includeErased when false (default), hides records with + * erased: true + */ +exports.searchAuthors = async (query: { + pattern: string, + offset: number, + limit: number, + sortBy: 'name' | 'lastSeen', + ascending: boolean, + includeErased: boolean, +}): Promise<{ + total: number, + cappedAt?: number, + results: Array<{ + authorID: string, + name: string | null, + colorId: string | number | null, + mapper: string[], + lastSeen: number | null, + erased: boolean, + }>, +}> => { + // Build a reverse index mapper -> authorID once. mapper2author values + // can be either a bare string (legacy) or an object {authorID}. + const mapperByAuthor = new Map(); + const mapperKeys: string[] = await db.findKeys('mapper2author:*', null); + for (const key of mapperKeys) { + const v = await db.get(key); + const authorID = + typeof v === 'string' ? v : (v && v.authorID) || null; + if (!authorID) continue; + const mapper = key.substring('mapper2author:'.length); + if (!mapperByAuthor.has(authorID)) mapperByAuthor.set(authorID, []); + mapperByAuthor.get(authorID)!.push(mapper); + } + + const authorKeys: string[] = await db.findKeys('globalAuthor:*', null); + const pattern = (query.pattern || '').toLowerCase(); + const rows: Array<{ + authorID: string, name: string | null, + colorId: string | number | null, mapper: string[], + lastSeen: number | null, erased: boolean, + }> = []; + + for (const key of authorKeys) { + const rec = await db.get(key); + if (rec == null) continue; + const erased = rec.erased === true; + if (erased && !query.includeErased) continue; + const authorID = key.substring('globalAuthor:'.length); + const mappers = mapperByAuthor.get(authorID) || []; + if (pattern) { + const nameMatch = + (rec.name || '').toLowerCase().includes(pattern); + const mapperMatch = + mappers.some((m) => m.toLowerCase().includes(pattern)); + if (!nameMatch && !mapperMatch) continue; + } + rows.push({ + authorID, + name: rec.name ?? null, + colorId: rec.colorId ?? null, + mapper: mappers, + lastSeen: typeof rec.lastSeen === 'number' ? rec.lastSeen : null, + erased, + }); + } + + rows.sort((a, b) => { + let av: any; let bv: any; + if (query.sortBy === 'lastSeen') { + av = a.lastSeen ?? 0; bv = b.lastSeen ?? 0; + } else { + av = (a.name || '').toLowerCase(); + bv = (b.name || '').toLowerCase(); + } + if (av < bv) return query.ascending ? -1 : 1; + if (av > bv) return query.ascending ? 1 : -1; + return 0; + }); + + const CAP = 1000; + let cappedAt: number | undefined; + let working = rows; + if (working.length > CAP) { + working = working.slice(0, CAP); + cappedAt = CAP; + } + + const total = working.length; + const page = working.slice(query.offset, query.offset + query.limit); + const out: any = {total, results: page}; + if (cappedAt != null) out.cappedAt = cappedAt; + return out; +}; +``` + +- [ ] **Step 4: Re-run the new spec** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/authorSearch.ts` + +Expected: 6 passing. + +- [ ] **Step 5: Commit** + +```bash +git add src/node/db/AuthorManager.ts src/tests/backend/specs/admin/authorSearch.ts +git commit -m "feat(authors): authorManager.searchAuthors helper + +In-memory enumeration of globalAuthor:* with a join on mapper2author:* +for the mapper column. Filter (substring on name OR mapper), sort +(name | lastSeen), paginate, and cap the pre-pagination set at 1000 +to prevent runaway scans. Powers the upcoming /admin/authors page. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Three new admin-socket events + flag delivery + +**Files:** +- Modify: `src/node/hooks/express/adminsettings.ts` (add handlers; extend `load` reply with feature flag) +- Test: `src/tests/backend/specs/admin/anonymizeAuthorSocket.ts` (new) + +**Why:** Wire the search/preview/erase actions to the existing `io.of('/settings')` admin namespace, reusing the admin-auth gate that's already in place. The `gdprAuthorErasure.enabled` flag gates only the live erasure event — the read paths (browse + dry-run preview) stay usable so the UI is discoverable. + +- [ ] **Step 1: Write the failing socket-integration test** — create `src/tests/backend/specs/admin/anonymizeAuthorSocket.ts`: + +```typescript +'use strict'; + +import {strict as assert} from 'assert'; +const io = require('socket.io-client'); + +const common = require('../../common'); +const settings = require('../../../../node/utils/Settings'); +const authorManager = require('../../../../node/db/AuthorManager'); + +const adminSocket = async () => { + // Mirrors the /settings admin namespace gated by the express session's + // is_admin flag. The test bootstrap signs the admin in via the same JWT + // helper used by REST tests. + const baseUrl = (await common.init()).replace(/^http/, 'ws'); + const socket = io.connect(`${baseUrl}/settings`, { + transports: ['websocket'], + extraHeaders: { + authorization: `Bearer ${await common.generateJWTToken()}`, + }, + }); + await new Promise((res, rej) => { + socket.once('connect', res); + socket.once('connect_error', rej); + }); + return socket; +}; + +const ask = (socket: any, evt: string, payload: any, replyEvt: string) => + new Promise((res) => { + socket.once(replyEvt, res); + socket.emit(evt, payload); + }); + +describe(__filename, function () { + let socket: any; + let originalFlag: boolean; + + before(async function () { + this.timeout(60000); + settings.gdprAuthorErasure = settings.gdprAuthorErasure || {enabled: false}; + originalFlag = settings.gdprAuthorErasure.enabled; + settings.gdprAuthorErasure.enabled = true; + socket = await adminSocket(); + }); + + after(function () { + if (socket) socket.disconnect(); + settings.gdprAuthorErasure.enabled = originalFlag; + }); + + it('authorLoad returns paginated rows', async function () { + const tag = `sock-${Date.now()}`; + await authorManager.createAuthorIfNotExistsFor(`m-${tag}`, `Sock ${tag}`); + const res = await ask(socket, 'authorLoad', + {pattern: tag, offset: 0, limit: 12, sortBy: 'name', + ascending: true, includeErased: false}, + 'results:authorLoad'); + assert.ok(res.total >= 1, JSON.stringify(res)); + assert.ok(res.results.some((r: any) => r.name === `Sock ${tag}`)); + }); + + it('anonymizeAuthorPreview returns counters without flipping erased', + async function () { + const tag = `prev-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `Prev ${tag}`); + const preview = await ask(socket, 'anonymizeAuthorPreview', + {authorID}, 'results:anonymizeAuthorPreview'); + assert.equal(preview.authorID, authorID); + assert.ok(preview.removedExternalMappings >= 1); + const rec = await authorManager.getAuthor(authorID); + assert.equal(rec.erased, undefined, + 'preview must not flip erased'); + }); + + it('anonymizeAuthor commits when the flag is enabled', async function () { + const tag = `live-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `Live ${tag}`); + const res = await ask(socket, 'anonymizeAuthor', + {authorID}, 'results:anonymizeAuthor'); + assert.equal(res.authorID, authorID); + assert.ok(res.removedExternalMappings >= 1); + const rec = await authorManager.getAuthor(authorID); + assert.equal(rec.erased, true); + }); + + it('anonymizeAuthor returns {error: "disabled"} when flag is off', + async function () { + settings.gdprAuthorErasure.enabled = false; + try { + const tag = `disabled-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `Off ${tag}`); + const res = await ask(socket, 'anonymizeAuthor', + {authorID}, 'results:anonymizeAuthor'); + assert.equal(res.error, 'disabled'); + const rec = await authorManager.getAuthor(authorID); + assert.notEqual(rec.erased, true, + 'record should not be erased when flag is off'); + } finally { + settings.gdprAuthorErasure.enabled = true; + } + }); + + it('anonymizeAuthorPreview still works when flag is off (read-only)', + async function () { + settings.gdprAuthorErasure.enabled = false; + try { + const tag = `prev-off-${Date.now()}`; + const {authorID} = await authorManager.createAuthorIfNotExistsFor( + `m-${tag}`, `PrevOff ${tag}`); + const preview = await ask(socket, 'anonymizeAuthorPreview', + {authorID}, 'results:anonymizeAuthorPreview'); + assert.ok(preview.removedExternalMappings >= 1); + } finally { + settings.gdprAuthorErasure.enabled = true; + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from `src/`: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/anonymizeAuthorSocket.ts` + +Expected: every spec fails because the new events don't exist yet (`results:authorLoad` etc. never fire). + +- [ ] **Step 3: Add the three socket handlers + extend `load`** — in `src/node/hooks/express/adminsettings.ts`, immediately after the existing `socket.on('cleanupPadRevisions', …)` handler (around line 305), add: + +```typescript + const authorManager = require('../../db/AuthorManager'); + + socket.on('authorLoad', async (query: any) => { + try { + const data = await authorManager.searchAuthors({ + pattern: query.pattern || '', + offset: query.offset || 0, + limit: query.limit || 12, + sortBy: query.sortBy === 'lastSeen' ? 'lastSeen' : 'name', + ascending: query.ascending !== false, + includeErased: query.includeErased === true, + }); + socket.emit('results:authorLoad', data); + } catch (err: any) { + logger.error(`authorLoad failed: ${err.stack || err}`); + socket.emit('results:authorLoad', + {total: 0, results: [], error: String(err.message || err)}); + } + }); + + socket.on('anonymizeAuthorPreview', async ({authorID}: {authorID: string}) => { + try { + if (!authorID) { + socket.emit('results:anonymizeAuthorPreview', + {authorID, error: 'authorID is required'}); + return; + } + const rec = await authorManager.getAuthor(authorID); + const counters = + await authorManager.anonymizeAuthor(authorID, {dryRun: true}); + socket.emit('results:anonymizeAuthorPreview', + {authorID, name: rec ? rec.name : null, ...counters}); + } catch (err: any) { + logger.error(`anonymizeAuthorPreview failed: ${err.stack || err}`); + socket.emit('results:anonymizeAuthorPreview', + {authorID, error: String(err.message || err)}); + } + }); + + socket.on('anonymizeAuthor', async ({authorID}: {authorID: string}) => { + try { + if (!settings.gdprAuthorErasure || !settings.gdprAuthorErasure.enabled) { + socket.emit('results:anonymizeAuthor', {authorID, error: 'disabled'}); + return; + } + if (!authorID) { + socket.emit('results:anonymizeAuthor', + {authorID, error: 'authorID is required'}); + return; + } + const counters = await authorManager.anonymizeAuthor(authorID); + logger.info(`anonymizeAuthor (admin socket): ${authorID}`); + socket.emit('results:anonymizeAuthor', {authorID, ...counters}); + } catch (err: any) { + logger.error(`anonymizeAuthor failed: ${err.stack || err}`); + socket.emit('results:anonymizeAuthor', + {authorID, error: String(err.message || err)}); + } + }); +``` + +- [ ] **Step 4: Extend the `load` reply with the feature flag** — in the same file, replace the existing `socket.on('load', …)` handler body so the client also gets the GDPR flag: + +```typescript + socket.on('load', async (query: string): Promise => { + let data; + try { + data = await fsp.readFile(settings.settingsFilename, 'utf8'); + } catch (err) { + return logger.error(`Error loading settings: ${err}`); + } + const flags = { + gdprAuthorErasure: !!(settings.gdprAuthorErasure && + settings.gdprAuthorErasure.enabled), + }; + if (settings.showSettingsInAdminPage === false) { + socket.emit('settings', {results: 'NOT_ALLOWED', flags}); + } else { + socket.emit('settings', {results: data, flags}); + } + }); +``` + +- [ ] **Step 5: Re-run the socket spec** + +Run: `NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 ./tests/backend/specs/admin/anonymizeAuthorSocket.ts` + +Expected: 5 passing. + +- [ ] **Step 6: Commit** + +```bash +git add src/node/hooks/express/adminsettings.ts src/tests/backend/specs/admin/anonymizeAuthorSocket.ts +git commit -m "feat(authors): admin-socket events for author erasure UI + +Adds three handlers on the /settings admin namespace: +- authorLoad: paginated search via authorManager.searchAuthors +- anonymizeAuthorPreview: dry-run counters, always available to + authenticated admins (read-only) +- anonymizeAuthor: live commit, gated on gdprAuthorErasure.enabled + (returns {error: 'disabled'} when off) + +Extends the load reply with a flags.gdprAuthorErasure boolean so the +client knows whether to render the disabled-flag banner without an +extra round-trip. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Frontend types, ColorSwatch, and i18n strings + +**Files:** +- Create: `admin/src/utils/AuthorSearch.ts` +- Create: `admin/src/components/ColorSwatch.tsx` +- Create: `admin/public/ep_admin_authors/en.json` + +**Why:** Standalone primitives for the page to consume. Doing this first lets the page implementation in Task 7 reference real types and real keys. + +- [ ] **Step 1: Create the types file** — `admin/src/utils/AuthorSearch.ts`: + +```typescript +export type AuthorSortBy = 'name' | 'lastSeen'; + +export type AuthorSearchQuery = { + pattern: string; + offset: number; + limit: number; + sortBy: AuthorSortBy; + ascending: boolean; + includeErased: boolean; +}; + +export type AuthorRow = { + authorID: string; + name: string | null; + colorId: string | number | null; + mapper: string[]; + lastSeen: number | null; + erased: boolean; +}; + +export type AuthorSearchResult = { + total: number; + cappedAt?: number; + results: AuthorRow[]; + error?: string; +}; + +export type AnonymizePreview = { + authorID: string; + name: string | null; + affectedPads: number; + removedTokenMappings: number; + removedExternalMappings: number; + clearedChatMessages: number; + error?: string; +}; + +export type AnonymizeResult = { + authorID: string; + affectedPads?: number; + removedTokenMappings?: number; + removedExternalMappings?: number; + clearedChatMessages?: number; + error?: string; +}; +``` + +- [ ] **Step 2: Create the swatch component** — `admin/src/components/ColorSwatch.tsx`: + +```tsx +type Props = { + color: string | number | null; + size?: number; +}; + +// Resolves the colorId stored on globalAuthor records into a CSS color. +// AuthorManager stores either a string hex (legacy) or an integer index +// into the palette returned by getColorPalette() — we re-derive the +// palette here rather than fetch it because the order is stable and the +// admin already has many other small constants inline. +const PALETTE = [ + '#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff', + '#e3c7ff', '#ffc7f1', '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3', + '#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5', '#e7b1b1', '#e9dcAf', + '#cde9af', '#bfedcc', '#b1e7e7', '#c3cdee', '#d2b8ea', '#eec3e6', + '#e9cece', '#e7e0ca', '#d3e5c7', '#bce1c5', '#c1e2e2', '#c1c9e2', + '#cfc1e2', '#e0bdd9', '#baded3', '#a0f8eb', '#b1e7e0', '#c3c8e4', + '#cec5e2', '#b1d5e7', '#cda8f0', '#f0f0a8', '#f2f2a6', '#f5a8eb', + '#c5f9a9', '#ececbb', '#e7c4bc', '#daf0b2', '#b0a0fd', '#bce2e7', + '#cce2bb', '#ec9afe', '#edabbd', '#aeaeea', '#c4e7b1', '#d722bb', + '#f3a5e7', '#ffa8a8', '#d8c0c5', '#eaaedd', '#adc6eb', '#bedad1', + '#dee9af', '#e9afc2', '#f8d2a0', '#b3b3e6', +]; + +export const ColorSwatch = ({color, size = 14}: Props) => { + let resolved = '#ccc'; + if (typeof color === 'string') { + resolved = color; + } else if (typeof color === 'number' && color >= 0 && color < PALETTE.length) { + resolved = PALETTE[color]; + } + return ; +}; +``` + +- [ ] **Step 3: Create the i18n file** — `admin/public/ep_admin_authors/en.json`: + +```json +{ + "ep_admin_authors:title": "Authors", + "ep_admin_authors:search-placeholder": "Search by name or mapper", + "ep_admin_authors:column.color": "Color", + "ep_admin_authors:column.name": "Name", + "ep_admin_authors:column.mapper": "Mapper", + "ep_admin_authors:column.last-seen": "Last seen", + "ep_admin_authors:column.author-id": "Author ID", + "ep_admin_authors:column.actions": "Actions", + "ep_admin_authors:show-erased": "Show erased authors", + "ep_admin_authors:erase": "Erase", + "ep_admin_authors:erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.", + "ep_admin_authors:erased-stub": "(erased)", + "ep_admin_authors:cap-warning": "Showing the first 1000 authors. Narrow your search to see more.", + "ep_admin_authors:feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.", + "ep_admin_authors:no-results": "No authors match this search.", + "ep_admin_authors:confirm-preview-title": "Erase author {{name}}", + "ep_admin_authors:confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.", + "ep_admin_authors:confirm-irreversible": "This cannot be undone.", + "ep_admin_authors:cancel": "Cancel", + "ep_admin_authors:continue": "Continue", + "ep_admin_authors:erasing": "Erasing…", + "ep_admin_authors:erase-success-toast": "Author {{authorID}} erased.", + "ep_admin_authors:erase-error-toast": "Erase failed: {{error}}", + "ep_admin_authors:no-mappers": "—", + "ep_admin_authors:never-seen": "—" +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add admin/src/utils/AuthorSearch.ts admin/src/components/ColorSwatch.tsx admin/public/ep_admin_authors/en.json +git commit -m "feat(admin): types, ColorSwatch, and en.json for authors page + +Standalone primitives for the upcoming /admin/authors page: +- AuthorSearch.ts: query/result/preview wire types matching the new + admin-socket events +- ColorSwatch.tsx: resolves a globalAuthor.colorId (palette index or + raw hex) to a small inline-styled swatch +- ep_admin_authors/en.json: every user-visible string the page needs, + loaded by the existing namespace-as-static-asset i18n strategy + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Store slice, route, and sidebar link + +**Files:** +- Modify: `admin/src/store/store.ts:1-50` (and the `useStore` initializer further down) +- Modify: `admin/src/main.tsx` +- Modify: `admin/src/App.tsx:103-110` (sidebar `
    `) and `:73-81` (`settings` event handler) + +**Why:** Wire the new page into the admin shell before building it. + +- [ ] **Step 1: Extend the store** — in `admin/src/store/store.ts`, add the import + state slice. Replace the existing `import {PadSearchResult} …` line with: + +```typescript +import {PadSearchResult} from "../utils/PadSearch.ts"; +import {AuthorSearchResult} from "../utils/AuthorSearch.ts"; +``` + +Then in the `StoreState` type, append before the closing `}`: + +```typescript + authors: AuthorSearchResult|undefined, + setAuthors: (authors: AuthorSearchResult)=>void, + gdprAuthorErasureEnabled: boolean, + setGdprAuthorErasureEnabled: (enabled: boolean)=>void, +``` + +In the `create(…)` call body (search the file for `setPads:`), append: + +```typescript + authors: undefined, + setAuthors: (authors)=>set({authors}), + gdprAuthorErasureEnabled: false, + setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}), +``` + +- [ ] **Step 2: Register the route** — in `admin/src/main.tsx`, add the import: + +```typescript +import {AuthorPage} from "./pages/AuthorPage.tsx"; +``` + +And add inside the `}>` block (after the `` line): + +```tsx + }/> +``` + +- [ ] **Step 3: Add the sidebar link** — in `admin/src/App.tsx`, extend the existing lucide-react import line: + +```typescript +import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react"; +``` + +In the sidebar `
      ` block (currently around line 103-109), insert a new `
    • ` immediately after the Pads `
    • ` and before Shout: + +```tsx +
    • +``` + +- [ ] **Step 4: Capture the flag from the existing `settings` event** — in `admin/src/App.tsx`, replace the `settingSocket.on('settings', …)` handler body: + +```typescript + settingSocket.on('settings', (settings: any) => { + // Pick up the GDPR-erasure feature flag from the same payload that + // also carries the settings.json blob. The flag drives the disabled + // banner on /admin/authors; we read it once here so the page is + // ready to render without an extra round trip. + if (settings && typeof settings.flags === 'object' && settings.flags) { + useStore.getState().setGdprAuthorErasureEnabled( + !!settings.flags.gdprAuthorErasure); + } + if (settings.results === 'NOT_ALLOWED') { + console.log('Not allowed to view settings.json') + return; + } + if (isJSONClean(settings.results)) { + setSettings(settings.results); + } else { + alert('Invalid JSON'); + } + useStore.getState().setShowLoading(false); + }); +``` + +- [ ] **Step 5: Verify the admin still builds** + +Run from repo root: `pnpm --filter etherpad-admin run build 2>&1 | tail -10` + +Expected: build completes (will fail with `Cannot find module './pages/AuthorPage.tsx'` because Task 7 hasn't run yet). At this checkpoint, **proceed to Task 7 and commit Tasks 6+7 together** — committing a half-wired route would leave the build broken. + +(If the admin package name in `admin/package.json` differs from `etherpad-admin`, run the build from `admin/` directly: `cd admin && pnpm run build`.) + +- [ ] **Step 6: Skip commit until Task 7 lands** + +The sidebar link points at a route whose component doesn't exist yet. Continue to Task 7; commit the two together. + +--- + +## Task 7: `AuthorPage.tsx` — table, search, sort, pagination, disabled banner + +**Files:** +- Create: `admin/src/pages/AuthorPage.tsx` + +**Why:** The actual page. Mirrors `PadPage.tsx`'s shape (search field, sortable headers, pagination, Radix dialog) so reviewers see one familiar pattern. + +- [ ] **Step 1: Create `admin/src/pages/AuthorPage.tsx`**: + +```tsx +import {Trans, useTranslation} from "react-i18next"; +import {useEffect, useMemo, useState} from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import {ChevronLeft, ChevronRight, Trash2} from "lucide-react"; +import {useStore} from "../store/store.ts"; +import {SearchField} from "../components/SearchField.tsx"; +import {ColorSwatch} from "../components/ColorSwatch.tsx"; +import {IconButton} from "../components/IconButton.tsx"; +import {determineSorting} from "../utils/sorting.ts"; +import {useDebounce} from "../utils/useDebounce.ts"; +import { + AnonymizePreview, AnonymizeResult, AuthorRow, AuthorSearchQuery, + AuthorSearchResult, AuthorSortBy, +} from "../utils/AuthorSearch.ts"; + +type DialogState = + | {phase: 'closed'} + | {phase: 'loading-preview', authorID: string, name: string | null} + | {phase: 'preview', preview: AnonymizePreview} + | {phase: 'committing', preview: AnonymizePreview}; + +export const AuthorPage = () => { + const {t} = useTranslation(); + const settingsSocket = useStore((s) => s.settingsSocket); + const authors = useStore((s) => s.authors); + const setAuthors = useStore((s) => s.setAuthors); + const erasureEnabled = useStore((s) => s.gdprAuthorErasureEnabled); + + const [searchTerm, setSearchTerm] = useState(''); + const [includeErased, setIncludeErased] = useState(false); + const [searchParams, setSearchParams] = useState({ + pattern: '', offset: 0, limit: 12, + sortBy: 'name', ascending: true, includeErased: false, + }); + const [currentPage, setCurrentPage] = useState(0); + const [dialog, setDialog] = useState({phase: 'closed'}); + + const pages = useMemo(() => { + if (!authors) return 0; + return Math.ceil(authors.total / searchParams.limit); + }, [authors, searchParams.limit]); + + useDebounce(() => { + setCurrentPage(0); + setSearchParams((p) => ({...p, pattern: searchTerm, offset: 0})); + }, 500, [searchTerm]); + + useEffect(() => { + setSearchParams((p) => ({...p, includeErased, offset: 0})); + setCurrentPage(0); + }, [includeErased]); + + useEffect(() => { + if (!settingsSocket) return; + settingsSocket.emit('authorLoad', searchParams); + }, [settingsSocket, searchParams]); + + useEffect(() => { + if (!settingsSocket) return; + const onLoad = (data: AuthorSearchResult) => setAuthors(data); + const onPreview = (data: AnonymizePreview) => { + // Ignore stale previews if the user closed the dialog. + setDialog((cur) => + cur.phase === 'loading-preview' && cur.authorID === data.authorID + ? {phase: 'preview', preview: data} + : cur); + }; + const onErase = (data: AnonymizeResult) => { + if (data.error) { + useStore.getState().setToastState({ + open: true, success: false, + title: t('ep_admin_authors:erase-error-toast', {error: data.error}), + }); + setDialog({phase: 'closed'}); + return; + } + useStore.getState().setToastState({ + open: true, success: true, + title: t('ep_admin_authors:erase-success-toast', {authorID: data.authorID}), + }); + // Patch the row in place so the user sees it become an erased stub + // without a refetch flicker. + const cur = useStore.getState().authors; + if (cur) { + setAuthors({ + ...cur, + results: cur.results.map((r): AuthorRow => + r.authorID === data.authorID + ? {...r, name: null, erased: true, mapper: []} + : r), + }); + } + setDialog({phase: 'closed'}); + }; + settingsSocket.on('results:authorLoad', onLoad); + settingsSocket.on('results:anonymizeAuthorPreview', onPreview); + settingsSocket.on('results:anonymizeAuthor', onErase); + return () => { + settingsSocket.off('results:authorLoad', onLoad); + settingsSocket.off('results:anonymizeAuthorPreview', onPreview); + settingsSocket.off('results:anonymizeAuthor', onErase); + }; + }, [settingsSocket, setAuthors, t]); + + const sortBy = (col: AuthorSortBy) => () => { + setCurrentPage(0); + setSearchParams((p) => ({ + ...p, sortBy: col, + ascending: p.sortBy === col ? !p.ascending : true, + offset: 0, + })); + }; + + const openErase = (row: AuthorRow) => { + setDialog({phase: 'loading-preview', authorID: row.authorID, name: row.name}); + settingsSocket?.emit('anonymizeAuthorPreview', {authorID: row.authorID}); + }; + + const commitErase = () => { + if (dialog.phase !== 'preview') return; + setDialog({phase: 'committing', preview: dialog.preview}); + settingsSocket?.emit('anonymizeAuthor', {authorID: dialog.preview.authorID}); + }; + + const lastSeenLabel = (row: AuthorRow) => + row.lastSeen + ? new Date(row.lastSeen).toLocaleString() + : t('ep_admin_authors:never-seen'); + + const mapperLabel = (row: AuthorRow) => { + if (row.mapper.length === 0) return t('ep_admin_authors:no-mappers'); + if (row.mapper.length === 1) return row.mapper[0]; + return `${row.mapper[0]} +${row.mapper.length - 1}`; + }; + + return
      + {!erasureEnabled && ( +
      + +
      + )} + + + + + + {dialog.phase === 'loading-preview' &&
      + +
      } + {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { + const p = dialog.preview; + return
      +

      {t('ep_admin_authors:confirm-preview-title', + {name: p.name || p.authorID})}

      +

      {t('ep_admin_authors:confirm-preview-counters', { + tokenMappings: p.removedTokenMappings, + externalMappings: p.removedExternalMappings, + chatMessages: p.clearedChatMessages, + affectedPads: p.affectedPads, + })}

      +

      + +

      +
      + + +
      +
      ; + })()} +
      +
      +
      + + +

      + +

      +
      + + setSearchTerm(v.target.value)} + placeholder={t('ep_admin_authors:search-placeholder')}/> + + + + {authors?.cappedAt != null && ( +

      + +

      + )} + + + + + + + + + + + + + + {authors?.results.length === 0 && } + {authors?.results.map((row) => ( + + + + + + + + + ))} + +
      + + + +
      + +
      + {row.erased + ? + : (row.name ?? '—')} + + {mapperLabel(row)} + {lastSeenLabel(row)} + {row.authorID} + +
      + } + title={} + onClick={() => openErase(row)} + {...(!erasureEnabled || row.erased + ? {disabled: true, + 'data-disabled-reason': + t('ep_admin_authors:erase-disabled-tooltip')} + : {})}/> +
      +
      + +
      + + {currentPage + 1} out of {pages} + +
      +
      ; +}; +``` + +- [ ] **Step 2: Verify the admin builds end-to-end** + +Run from repo root: `cd admin && pnpm run build 2>&1 | tail -15` + +Expected: build succeeds. If the IconButton component doesn't accept a `disabled` prop, drop the spread and instead skip rendering the button when `!erasureEnabled || row.erased` (replace the IconButton with a `disabled` `
    diff --git a/admin/src/localization/i18n.ts b/admin/src/localization/i18n.ts index 048601b5233..31f1e5cb36d 100644 --- a/admin/src/localization/i18n.ts +++ b/admin/src/localization/i18n.ts @@ -58,7 +58,7 @@ i18n .use(initReactI18next) .init( { - ns: ['translation','ep_admin_pads'], + ns: ['translation','ep_admin_pads','ep_admin_authors'], fallbackLng: 'en' } ) diff --git a/admin/src/main.tsx b/admin/src/main.tsx index c7dcc456bf6..79c57d4100b 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -11,6 +11,7 @@ import * as Toast from '@radix-ui/react-toast' import {I18nextProvider} from "react-i18next"; import i18n from "./localization/i18n.ts"; import {PadPage} from "./pages/PadPage.tsx"; +import {AuthorPage} from "./pages/AuthorPage.tsx"; import {ToastDialog} from "./utils/Toast.tsx"; import {ShoutPage} from "./pages/ShoutPage.tsx"; import {UpdatePage} from "./pages/UpdatePage.tsx"; @@ -22,6 +23,7 @@ const router = createBrowserRouter(createRoutesFromElements( }/> }/> }/> + }/> }/> }/> diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx new file mode 100644 index 00000000000..fff65105fed --- /dev/null +++ b/admin/src/pages/AuthorPage.tsx @@ -0,0 +1,279 @@ +import {Trans, useTranslation} from "react-i18next"; +import {useEffect, useMemo, useState} from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import {ChevronLeft, ChevronRight, Trash2} from "lucide-react"; +import {useStore} from "../store/store.ts"; +import {SearchField} from "../components/SearchField.tsx"; +import {ColorSwatch} from "../components/ColorSwatch.tsx"; +import {IconButton} from "../components/IconButton.tsx"; +import {determineSorting} from "../utils/sorting.ts"; +import {useDebounce} from "../utils/useDebounce.ts"; +import { + AnonymizePreview, AnonymizeResult, AuthorRow, AuthorSearchQuery, + AuthorSearchResult, AuthorSortBy, +} from "../utils/AuthorSearch.ts"; + +type DialogState = + | {phase: 'closed'} + | {phase: 'loading-preview', authorID: string, name: string | null} + | {phase: 'preview', preview: AnonymizePreview} + | {phase: 'committing', preview: AnonymizePreview}; + +export const AuthorPage = () => { + const {t} = useTranslation(); + const settingsSocket = useStore((s) => s.settingsSocket); + const authors = useStore((s) => s.authors); + const setAuthors = useStore((s) => s.setAuthors); + const erasureEnabled = useStore((s) => s.gdprAuthorErasureEnabled); + + const [searchTerm, setSearchTerm] = useState(''); + const [includeErased, setIncludeErased] = useState(false); + const [searchParams, setSearchParams] = useState({ + pattern: '', offset: 0, limit: 12, + sortBy: 'name', ascending: true, includeErased: false, + }); + const [currentPage, setCurrentPage] = useState(0); + const [dialog, setDialog] = useState({phase: 'closed'}); + + const pages = useMemo(() => { + if (!authors) return 0; + return Math.ceil(authors.total / searchParams.limit); + }, [authors, searchParams.limit]); + + useDebounce(() => { + setCurrentPage(0); + setSearchParams((p) => ({...p, pattern: searchTerm, offset: 0})); + }, 500, [searchTerm]); + + useEffect(() => { + setSearchParams((p) => ({...p, includeErased, offset: 0})); + setCurrentPage(0); + }, [includeErased]); + + useEffect(() => { + if (!settingsSocket) return; + settingsSocket.emit('authorLoad', searchParams); + }, [settingsSocket, searchParams]); + + useEffect(() => { + if (!settingsSocket) return; + const onLoad = (data: AuthorSearchResult) => setAuthors(data); + const onPreview = (data: AnonymizePreview) => { + setDialog((cur) => + cur.phase === 'loading-preview' && cur.authorID === data.authorID + ? {phase: 'preview', preview: data} + : cur); + }; + const onErase = (data: AnonymizeResult) => { + if (data.error) { + useStore.getState().setToastState({ + open: true, success: false, + title: t('ep_admin_authors:erase-error-toast', {error: data.error}), + }); + setDialog({phase: 'closed'}); + return; + } + useStore.getState().setToastState({ + open: true, success: true, + title: t('ep_admin_authors:erase-success-toast', {authorID: data.authorID}), + }); + const cur = useStore.getState().authors; + if (cur) { + setAuthors({ + ...cur, + results: cur.results.map((r): AuthorRow => + r.authorID === data.authorID + ? {...r, name: null, erased: true, mapper: []} + : r), + }); + } + setDialog({phase: 'closed'}); + }; + settingsSocket.on('results:authorLoad', onLoad); + settingsSocket.on('results:anonymizeAuthorPreview', onPreview); + settingsSocket.on('results:anonymizeAuthor', onErase); + return () => { + settingsSocket.off('results:authorLoad', onLoad); + settingsSocket.off('results:anonymizeAuthorPreview', onPreview); + settingsSocket.off('results:anonymizeAuthor', onErase); + }; + }, [settingsSocket, setAuthors, t]); + + const sortBy = (col: AuthorSortBy) => () => { + setCurrentPage(0); + setSearchParams((p) => ({ + ...p, sortBy: col, + ascending: p.sortBy === col ? !p.ascending : true, + offset: 0, + })); + }; + + const openErase = (row: AuthorRow) => { + setDialog({phase: 'loading-preview', authorID: row.authorID, name: row.name}); + settingsSocket?.emit('anonymizeAuthorPreview', {authorID: row.authorID}); + }; + + const commitErase = () => { + if (dialog.phase !== 'preview') return; + setDialog({phase: 'committing', preview: dialog.preview}); + settingsSocket?.emit('anonymizeAuthor', {authorID: dialog.preview.authorID}); + }; + + const lastSeenLabel = (row: AuthorRow) => + row.lastSeen + ? new Date(row.lastSeen).toLocaleString() + : t('ep_admin_authors:never-seen'); + + const mapperLabel = (row: AuthorRow) => { + if (row.mapper.length === 0) return t('ep_admin_authors:no-mappers'); + if (row.mapper.length === 1) return row.mapper[0]; + return `${row.mapper[0]} +${row.mapper.length - 1}`; + }; + + return
    + {!erasureEnabled && ( +
    + +
    + )} + + + + + + {dialog.phase === 'loading-preview' &&
    + +
    } + {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { + const p = dialog.preview; + return
    +

    {t('ep_admin_authors:confirm-preview-title', + {name: p.name || p.authorID})}

    +

    {t('ep_admin_authors:confirm-preview-counters', { + tokenMappings: p.removedTokenMappings, + externalMappings: p.removedExternalMappings, + chatMessages: p.clearedChatMessages, + affectedPads: p.affectedPads, + })}

    +

    + +

    +
    + + +
    +
    ; + })()} +
    +
    +
    + + +

    + +

    +
    + + setSearchTerm(v.target.value)} + placeholder={t('ep_admin_authors:search-placeholder')}/> + + + + {authors?.cappedAt != null && ( +

    + +

    + )} + + + + + + + + + + + + + + {authors?.results.length === 0 && } + {authors?.results.map((row) => ( + + + + + + + + + ))} + +
    + + + +
    + +
    + {row.erased + ? + : (row.name ?? '—')} + + {mapperLabel(row)} + {lastSeenLabel(row)} + {row.authorID} + +
    + } + title={} + onClick={() => openErase(row)} + {...(!erasureEnabled || row.erased + ? {disabled: true, + 'data-disabled-reason': + t('ep_admin_authors:erase-disabled-tooltip')} + : {})}/> +
    +
    + +
    + + {currentPage + 1} out of {pages} + +
    +
    ; +}; diff --git a/admin/src/store/store.ts b/admin/src/store/store.ts index f3748f47cd4..61b230a2167 100644 --- a/admin/src/store/store.ts +++ b/admin/src/store/store.ts @@ -1,6 +1,7 @@ import {create} from "zustand"; import {Socket} from "socket.io-client"; import {PadSearchResult} from "../utils/PadSearch.ts"; +import {AuthorSearchResult} from "../utils/AuthorSearch.ts"; import {InstalledPlugin} from "../pages/Plugin.ts"; export interface UpdateStatusPayload { @@ -45,6 +46,10 @@ type StoreState = { setInstalledPlugins: (plugins: InstalledPlugin[])=>void, updateStatus: UpdateStatusPayload | null, setUpdateStatus: (s: UpdateStatusPayload) => void, + authors: AuthorSearchResult|undefined, + setAuthors: (authors: AuthorSearchResult)=>void, + gdprAuthorErasureEnabled: boolean, + setGdprAuthorErasureEnabled: (enabled: boolean)=>void, } @@ -70,4 +75,8 @@ export const useStore = create()((set) => ({ setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}), updateStatus: null, setUpdateStatus: (s) => set({updateStatus: s}), + authors: undefined, + setAuthors: (authors)=>set({authors}), + gdprAuthorErasureEnabled: false, + setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}), })); From 016648b2019dd5255960248c456cfc13f78a0a85 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 19:25:52 +0100 Subject: [PATCH 12/17] fix(authors): i18n the pagination controls Spec review caught three hardcoded English strings in the /admin/authors pagination footer ('Previous Page', 'Next Page', 'X out of Y'). Carried over from PadPage.tsx via the plan template, which had the same gap. Added three new keys to ep_admin_authors and routed the spans through Trans/t(). Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/public/ep_admin_authors/en.json | 5 ++++- admin/src/pages/AuthorPage.tsx | 11 ++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/admin/public/ep_admin_authors/en.json b/admin/public/ep_admin_authors/en.json index 4f2af3b8082..d8c568736e3 100644 --- a/admin/public/ep_admin_authors/en.json +++ b/admin/public/ep_admin_authors/en.json @@ -23,5 +23,8 @@ "ep_admin_authors:erase-success-toast": "Author {{authorID}} erased.", "ep_admin_authors:erase-error-toast": "Erase failed: {{error}}", "ep_admin_authors:no-mappers": "—", - "ep_admin_authors:never-seen": "—" + "ep_admin_authors:never-seen": "—", + "ep_admin_authors:prev-page": "Previous Page", + "ep_admin_authors:next-page": "Next Page", + "ep_admin_authors:page-counter": "{{current}} out of {{total}}" } diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx index fff65105fed..e38db6edb96 100644 --- a/admin/src/pages/AuthorPage.tsx +++ b/admin/src/pages/AuthorPage.tsx @@ -266,14 +266,19 @@ export const AuthorPage = () => { setCurrentPage(currentPage - 1); setSearchParams((p) => ({...p, offset: (currentPage - 1) * searchParams.limit})); - }}>Previous Page - {currentPage + 1} out of {pages} + }}> + + + {t('ep_admin_authors:page-counter', + {current: currentPage + 1, total: pages})} + }}> + + ; }; From 35383ba1e9692492990f734d54733a51f6b1f95e Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 3 May 2026 19:32:12 +0100 Subject: [PATCH 13/17] fix(authors): banner CSS, IconButton attribute drop, erase phase string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review on the AuthorPage commit caught three issues: - Disabled-flag banner used dialog-confirm-content classname which is position: fixed + centered + z-index: 101, making it render as a modal-style overlay over the table. Drop the className and define the banner with inline styles only; add role='alert' for SR users. - The Erase IconButton spread {data-disabled-reason: …} alongside {disabled: true}, but IconButton only forwards a small allowlist of props — the data attribute was silently dropped. Replaced with a conditional title that flips to the disabled-reason string when the button is disabled (which IconButton does forward). - 'Erasing…' string was rendered during loading-preview, but the string literally describes the commit phase. Added a new loading-preview key for the preview-loading state, and surface the existing 'erasing' string under the buttons during the committing phase. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/public/ep_admin_authors/en.json | 1 + admin/src/pages/AuthorPage.tsx | 24 ++++++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/admin/public/ep_admin_authors/en.json b/admin/public/ep_admin_authors/en.json index d8c568736e3..2cbb6161e65 100644 --- a/admin/public/ep_admin_authors/en.json +++ b/admin/public/ep_admin_authors/en.json @@ -19,6 +19,7 @@ "ep_admin_authors:confirm-irreversible": "This cannot be undone.", "ep_admin_authors:cancel": "Cancel", "ep_admin_authors:continue": "Continue", + "ep_admin_authors:loading-preview": "Loading preview…", "ep_admin_authors:erasing": "Erasing…", "ep_admin_authors:erase-success-toast": "Author {{authorID}} erased.", "ep_admin_authors:erase-error-toast": "Erase failed: {{error}}", diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx index e38db6edb96..b73cb739d51 100644 --- a/admin/src/pages/AuthorPage.tsx +++ b/admin/src/pages/AuthorPage.tsx @@ -132,9 +132,10 @@ export const AuthorPage = () => { return
    {!erasureEnabled && ( -
    + background: '#fff8e1', border: '1px solid #f0c36d', + borderRadius: 4}}>
    @@ -145,7 +146,7 @@ export const AuthorPage = () => { {dialog.phase === 'loading-preview' &&
    - +
    } {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { const p = dialog.preview; @@ -176,6 +177,10 @@ export const AuthorPage = () => { ns="ep_admin_authors"/>
    + {dialog.phase === 'committing' &&

    + +

    } ; })()} @@ -246,14 +251,13 @@ export const AuthorPage = () => {
    } - title={} + title={} onClick={() => openErase(row)} - {...(!erasureEnabled || row.erased - ? {disabled: true, - 'data-disabled-reason': - t('ep_admin_authors:erase-disabled-tooltip')} - : {})}/> + disabled={!erasureEnabled || row.erased}/>
    From 51ef92a85251cbfd9c8408e7a397460256e0806b Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 4 May 2026 07:42:55 +0100 Subject: [PATCH 14/17] test(admin): Playwright /admin/authors + fix i18n key shape The earlier en.json shipped namespace-prefixed JSON keys ('ep_admin_authors:title': 'Authors') which is the wrong shape: i18next splits the lookup on ':' to extract the namespace, then looks up the bare key in the loaded namespace data. The existing convention (admin/public/ep_admin_pads/en.json) uses flat keys without the namespace prefix; matching it makes every resolve to the intended translated string. Strings render as English fallback without this fix; only the page-title test passes (and only by substring accident). Also adds the Playwright coverage required by Task 8: localized title, empty-state message on a fresh search tag, disabled banner toggling with gdprAuthorErasure.enabled. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/public/ep_admin_authors/en.json | 58 ++++++++-------- .../admin-spec/admin_authors_page.spec.ts | 68 +++++++++++++++++++ 2 files changed, 97 insertions(+), 29 deletions(-) create mode 100644 src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts diff --git a/admin/public/ep_admin_authors/en.json b/admin/public/ep_admin_authors/en.json index 2cbb6161e65..d4215b3b478 100644 --- a/admin/public/ep_admin_authors/en.json +++ b/admin/public/ep_admin_authors/en.json @@ -1,31 +1,31 @@ { - "ep_admin_authors:title": "Authors", - "ep_admin_authors:search-placeholder": "Search by name or mapper", - "ep_admin_authors:column.color": "Color", - "ep_admin_authors:column.name": "Name", - "ep_admin_authors:column.mapper": "Mapper", - "ep_admin_authors:column.last-seen": "Last seen", - "ep_admin_authors:column.author-id": "Author ID", - "ep_admin_authors:column.actions": "Actions", - "ep_admin_authors:show-erased": "Show erased authors", - "ep_admin_authors:erase": "Erase", - "ep_admin_authors:erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.", - "ep_admin_authors:erased-stub": "(erased)", - "ep_admin_authors:cap-warning": "Showing the first 1000 authors. Narrow your search to see more.", - "ep_admin_authors:feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.", - "ep_admin_authors:no-results": "No authors match this search.", - "ep_admin_authors:confirm-preview-title": "Erase author {{name}}", - "ep_admin_authors:confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.", - "ep_admin_authors:confirm-irreversible": "This cannot be undone.", - "ep_admin_authors:cancel": "Cancel", - "ep_admin_authors:continue": "Continue", - "ep_admin_authors:loading-preview": "Loading preview…", - "ep_admin_authors:erasing": "Erasing…", - "ep_admin_authors:erase-success-toast": "Author {{authorID}} erased.", - "ep_admin_authors:erase-error-toast": "Erase failed: {{error}}", - "ep_admin_authors:no-mappers": "—", - "ep_admin_authors:never-seen": "—", - "ep_admin_authors:prev-page": "Previous Page", - "ep_admin_authors:next-page": "Next Page", - "ep_admin_authors:page-counter": "{{current}} out of {{total}}" + "title": "Authors", + "search-placeholder": "Search by name or mapper", + "column.color": "Color", + "column.name": "Name", + "column.mapper": "Mapper", + "column.last-seen": "Last seen", + "column.author-id": "Author ID", + "column.actions": "Actions", + "show-erased": "Show erased authors", + "erase": "Erase", + "erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.", + "erased-stub": "(erased)", + "cap-warning": "Showing the first 1000 authors. Narrow your search to see more.", + "feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.", + "no-results": "No authors match this search.", + "confirm-preview-title": "Erase author {{name}}", + "confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.", + "confirm-irreversible": "This cannot be undone.", + "cancel": "Cancel", + "continue": "Continue", + "loading-preview": "Loading preview…", + "erasing": "Erasing…", + "erase-success-toast": "Author {{authorID}} erased.", + "erase-error-toast": "Erase failed: {{error}}", + "no-mappers": "—", + "never-seen": "—", + "prev-page": "Previous Page", + "next-page": "Next Page", + "page-counter": "{{current}} out of {{total}}" } diff --git a/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts b/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts new file mode 100644 index 00000000000..4c5a4fe2d26 --- /dev/null +++ b/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts @@ -0,0 +1,68 @@ +import {expect, test} from "@playwright/test"; +import {loginToAdmin, saveSettings} from "../helper/adminhelper"; + +// /admin tests run serially because they mutate global server state. +test.describe.configure({mode: 'serial'}); + +const ADMIN_URL = 'http://localhost:9001/admin'; + +const setErasureFlag = async (page: any, enabled: boolean) => { + await page.goto(`${ADMIN_URL}/settings`); + await page.waitForSelector('.settings'); + const settings = page.locator('.settings'); + await expect(settings).not.toHaveValue('', {timeout: 30000}); + const raw = await settings.inputValue(); + const obj = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\//g, '')); + obj.gdprAuthorErasure = {enabled}; + await settings.fill(JSON.stringify(obj)); + await saveSettings(page); +}; + +test.describe('admin authors page', () => { + test.beforeEach(async ({page}) => { + await loginToAdmin(page, 'admin', 'changeme1'); + }); + + test('renders the localized page title', async ({page}) => { + await page.goto(`${ADMIN_URL}/authors`); + await expect(page.getByRole('heading', {name: 'Authors'})) + .toBeVisible({timeout: 30000}); + }); + + test('search filters the table to a matching author', async ({page}) => { + const tag = `pw-${Date.now()}`; + await page.goto(`${ADMIN_URL}/authors`); + await page.waitForSelector('table'); + const search = page.getByPlaceholder('Search by name or mapper'); + await search.fill(tag); + await expect(page.getByText('No authors match this search.')) + .toBeVisible({timeout: 5000}); + }); + + test('disabled banner shows when gdprAuthorErasure.enabled = false', + async ({page}) => { + await setErasureFlag(page, false); + await page.goto(`${ADMIN_URL}/authors`); + await expect(page.getByRole('alert')) + .toContainText('Author erasure is disabled.', {timeout: 30000}); + }); + + test('disabled banner is hidden when gdprAuthorErasure.enabled = true', + async ({page}) => { + await setErasureFlag(page, true); + await page.goto(`${ADMIN_URL}/authors`); + await page.waitForSelector('table'); + await expect(page.getByRole('alert')).toHaveCount(0); + }); + + test.afterAll(async ({browser}) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await loginToAdmin(page, 'admin', 'changeme1'); + await setErasureFlag(page, false); + } finally { + await ctx.close(); + } + }); +}); From 7472e48bd6ed45f8775df463dfbe62bdea2f9dbc Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 4 May 2026 07:53:06 +0100 Subject: [PATCH 15/17] fix(test): JSONC-tolerant settings parse + sidebar count = 7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on PR #7667 surfaced two test failures caused by my changes: 1. setErasureFlag() in admin_authors_page.spec.ts used JSON.parse on the raw settings.json textarea content. The CI environment loads settings.json.template which has unquoted property names, trailing commas, and block + line comments — JSON.parse rejects all three. Switched to `new Function('return (' + raw + ')')` which evaluates the textarea as a JS object literal, accepting every shape Etherpad's own settings loader handles. 2. admintroubleshooting.spec.ts hardcoded `menu.locator('li').toHaveCount(6)`. The new /authors sidebar entry made it 7. Updated the assertion and the sidebar comment. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../frontend-new/admin-spec/admin_authors_page.spec.ts | 6 +++++- .../frontend-new/admin-spec/admintroubleshooting.spec.ts | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts b/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts index 4c5a4fe2d26..a29ac56bae3 100644 --- a/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts +++ b/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts @@ -12,7 +12,11 @@ const setErasureFlag = async (page: any, enabled: boolean) => { const settings = page.locator('.settings'); await expect(settings).not.toHaveValue('', {timeout: 30000}); const raw = await settings.inputValue(); - const obj = JSON.parse(raw.replace(/\/\*[\s\S]*?\*\//g, '')); + // The textarea exposes the raw settings.json — JSONC with comments, + // trailing commas, and unquoted property names. JSON.parse rejects + // all three. Evaluating it as a JS object literal (which it always + // is) accepts everything Etherpad's own settings loader does. + const obj = new Function(`return (${raw})`)(); obj.gdprAuthorErasure = {enabled}; await settings.fill(JSON.stringify(obj)); await saveSettings(page); diff --git a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts index 57518e3fe1d..8cd0e93f6b1 100644 --- a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts +++ b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts @@ -14,8 +14,8 @@ test('Shows troubleshooting page manager', async ({page}) => { await page.goto('http://localhost:9001/admin/help') await page.waitForSelector('.menu') const menu = page.locator('.menu'); - // Sidebar nav: plugins, settings, help, pads, shout, update. - await expect(menu.locator('li')).toHaveCount(6); + // Sidebar nav: plugins, settings, help, pads, authors, shout, update. + await expect(menu.locator('li')).toHaveCount(7); }) test('Shows a version number', async function ({page}) { From 69707aeb13106d10a539a74470958fc6d00c8e15 Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 4 May 2026 07:58:21 +0100 Subject: [PATCH 16/17] fix(authors): IconButton title, Dialog.Title, preview errors, settings restart Final whole-branch review found three Important UX/a11y defects, plus CI flagged one runtime defect: - IconButton renders the title prop as a visible , not as the HTML title attribute. Disabled rows were displaying the 80-character 'Author erasure is disabled...' string next to every trash icon. Reverted to the short 'Erase' label; the page-level banner already explains the disabled state. - Radix Dialog.Content was missing Dialog.Title. Wrapped the existing

    in so screen readers can announce the dialog purpose. - onPreview proceeded to render the preview UI even when the backend reply carried {error}, leaving 'Will clear undefined token mappings...' on screen. Now mirrors onErase. - The disabled-banner-hidden Playwright test failed because settings.json save does not hot-reload. setErasureFlag now restartEtherpad's after saveSettings and re-logins. Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/src/pages/AuthorPage.tsx | 21 ++++++++++++------- .../admin-spec/admin_authors_page.spec.ts | 9 +++++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx index b73cb739d51..ea119e63334 100644 --- a/admin/src/pages/AuthorPage.tsx +++ b/admin/src/pages/AuthorPage.tsx @@ -59,6 +59,14 @@ export const AuthorPage = () => { if (!settingsSocket) return; const onLoad = (data: AuthorSearchResult) => setAuthors(data); const onPreview = (data: AnonymizePreview) => { + if (data.error) { + useStore.getState().setToastState({ + open: true, success: false, + title: t('ep_admin_authors:erase-error-toast', {error: data.error}), + }); + setDialog({phase: 'closed'}); + return; + } setDialog((cur) => cur.phase === 'loading-preview' && cur.authorID === data.authorID ? {phase: 'preview', preview: data} @@ -151,8 +159,10 @@ export const AuthorPage = () => { {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { const p = dialog.preview; return
    -

    {t('ep_admin_authors:confirm-preview-title', - {name: p.name || p.authorID})}

    + +

    {t('ep_admin_authors:confirm-preview-title', + {name: p.name || p.authorID})}

    +

    {t('ep_admin_authors:confirm-preview-counters', { tokenMappings: p.removedTokenMappings, externalMappings: p.removedExternalMappings, @@ -251,11 +261,8 @@ export const AuthorPage = () => {

    } - title={} + title={} onClick={() => openErase(row)} disabled={!erasureEnabled || row.erased}/>
    diff --git a/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts b/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts index a29ac56bae3..cc5850949a1 100644 --- a/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts +++ b/src/tests/frontend-new/admin-spec/admin_authors_page.spec.ts @@ -1,5 +1,5 @@ import {expect, test} from "@playwright/test"; -import {loginToAdmin, saveSettings} from "../helper/adminhelper"; +import {loginToAdmin, saveSettings, restartEtherpad} from "../helper/adminhelper"; // /admin tests run serially because they mutate global server state. test.describe.configure({mode: 'serial'}); @@ -20,6 +20,13 @@ const setErasureFlag = async (page: any, enabled: boolean) => { obj.gdprAuthorErasure = {enabled}; await settings.fill(JSON.stringify(obj)); await saveSettings(page); + // settings.json save does not hot-reload — the server keeps the prior + // in-memory `settings.gdprAuthorErasure.enabled` until restart, so a + // subsequent navigation would still see the old flag value pushed via + // the `flags` field on the connect-time settings reply. Restart so + // tests observing the flag flip see the new value. + await restartEtherpad(page); + await loginToAdmin(page, 'admin', 'changeme1'); }; test.describe('admin authors page', () => { From 88610f6bee90485443d70d039dc50cdb8b1c23cd Mon Sep 17 00:00:00 2001 From: John McLear Date: Mon, 4 May 2026 08:14:45 +0100 Subject: [PATCH 17/17] =?UTF-8?q?fix(authors):=20action=20Qodo=20review=20?= =?UTF-8?q?=E2=80=94=20lastSeen,=20flag-gating,=20defensive=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo on PR #7667 surfaced three issues: 1. (Bug, Correctness) lastSeen lost or stale. - mapAuthorWithDBKey only updated `timestamp` for returning authors so the admin /authors 'Last seen' column drifted on every reconnect without an identity write. Now stamps both timestamp and lastSeen. - anonymizeAuthor's two db.set calls overwrote globalAuthor without preserving lastSeen, blanking the column for erased rows. Both writes now carry forward `existing.lastSeen ?? existing.timestamp`. - searchAuthors falls back to rec.timestamp when rec.lastSeen is missing so legacy records aren't blank. 2. (Rule violation, Security) /authors route not flag-gated. The new admin-socket read paths (authorLoad, anonymizeAuthorPreview) were always-on; only the destructive anonymizeAuthor was gated. Project rule (Compliance ID 6) requires new features behind a flag, disabled by default. All three handlers now check gdprAuthorErasure.enabled and return {error:'disabled'} when off. The sidebar 'Authors' link is hidden when the flag is off (deep-link to /admin/authors still works and renders the existing disabled banner so docs can point to it). 3. (Bug, Reliability) Socket destructure throws on missing payload. Handlers signed `async ({authorID}: {authorID: string}) => …` threw before try/catch when a client emitted with no payload, producing an unhandled rejection. Switched to `async (payload: any) => { const authorID = payload?.authorID; … }`. Test impact: anonymizeAuthorSocket gains two regressions (authorLoad disabled-shape, payload-less emits don't crash) and updates the preview-when-flag-off test to assert {error:'disabled'} per the new gating posture (was 'preview still works'). admintroubleshooting sidebar-count reverts 7 → 6 since the Authors link is now conditional on the flag (off by default in the test environment). Co-Authored-By: Claude Opus 4.7 (1M context) --- admin/src/App.tsx | 5 ++- src/node/db/AuthorManager.ts | 18 +++++++-- src/node/hooks/express/adminsettings.ts | 32 +++++++++++++-- .../specs/admin/anonymizeAuthorSocket.ts | 40 ++++++++++++++++++- .../admin-spec/admintroubleshooting.spec.ts | 6 ++- 5 files changed, 89 insertions(+), 12 deletions(-) diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 169a8bab27c..5d527f0858a 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -12,6 +12,7 @@ import {UpdateBanner} from "./components/UpdateBanner"; const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : '' export const App = () => { const setSettings = useStore(state => state.setSettings); + const erasureEnabled = useStore(state => state.gdprAuthorErasureEnabled) const {t} = useTranslation() const navigate = useNavigate() const [sidebarOpen, setSidebarOpen] = useState(true) @@ -106,7 +107,9 @@ export const App = () => {
  • -
  • + {erasureEnabled && ( +
  • + )}
  • Communication
diff --git a/src/node/db/AuthorManager.ts b/src/node/db/AuthorManager.ts index 1c7c3c0a9fa..8fafe51f57b 100644 --- a/src/node/db/AuthorManager.ts +++ b/src/node/db/AuthorManager.ts @@ -130,8 +130,11 @@ const mapAuthorWithDBKey = async (mapperkey: string, mapper:string) => { } // there is an author with this mapper - // update the timestamp of this author - await db.setSub(`globalAuthor:${author}`, ['timestamp'], Date.now()); + // update the timestamp + lastSeen of this author so the admin + // /authors "Last seen" column reflects the most recent connect + const now = Date.now(); + await db.setSub(`globalAuthor:${author}`, ['timestamp'], now); + await db.setSub(`globalAuthor:${author}`, ['lastSeen'], now); // return the author return {authorID: author}; @@ -374,11 +377,15 @@ exports.anonymizeAuthor = async ( // Zero the display identity now — without the `erased` sentinel — so a // partial run still hides the name. The sentinel itself is only set at // the end (below) so a failure in chat scrub lets the next call resume. + // Preserve `lastSeen` so the admin /authors UI's column stays accurate + // for erased records (the operator can still see when the author was + // last active before erasure). if (!dryRun) { await db.set(`globalAuthor:${authorID}`, { colorId: 0, name: null, timestamp: Date.now(), + lastSeen: existing.lastSeen ?? existing.timestamp ?? null, padIDs: existing.padIDs || {}, }); } @@ -414,6 +421,7 @@ exports.anonymizeAuthor = async ( colorId: 0, name: null, timestamp: Date.now(), + lastSeen: existing.lastSeen ?? existing.timestamp ?? null, padIDs: existing.padIDs || {}, erased: true, erasedAt: new Date().toISOString(), @@ -513,7 +521,11 @@ exports.searchAuthors = async (query: { name: rec.name ?? null, colorId: rec.colorId ?? null, mapper: mappers, - lastSeen: typeof rec.lastSeen === 'number' ? rec.lastSeen : null, + // Prefer lastSeen; fall back to timestamp for legacy records that + // pre-date the new field so the admin /authors column isn't blank. + lastSeen: typeof rec.lastSeen === 'number' + ? rec.lastSeen + : (typeof rec.timestamp === 'number' ? rec.timestamp : null), erased, }); } diff --git a/src/node/hooks/express/adminsettings.ts b/src/node/hooks/express/adminsettings.ts index 8702910b6da..302492117fc 100644 --- a/src/node/hooks/express/adminsettings.ts +++ b/src/node/hooks/express/adminsettings.ts @@ -309,8 +309,25 @@ exports.socketio = (hookName: string, {io}: any) => { const authorManager = require('../../db/AuthorManager'); - socket.on('authorLoad', async (query: any) => { + // The admin author-erasure UI (PR #7667) is gated as a single + // feature: when gdprAuthorErasure.enabled is false, all three + // socket handlers refuse so the page is fully off by default per + // project rule "new features behind a feature flag, disabled by + // default" (Qodo Compliance ID 6). The destructive + // anonymizeAuthor stays gated as before; the read paths + // (authorLoad / preview) are also gated so listing data isn't + // exposed without an explicit opt-in. + const erasureEnabled = () => + !!(settings.gdprAuthorErasure && settings.gdprAuthorErasure.enabled); + + socket.on('authorLoad', async (payload: any) => { try { + if (!erasureEnabled()) { + socket.emit('results:authorLoad', + {total: 0, results: [], error: 'disabled'}); + return; + } + const query = payload || {}; const data = await authorManager.searchAuthors({ pattern: query.pattern || '', offset: query.offset || 0, @@ -327,8 +344,14 @@ exports.socketio = (hookName: string, {io}: any) => { } }); - socket.on('anonymizeAuthorPreview', async ({authorID}: {authorID: string}) => { + socket.on('anonymizeAuthorPreview', async (payload: any) => { + const authorID = payload?.authorID; try { + if (!erasureEnabled()) { + socket.emit('results:anonymizeAuthorPreview', + {authorID, error: 'disabled'}); + return; + } if (!authorID) { socket.emit('results:anonymizeAuthorPreview', {authorID, error: 'authorID is required'}); @@ -346,9 +369,10 @@ exports.socketio = (hookName: string, {io}: any) => { } }); - socket.on('anonymizeAuthor', async ({authorID}: {authorID: string}) => { + socket.on('anonymizeAuthor', async (payload: any) => { + const authorID = payload?.authorID; try { - if (!settings.gdprAuthorErasure || !settings.gdprAuthorErasure.enabled) { + if (!erasureEnabled()) { socket.emit('results:anonymizeAuthor', {authorID, error: 'disabled'}); return; } diff --git a/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts b/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts index ef3984c0598..486a2dad08f 100644 --- a/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts +++ b/src/tests/backend/specs/admin/anonymizeAuthorSocket.ts @@ -144,8 +144,12 @@ describe(__filename, function () { } }); - it('anonymizeAuthorPreview still works when flag is off (read-only)', + it('anonymizeAuthorPreview returns {error: "disabled"} when flag is off', async function () { + // Per Qodo Compliance ID 6 ('new features behind a feature flag, + // disabled by default') the preview event is also gated, not just + // the live anonymizeAuthor. The page renders its disabled banner + // off the socket reply when this fires. settings.gdprAuthorErasure.enabled = false; try { const tag = `prev-off-${Date.now()}`; @@ -153,9 +157,41 @@ describe(__filename, function () { `m-${tag}`, `PrevOff ${tag}`); const preview = await ask(socket, 'anonymizeAuthorPreview', {authorID}, 'results:anonymizeAuthorPreview'); - assert.ok(preview.removedExternalMappings >= 1); + assert.equal(preview.error, 'disabled'); + assert.equal(preview.removedExternalMappings, undefined, + 'no counters should leak when the flag is off'); } finally { settings.gdprAuthorErasure.enabled = true; } }); + + it('authorLoad returns {error: "disabled"} when flag is off', + async function () { + settings.gdprAuthorErasure.enabled = false; + try { + const res = await ask(socket, 'authorLoad', + {pattern: '', offset: 0, limit: 12, sortBy: 'name', + ascending: true, includeErased: false}, + 'results:authorLoad'); + assert.equal(res.error, 'disabled'); + assert.deepEqual(res.results, []); + } finally { + settings.gdprAuthorErasure.enabled = true; + } + }); + + it('handlers do not crash on payload-less emits', + async function () { + // Pre-Qodo-fix the destructure `({authorID}: ...)` threw before + // try/catch when client emitted with no payload. Both gated + // handlers now accept `payload: any` and read defensively. + const previewRes = await ask(socket, 'anonymizeAuthorPreview', + undefined, 'results:anonymizeAuthorPreview'); + assert.ok(previewRes.error, + `expected error, got ${JSON.stringify(previewRes)}`); + const eraseRes = await ask(socket, 'anonymizeAuthor', + undefined, 'results:anonymizeAuthor'); + assert.ok(eraseRes.error, + `expected error, got ${JSON.stringify(eraseRes)}`); + }); }); diff --git a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts index 8cd0e93f6b1..346d2513df4 100644 --- a/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts +++ b/src/tests/frontend-new/admin-spec/admintroubleshooting.spec.ts @@ -14,8 +14,10 @@ test('Shows troubleshooting page manager', async ({page}) => { await page.goto('http://localhost:9001/admin/help') await page.waitForSelector('.menu') const menu = page.locator('.menu'); - // Sidebar nav: plugins, settings, help, pads, authors, shout, update. - await expect(menu.locator('li')).toHaveCount(7); + // Sidebar nav: plugins, settings, help, pads, shout, update. + // The Authors link only renders when gdprAuthorErasure.enabled = true, + // which the test environment leaves false by default. + await expect(menu.locator('li')).toHaveCount(6); }) test('Shows a version number', async function ({page}) {