diff --git a/admin/public/ep_admin_authors/en.json b/admin/public/ep_admin_authors/en.json new file mode 100644 index 00000000000..d4215b3b478 --- /dev/null +++ b/admin/public/ep_admin_authors/en.json @@ -0,0 +1,31 @@ +{ + "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/admin/src/App.tsx b/admin/src/App.tsx index 27d5a2ae367..5d527f0858a 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -6,12 +6,13 @@ import {NavLink, Outlet, useNavigate} from "react-router-dom"; import {useStore} from "./store/store.ts"; import {LoadingScreen} from "./utils/LoadingScreen.tsx"; import {Trans, useTranslation} from "react-i18next"; -import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell} from "lucide-react"; +import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react"; 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) @@ -61,14 +62,15 @@ export const App = () => { } }); - settingSocket.on('settings', (settings) => { - /* Check whether the settings.json is authorized to be viewed */ + settingSocket.on('settings', (settings: any) => { + 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; } - - /* Check to make sure the JSON is clean before proceeding */ if (isJSONClean(settings.results)) { setSettings(settings.results); } else { @@ -105,6 +107,9 @@ export const App = () => {
  • + {erasureEnabled && ( +
  • + )}
  • Communication
  • diff --git a/admin/src/components/ColorSwatch.tsx b/admin/src/components/ColorSwatch.tsx new file mode 100644 index 00000000000..f1e9b123f8d --- /dev/null +++ b/admin/src/components/ColorSwatch.tsx @@ -0,0 +1,37 @@ +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 ; +}; 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..ea119e63334 --- /dev/null +++ b/admin/src/pages/AuthorPage.tsx @@ -0,0 +1,295 @@ +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) => { + 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} + : 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, + })}

    +

    + +

    +
    + + +
    + {dialog.phase === 'committing' &&

    + +

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

    + +

    +
    + + 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)} + disabled={!erasureEnabled || row.erased}/> +
    +
    + +
    + + {t('ep_admin_authors:page-counter', + {current: currentPage + 1, total: 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}), })); diff --git a/admin/src/utils/AuthorSearch.ts b/admin/src/utils/AuthorSearch.ts new file mode 100644 index 00000000000..2ca6e92bd91 --- /dev/null +++ b/admin/src/utils/AuthorSearch.ts @@ -0,0 +1,45 @@ +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; +}; 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` `