Admin Panel — Design#
Date: 2026-02-26 Status: Approved
Summary#
Add a dedicated admin panel at /admin/* in the web app. The panel gives privileged users a UI for managing members, forum structure (categories and boards), and reviewing the mod action audit log. In-context moderation buttons (lock/unlock, hide/unhide, ban/unban on topic pages) already exist and are out of scope. Role management (create/edit/delete roles) is also out of scope — roles remain seeded by the bootstrap CLI.
Scope#
In scope:
/adminlanding page — permission-aware navigation dashboard/admin/members— member list with inline role assignment/admin/structure— full CRUD for categories and boards/admin/modlog— paginated read-only mod action audit log- New AppView endpoints for category/board writes and mod log reads
Out of scope:
- Role management (create/edit/delete roles) — CLI-seeded only
- Pagination for the member list (hard cap at 100, follow-up work)
- SSE / push updates (consistency gap between PDS write and firehose indexing is a known, accepted trade-off)
- In-context mod actions (already shipped as part of ATB-24)
Architecture#
The admin panel uses the same proxy architecture as the rest of the web app. The web server gates each page using the permissions already present in WebSession (fetched from GET /api/admin/members/me during session load). No new session infrastructure is needed.
Write Path (create, edit, delete)#
All structure mutations follow the PDS-first pattern consistent with how the rest of the AppView works. The AppView never writes to its database directly for these operations.
Browser
→ POST /admin/structure/categories (web server)
→ POST /api/admin/categories (AppView)
→ validate input + referential integrity (reads DB)
→ ForumAgent.putRecord() / deleteRecord() → PDS
← 201/200 (AT URI in response)
← Redirect to /admin/structure
[firehose, near-instant on self-hosted — eventually consistent by design]
→ indexer processes the event
→ DB row created / updated / deleted
→ GET /api/categories (public, reads DB)
← renders updated /admin/structure page
Delete pre-flight: The AppView checks referential integrity synchronously before the PDS write:
- Category delete: refuse with 409 if any boards reference that category in the DB
- Board delete: refuse with 409 if any posts have
boardIdpointing to that board
After the pre-flight passes, ForumAgent.deleteRecord() is called. The DB row is removed by the firehose, not by the AppView directly. This applies to deletes as well as creates and edits — no direct DB writes in the mutation path.
Consistency note: On a self-hosted instance the firehose runs in-process and indexes events near-instantly, so the redirect-after-POST will almost always land on up-to-date data. The gap is accepted by design; SSE is a future path to close it.
Web Server Permission Gates#
| Route | Required Permission |
|---|---|
GET /admin |
any of: manageMembers, manageCategories, moderatePosts, banUsers, lockTopics |
GET /admin/members |
manageMembers |
POST /admin/members/:did/role |
manageRoles |
GET /admin/structure |
manageCategories |
POST /admin/structure/categories |
manageCategories |
POST /admin/structure/categories/:id/edit |
manageCategories |
POST /admin/structure/categories/:id/delete |
manageCategories |
POST /admin/structure/boards |
manageCategories |
POST /admin/structure/boards/:id/edit |
manageCategories |
POST /admin/structure/boards/:id/delete |
manageCategories |
GET /admin/modlog |
any of: moderatePosts, banUsers, lockTopics |
Note: web-layer structure routes use POST for edit/delete (HTMX does not support PUT/DELETE from forms). The web server translates to the correct HTTP method when calling the AppView.
New AppView Endpoints#
All added to apps/appview/src/routes/admin.ts.
Category Management#
| Endpoint | Permission | Description |
|---|---|---|
POST /api/admin/categories |
manageCategories |
Create a new category. Writes space.atbb.forum.category record to Forum DID's PDS via ForumAgent. |
PUT /api/admin/categories/:id |
manageCategories |
Update name, description, or sortOrder. Fetches existing rkey from DB, calls putRecord with updated fields. |
DELETE /api/admin/categories/:id |
manageCategories |
Delete. Refuses with 409 if category has boards. Calls deleteRecord on PDS. |
Create/edit request body:
{
"name": "General Discussion",
"description": "Talk about anything.",
"sortOrder": 1
}
Create response (201):
{
"uri": "at://did:plc:.../space.atbb.forum.category/abc123",
"cid": "bafyrei..."
}
Board Management#
| Endpoint | Permission | Description |
|---|---|---|
POST /api/admin/boards |
manageCategories |
Create board under a category. Writes space.atbb.forum.board record with categoryRef strongRef. |
PUT /api/admin/boards/:id |
manageCategories |
Update name, description, sortOrder. |
DELETE /api/admin/boards/:id |
manageCategories |
Delete. Refuses with 409 if board has posts. Calls deleteRecord on PDS. |
Create request body:
{
"name": "General Chat",
"description": "Casual conversation.",
"sortOrder": 1,
"categoryUri": "at://did:plc:.../space.atbb.forum.category/abc123",
"categoryCid": "bafyrei..."
}
The categoryCid is required to construct the categoryRef strongRef in the board lexicon. The AppView fetches the category record from its DB to supply this when the client omits it — the client only needs to pass categoryUri.
Mod Action Log#
| Endpoint | Permission | Description |
|---|---|---|
GET /api/admin/modlog |
moderatePosts OR banUsers OR lockTopics |
Paginated list of mod actions. |
Query params: ?limit=50&offset=0
Response:
{
"actions": [
{
"id": "123",
"action": "space.atbb.modAction.ban",
"moderatorDid": "did:plc:abc",
"moderatorHandle": "alice.bsky.social",
"subjectDid": "did:plc:xyz",
"subjectHandle": "bob.bsky.social",
"subjectPostUri": null,
"reason": "Spam",
"createdAt": "2026-02-26T12:01:00Z"
}
],
"total": 42,
"offset": 0,
"limit": 50
}
The endpoint joins modActions with users twice: once for the moderator handle (via createdBy DID), once for the subject handle (via subjectDid, nullable for post-targeting actions).
Page Designs#
/admin — Landing Page#
No API call on load. Renders navigation cards from WebSession.permissions. Cards not present for permissions the user lacks.
Admin Panel
┌──────────────┬────────────────┬──────────────────┐
│ 👥 Members │ 📁 Structure │ 📋 Mod Log │
│ │ │ │
│ View and │ Manage │ Audit trail of │
│ assign │ categories │ moderation │
│ member │ and boards │ actions │
│ roles │ │ │
└──────────────┴────────────────┴──────────────────┘
A user with only moderatePosts sees only the Mod Log card. Attempting to access a gated page directly without the required permission returns 403.
/admin/members — Member List#
Fetches GET /api/admin/members (member list) and GET /api/admin/roles (available roles for the dropdown). Role assignment dropdown is only rendered if the current user also has manageRoles.
Members (47)
Handle Role Joined
alice.bsky.social Owner Jan 1 2026 —
bob.bsky.social Moderator Jan 5 2026 [Moderator ▼] [Assign]
carol.bsky.social Member Jan 8 2026 [Member ▼] [Assign]
...
Role assignment uses HTMX: the <select> + submit button submit to POST /admin/members/:did/role. On success, HTMX swaps the row's role badge. On failure, renders an inline error message for that row.
Note: The AppView's priority check prevents assigning a role with equal or higher authority than the assigning user's own role. This is already enforced server-side.
Pagination: Not in this plan. The AppView hard-caps at 100 members. A follow-up issue will add cursor-based pagination when forums grow large enough to need it.
/admin/structure — Forum Structure#
Reads the existing public GET /api/categories endpoint (which also returns boards per category). Write operations use the new admin endpoints via web server proxies.
Forum Structure
▾ General Discussion sortOrder: 1 [Edit] [Delete]
General Chat sortOrder: 1 [Edit] [Delete]
Introductions sortOrder: 2 [Edit] [Delete]
[+ Add Board to this category]
▾ Projects sortOrder: 2 [Edit] [Delete]
Showcase sortOrder: 1 [Edit] [Delete]
[+ Add Board to this category]
[+ Add Category]
- Edit forms expand inline via HTMX (or open as
<dialog>) - Delete requires a
<dialog>confirmation before submitting - 409 responses from the AppView render as inline user-friendly errors ("This category has boards — remove them first")
- Sort order is a numeric text field; no drag-and-drop
/admin/modlog — Mod Action Log#
Reads GET /api/admin/modlog. Simple offset pagination (50 per page). Read-only.
Mod Action Log
Time Moderator Action Subject Reason
2026-02-26 12:01 alice.bsky.social Ban bob.bsky.social Spam
2026-02-26 11:45 alice.bsky.social Lock "Intro thread" Off-topic
2026-02-26 11:30 carol.bsky.social Hide post by dave… Inappropriate
[← Previous] Page 1 of 3 [Next →]
Action labels are human-readable translations of the space.atbb.modAction.* token values.
Error Handling#
| Scenario | Response |
|---|---|
| Web server: user lacks required permission | 403 page |
| Web server: AppView 401 | Redirect to login |
| AppView: category/board delete blocked by referential integrity | 409 with { error: "..." } — web renders inline message |
| AppView: ForumAgent PDS write fails (network) | 503 — web renders "Forum temporarily unavailable. Please try again." |
| AppView: invalid input | 400 — web renders inline validation error |
| AppView: record not found | 404 — web renders inline error |
Testing#
AppView — Category/Board Endpoints#
- Create category → 201, PDS
putRecordcalled with correct fields - Create with missing name → 400, no PDS write
- Edit category name → 200, PDS
putRecordcalled with updated name, same rkey - Delete empty category → 200, PDS
deleteRecordcalled - Delete category with boards → 409, PDS
deleteRecordNOT called - Delete board with posts → 409, PDS
deleteRecordNOT called - All endpoints: unauthenticated → 401, lacks
manageCategories→ 403
AppView — Mod Log Endpoint#
- Returns paginated list joined with actor and subject handles
- Actions targeting a post (no subjectDid) →
subjectHandleis null,subjectPostUriis populated - Unauthenticated → 401
- Authenticated user with no mod permissions → 403
Web Server — Admin Routes#
/adminrenders only cards for permissions the session user holds/admin/memberswithoutmanageMembers→ 403/admin/structurewithoutmanageCategories→ 403/admin/modlogwithout any mod permission → 403- Role assignment row only rendered when session user has
manageRoles - Successful role assignment → HTMX swap updates the row
- AppView 409 on structure delete → inline error message rendered, no crash
Key Files#
New files:
apps/web/src/routes/admin.tsx— all four admin pages + proxy write routesapps/web/src/routes/__tests__/admin.test.tsx— web admin route tests
Modified files:
apps/appview/src/routes/admin.ts— add category/board CRUD + modlog endpointsapps/appview/src/routes/__tests__/admin.test.ts— add tests for new endpointsapps/web/src/routes/index.ts— register admin routesapps/web/src/lib/session.ts— addhasAnyAdminPermission()helper predicateapps/web/src/styles/— admin panel CSS (layout, table styles, structure tree)