A super-admin can ban a member and remove any board by its record #216

Merged
Grandiras merged 2 commits from issue-196-moderation into main 2026-09-22 21:06:48 +00:00
Owner

Closes #196. The super-admin half of the design posted there, on top of the soft delete (previous PR).

Since #190, a deployment's configured admin can't open a board they weren't given. They still have to be able to act on a report about one, and to stop a member who shouldn't be here. This PR adds those two verbs, with as little reach into boards as they need.

Ban a member

  • A flag, not a removal: Member.BannedAt, BannedByDid, and BannedReason (encrypted). The row is what every card, comment and assignment they made points at. Deleting it fails for anybody who ever made anything, which is exactly who a ban is for, or it would destroy their work. DbAllowlistService leaves banned members out, and that one clause ends their circuits, closes every page and stops their calendar feed. Their grants stay, inert, so lifting the ban is one click.
  • Their boards are handed on in the same save, wherever they were the last admin who can still sign in: to the longest-standing member who can (BoardSuccession, the rule removal already uses). A board with nobody else on it is left alone. It's unreachable while the ban holds, and back exactly as it was once lifted. Handing it to the super-admin would open a private board to somebody who was never given it. The Members page says which boards were left like that.
  • A configured super-admin can't be banned (CannotBanSuperAdminException), since the configuration would let them in anyway. A banned DID can't be re-added or approved from the requests panel (MemberBannedException). The assignee and access pickers leave banned members out, and "admins left on a board" counts only members who can sign in.
  • A banned member who signs in gets their own message on the access-denied page, not the request-access form.
  • Boot-time repair (EnsureBoardAdminsAsync) now runs succession first. A board open to everyone falls back to the super-admins, and a restricted one with nobody left is logged, never opened up.
  • Migration AddMemberBans: three nullable columns.

Board record and moderation delete

  • /admin/board/{key} is for the configured admin only, reached from a link somebody reports. It shows one board's structure: name, who runs it, how many members, columns, cards and comments it has, when it was made and when a card last changed. Of everything members typed, only the name, because a delete made blind can't be undone and the name is in the report anyway. Each open is logged.
  • Remove board there is the soft delete with DeletedAsModeration set. The board's own admins can't restore it (their "Recently deleted" doesn't list it, and the deleted-board page says it was removed by an admin). Only the super-admin brings it back, from the same page. Purge after 30 days as usual.
  • A report arrives as a link, and a super-admin following one to a board they weren't given gets the usual "no access" / "not found" page with a Board record link on it. A deleted board looks missing to anybody who wasn't on it, and its record is where a moderation delete is undone. There's no list of every board: the record is reached by address, never browsed.

Tests

  • tests/e2e/moderation.spec.js: a ban shuts a member out, hands on the board they ran, and lifts again; a board the super-admin isn't on is removed from its record, and only they bring it back.
  • Full e2e suite on this branch: 164/164. Unit tests: 558/558. The only change since that run is BoardRecordPage.Dispose now overriding the base's, so its locale subscription is released too (it was a CS0114 warning).
  • Docs: auth.md (bans and the allowlist), boards-and-permissions.md (the record and where the line is), data.md (the new columns), testing.md (the spec).

🤖 Generated with Claude Code

Closes #196. The super-admin half of the design posted there, on top of the soft delete (previous PR). Since #190, a deployment's configured admin can't open a board they weren't given. They still have to be able to act on a report about one, and to stop a member who shouldn't be here. This PR adds those two verbs, with as little reach into boards as they need. ## Ban a member - **A flag, not a removal**: `Member.BannedAt`, `BannedByDid`, and `BannedReason` (encrypted). The row is what every card, comment and assignment they made points at. Deleting it fails for anybody who ever made anything, which is exactly who a ban is for, or it would destroy their work. `DbAllowlistService` leaves banned members out, and that one clause ends their circuits, closes every page and stops their calendar feed. Their grants stay, inert, so lifting the ban is one click. - **Their boards are handed on** in the same save, wherever they were the last admin who can still sign in: to the longest-standing member who can (`BoardSuccession`, the rule removal already uses). A board with nobody else on it is left alone. It's unreachable while the ban holds, and back exactly as it was once lifted. Handing it to the super-admin would open a private board to somebody who was never given it. The Members page says which boards were left like that. - A configured super-admin can't be banned (`CannotBanSuperAdminException`), since the configuration would let them in anyway. A banned DID can't be re-added or approved from the requests panel (`MemberBannedException`). The assignee and access pickers leave banned members out, and "admins left on a board" counts only members who can sign in. - A banned member who signs in gets their own message on the access-denied page, not the request-access form. - Boot-time repair (`EnsureBoardAdminsAsync`) now runs succession first. A board open to everyone falls back to the super-admins, and a restricted one with nobody left is logged, never opened up. - Migration `AddMemberBans`: three nullable columns. ## Board record and moderation delete - `/admin/board/{key}` is for the configured admin only, reached from a link somebody reports. It shows **one board's structure**: name, who runs it, how many members, columns, cards and comments it has, when it was made and when a card last changed. Of everything members typed, only the name, because a delete made blind can't be undone and the name is in the report anyway. Each open is logged. - **Remove board** there is the soft delete with `DeletedAsModeration` set. The board's own admins can't restore it (their "Recently deleted" doesn't list it, and the deleted-board page says it was removed by an admin). Only the super-admin brings it back, from the same page. Purge after 30 days as usual. - A report arrives as a link, and a super-admin following one to a board they weren't given gets the usual "no access" / "not found" page with a **Board record** link on it. A deleted board looks missing to anybody who wasn't on it, and its record is where a moderation delete is undone. There's no list of every board: the record is reached by address, never browsed. ## Tests - `tests/e2e/moderation.spec.js`: a ban shuts a member out, hands on the board they ran, and lifts again; a board the super-admin isn't on is removed from its record, and only they bring it back. - Full e2e suite on this branch: 164/164. Unit tests: 558/558. The only change since that run is `BoardRecordPage.Dispose` now overriding the base's, so its locale subscription is released too (it was a CS0114 warning). - Docs: `auth.md` (bans and the allowlist), `boards-and-permissions.md` (the record and where the line is), `data.md` (the new columns), `testing.md` (the spec). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
A super-admin can ban a member and remove any board by its record
All checks were successful
CI / build (pull_request) Successful in 39s
2d8d1634d7
A ban is a flag on the member row, not a removal: the allowlist snapshot
leaves them out, which ends their circuits and feeds, while their work and
their grants stay put, so lifting it is one click. Their boards are handed on
where they were the last admin (BoardSuccession); a board with nobody else on
it is left alone rather than opened to the super-admin.

/admin/board/{key} shows one board's structure — who runs it, how much is on
it, never what anybody wrote besides its name — and removes it as moderation:
the soft delete with DeletedAsModeration set, which only the super-admin can
undo. It is reached from the link a report arrives as, never browsed.

Closes #196.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

💬 Verdict: comment — notes only, nothing blocking.

Solid implementation of both verbs, and the design's limits are enforced where they matter: GetRecordAsync is structure-only and reached by address, DeleteAsModerationAsync/RestoreAsSuperAdminAsync re-check the configured admin at the write (RequireSuperAdmin), the succession rule counts only members who can sign in (HandOverAsync, SetAccessAsync, EnsureBoardAdminsAsync all agree), and the boot repair no longer hands private boards to the super-admin. I checked the catch order in RequestsPanel.razor and Program.cs (MemberBannedException vs DbUpdateException — both reachable), the locale keys including the .one plural, the e2e helpers the new spec imports, and the BoardSuccession.For signature the initializer now passes leaving: string.Empty to — all correct.

Two minor notes, neither blocking:

  1. Changelog.Unreleased has no entry for this. The repo convention (CLAUDE.md) is that a change a member would notice adds a line in the PR that makes it — bans (the access-denied message, the narrowed pickers) and the board record qualify. Worth adding before merge, in both locales.

  2. The board record's admin list includes banned admins. GetRecordAsync returns every grant with the admin role, banned or not, while the empty-state label says "Nobody who can sign in" — so a board whose only admin is banned shows that banned admin as its admin rather than the label. Arguably correct (the grant row is real), but the label and the list disagree about what is being filtered; filtering AdminDids to unbanned members, or rewording the empty label, would make them consistent.



⏱ 3m 24s · 14 steps · $0.0600 · tokens: 283.2k in / 9.3k out (+428.9k cache-read) · Go: 5h 37% · wk 21% · mo 43%
📜 Full session transcript

💬 **Verdict: comment** — notes only, nothing blocking. Solid implementation of both verbs, and the design's limits are enforced where they matter: `GetRecordAsync` is structure-only and reached by address, `DeleteAsModerationAsync`/`RestoreAsSuperAdminAsync` re-check the configured admin at the write (`RequireSuperAdmin`), the succession rule counts only members who can sign in (`HandOverAsync`, `SetAccessAsync`, `EnsureBoardAdminsAsync` all agree), and the boot repair no longer hands private boards to the super-admin. I checked the catch order in `RequestsPanel.razor` and `Program.cs` (MemberBannedException vs DbUpdateException — both reachable), the locale keys including the `.one` plural, the e2e helpers the new spec imports, and the `BoardSuccession.For` signature the initializer now passes `leaving: string.Empty` to — all correct. Two minor notes, neither blocking: 1. **`Changelog.Unreleased` has no entry for this.** The repo convention (CLAUDE.md) is that a change a member would notice adds a line in the PR that makes it — bans (the access-denied message, the narrowed pickers) and the board record qualify. Worth adding before merge, in both locales. 2. **The board record's admin list includes banned admins.** `GetRecordAsync` returns every grant with the admin role, banned or not, while the empty-state label says "Nobody who can sign in" — so a board whose only admin is banned shows that banned admin as its admin rather than the label. Arguably correct (the grant row is real), but the label and the list disagree about what is being filtered; filtering `AdminDids` to unbanned members, or rewording the empty label, would make them consistent. --- --- ⏱ 3m 24s · 14 steps · $0.0600 · tokens: 283.2k in / 9.3k out (+428.9k cache-read) · Go: 5h 37% · wk 21% · mo 43% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-216/run-2026-09-22T20-54-17-886Z.md) <!-- opencode-reviewed-head:2d8d1634d76e9de9dded8d2eefbe356eb970e3b2 --> <!-- opencode-verdict:comment -->
Collaborator

🤖 On it — the reply will link the full session transcript.

🤖 On it — the reply will link the full session transcript. <!-- opencode-run:task -->
Review: a banned admin is marked on the board record, and the changelog says it
All checks were successful
CI / build (pull_request) Successful in 44s
e0f3a3b8be
The record lists every admin grant, a banned one marked "Banned", and says
"Nobody who can sign in" whenever no admin can — which is what it claims.
The moderation line joins the soft-delete one (the block is at its seven).
didOf in moderation.spec polls, since one read can land between renders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Both notes addressed in e0f3a3b:

  1. Changelog: the moderation line joins the soft-delete one, because the unreleased block is at its seven-line cap. EN: The configured admins can remove a board that was reported to them, and ban a member. DE likewise.
  2. Banned admins on the record: the list still shows every admin grant, since a board whose only admin is banned is exactly what a moderator needs to see. A banned admin now carries the same Banned pill as on the members page, and Nobody who can sign in shows whenever no listed admin can sign in, so the label and the list agree. moderation.spec.js pins it with a board only the banned member was on.

Also: didOf in that spec now polls, since a single read could land between the prerender and the circuit's render (the one flaky run I saw). Unit 558/558, moderation spec 3/3.

Both notes addressed in e0f3a3b: 1. **Changelog**: the moderation line joins the soft-delete one, because the unreleased block is at its seven-line cap. EN: *The configured admins can remove a board that was reported to them, and ban a member.* DE likewise. 2. **Banned admins on the record**: the list still shows every admin grant, since a board whose only admin is banned is exactly what a moderator needs to see. A banned admin now carries the same *Banned* pill as on the members page, and *Nobody who can sign in* shows whenever no listed admin can sign in, so the label and the list agree. `moderation.spec.js` pins it with a board only the banned member was on. Also: `didOf` in that spec now polls, since a single read could land between the prerender and the circuit's render (the one flaky run I saw). Unit 558/558, moderation spec 3/3.
Collaborator

⚠️ The run failed: git -c http.extraheader=Authorization: Basic b3BlbmNvZGUtYm90OjMxNWQ2MDE5ZTg1M2I2NmQ5ZTcxN2ZiNWQzYTA2NmU3MmU2M2RjNDk= push origin HEAD:refs/heads/issue-196-moderation failed (1): To http://forgejo:3000/Grandiras/Ponente.git
! [rejected] HEAD -> issue-196-moderation (fetch first)
error: failed to push some refs to 'http://forgejo:3000/Grandiras/Ponente.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually

⚠️ The run failed: git -c http.extraheader=Authorization: Basic b3BlbmNvZGUtYm90OjMxNWQ2MDE5ZTg1M2I2NmQ5ZTcxN2ZiNWQzYTA2NmU3MmU2M2RjNDk= push origin HEAD:refs/heads/issue-196-moderation failed (1): To http://forgejo:3000/Grandiras/Ponente.git ! [rejected] HEAD -> issue-196-moderation (fetch first) error: failed to push some refs to 'http://forgejo:3000/Grandiras/Ponente.git' hint: Updates were rejected because the remote contains work that you do hint: not have locally. This is usually
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Both notes from the previous pass are resolved: the Changelog Unreleased entry now names the new verbs in both locales, and the board record's admin list now shows the "Nobody who can sign in" label whenever every admin grant is banned, with banned admins still listed and marked — the label and the list agree, and moderation.spec.js pins both visible together. The new Dispose override is correct against LocalizedComponentBase (virtual, base.Dispose() called first), and the BoardSuccession.For call in the boot repair matches the optional eligible signature. The core design holds: GetRecordAsync is structure-only and reached by address, the moderation delete/restore re-check the configured admin at the write, and the succession rule counts only members who can sign in consistently across HandOverAsync, SetAccessAsync, and EnsureBoardAdminsAsync. Good to merge.



⏱ 0m 34s · 5 steps · $0.0151 · tokens: 78.5k in / 1.2k out (+90.0k cache-read) · Go: 5h 55% · wk 29% · mo 47%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Both notes from the previous pass are resolved: the Changelog `Unreleased` entry now names the new verbs in both locales, and the board record's admin list now shows the "Nobody who can sign in" label whenever every admin grant is banned, with banned admins still listed and marked — the label and the list agree, and `moderation.spec.js` pins both visible together. The new `Dispose` override is correct against `LocalizedComponentBase` (virtual, `base.Dispose()` called first), and the `BoardSuccession.For` call in the boot repair matches the optional `eligible` signature. The core design holds: `GetRecordAsync` is structure-only and reached by address, the moderation delete/restore re-check the configured admin at the write, and the succession rule counts only members who can sign in consistently across `HandOverAsync`, `SetAccessAsync`, and `EnsureBoardAdminsAsync`. Good to merge. --- --- ⏱ 0m 34s · 5 steps · $0.0151 · tokens: 78.5k in / 1.2k out (+90.0k cache-read) · Go: 5h 55% · wk 29% · mo 47% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-216/run-2026-09-22T21-05-50-813Z.md) <!-- opencode-reviewed-head:e0f3a3b8bee18c7948b78f63150b51fa0546acee --> <!-- opencode-verdict:approve -->
Grandiras deleted branch issue-196-moderation 2026-09-22 21:06:48 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente!216
No description provided.