feat(gdpr): admin UI for author erasure (follow-up to #7550)#7667
feat(gdpr): admin UI for author erasure (follow-up to #7550)#7667JohnMcLear wants to merge 17 commits intodevelopfrom
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ryRun Code review on the previous commit caught that the dryRun refactor silently dropped four WHY-comments (lazy-require cycle, drop-mappings- first ordering, zero-identity-without-sentinel split, sentinel-last discipline) and left the new opts parameter undocumented. Restored the comments verbatim and added a one-line JSDoc note for dryRun. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In-memory enumeration of globalAuthor:* with a join on mapper2author:* for the mapper column. Filter (substring on name OR mapper OR authorID — the authorID match lets admins verify a specific erased record where name and mapper bindings are gone), sort (name | lastSeen), paginate, 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) <noreply@anthropic.com>
Code review pointed out that ties in the primary sort key (common on lastSeen for authors created the same ms, possible on identical names too) fell back to findKeys enumeration order — not guaranteed stable across DB backends. Adding an authorID secondary sort makes pagination safe across requests. Also fix a misleading 'default' note in the JSDoc — includeErased is required, not optional. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Code review found two test-isolation issues: - settings.users 'restoration' was a no-op (savedUsers held a reference to the same object that the test mutated). The test-admin key now gets explicitly removed in after(). - The Promise that awaited connect/connect_error left the loser listener attached for the lifetime of the socket, risking an unhandled rejection on a later spurious connect_error event. Listeners are now paired with off() so only the winner survives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Adds a searchable, sortable, paginated authors page mirroring the existing PadPage shape. Search matches name OR mapper substring; 'Show erased' toggle off by default; cap-at-1000 hint surfaces when the backend caps the pre-pagination set. Two-step erase modal: dry- run preview shows what will be cleared, then a Continue button commits the irreversible erasure. Disabled-flag banner explains how to enable when gdprAuthorErasure.enabled is false; per-row Erase button is disabled with a tooltip in the same condition. Sidebar gets a Users link between Pads and Communication. App.tsx listens for the new flags.gdprAuthorErasure on the connect-time settings push so the page knows the flag state without an extra round trip. ep_admin_authors namespace is added to i18next's ns list so all translation keys resolve. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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
<Trans i18nKey='ep_admin_authors:foo'/> 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) <noreply@anthropic.com>
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
Review Summary by QodoAdd admin UI for GDPR author erasure with search, preview, and erase workflows
WalkthroughsDescription• Implements a new /admin/authors page enabling operators to search, sort, and erase authors through an in-product UI instead of CLI commands • Adds three new admin-socket events (authorLoad, anonymizeAuthorPreview, anonymizeAuthor) with feature-flag gating for live erasure operations • Extends AuthorManager with searchAuthors helper (paginated, 1000-row cap, substring filtering on name/mapper/authorID) and anonymizeAuthor dry-run mode for preview counters • Introduces lastSeen timestamp field on globalAuthor records, stamped on creation and identity writes • Frontend features: searchable/sortable table with pagination, two-step erase modal (preview counters → commit), disabled-flag banner with per-row tooltips, in-place row updates after erasure • Comprehensive test coverage: 6 backend unit specs for author search, 5 integration specs for socket events, 3 regression specs for lastSeen/dryRun, 4 Playwright specs with localization assertions • Includes 31 English i18n keys covering all user-visible strings, implementation plan, and detailed specification document Diagramflowchart LR
Admin["Admin User"]
AuthorPage["AuthorPage Component"]
SocketEvents["Admin Socket Events<br/>authorLoad<br/>anonymizeAuthorPreview<br/>anonymizeAuthor"]
AuthorManager["AuthorManager<br/>searchAuthors<br/>anonymizeAuthor dryRun"]
DB["Database<br/>globalAuthor records"]
Admin -->|"Search/Sort/Erase"| AuthorPage
AuthorPage -->|"Emit events"| SocketEvents
SocketEvents -->|"Query/Preview/Execute"| AuthorManager
AuthorManager -->|"Read/Write"| DB
AuthorManager -->|"Return results"| SocketEvents
SocketEvents -->|"Update UI"| AuthorPage
File Changes1. src/node/db/AuthorManager.ts
|
Code Review by Qodo
1.
|
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) <noreply@anthropic.com>
…s 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 <span>, 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
<h3> in <Dialog.Title asChild> 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) <noreply@anthropic.com>
…ayloads 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) <noreply@anthropic.com>
Summary
Adds an in-product
/admin/authorspage so operators can search/sort/erase authors without crafting a curl. Follow-up to PR5 of #6701 (#7550).io.of('/settings'):authorLoad,anonymizeAuthorPreview(always available, read-only),anonymizeAuthor(gated ongdprAuthorErasure.enabled, returns{error:'disabled'}when off).authorManager.searchAuthorshelper with cap-at-1000 safety, substring match against name OR mapper OR opaque authorID.anonymizeAuthor({dryRun: true})for preview counters — same loops, no writes.lastSeenfield stamped onglobalAuthorwrite paths (additive, no migration).The public REST endpoint and its
gdprAuthorErasure.enabledflag are unchanged. The admin-socket live event reuses the same flag for parity; preview is intentionally always available so the page is discoverable when the flag is off.Spec:
docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.mdPlan:
docs/superpowers/plans/2026-05-03-gdpr-admin-author-erasure-ui.mdTest plan