WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

docs: admin panel design

Covers member management, forum structure CRUD (categories + boards),
and mod action audit log. Approach A: separate pages per section at
/admin/*.

All structure mutations follow PDS-first pattern (ForumAgent → firehose
→ DB) consistent with the rest of the AppView, not the bootstrap CLI's
dual-write shortcut.

+287
+287
docs/plans/2026-02-26-admin-panel-design.md
··· 1 + # Admin Panel — Design 2 + 3 + **Date:** 2026-02-26 4 + **Status:** Approved 5 + 6 + ## Summary 7 + 8 + 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. 9 + 10 + ## Scope 11 + 12 + **In scope:** 13 + - `/admin` landing page — permission-aware navigation dashboard 14 + - `/admin/members` — member list with inline role assignment 15 + - `/admin/structure` — full CRUD for categories and boards 16 + - `/admin/modlog` — paginated read-only mod action audit log 17 + - New AppView endpoints for category/board writes and mod log reads 18 + 19 + **Out of scope:** 20 + - Role management (create/edit/delete roles) — CLI-seeded only 21 + - Pagination for the member list (hard cap at 100, follow-up work) 22 + - SSE / push updates (consistency gap between PDS write and firehose indexing is a known, accepted trade-off) 23 + - In-context mod actions (already shipped as part of ATB-24) 24 + 25 + ## Architecture 26 + 27 + 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. 28 + 29 + ### Write Path (create, edit, delete) 30 + 31 + 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. 32 + 33 + ``` 34 + Browser 35 + → POST /admin/structure/categories (web server) 36 + → POST /api/admin/categories (AppView) 37 + → validate input + referential integrity (reads DB) 38 + → ForumAgent.putRecord() / deleteRecord() → PDS 39 + ← 201/200 (AT URI in response) 40 + ← Redirect to /admin/structure 41 + 42 + [firehose, near-instant on self-hosted — eventually consistent by design] 43 + → indexer processes the event 44 + → DB row created / updated / deleted 45 + 46 + → GET /api/categories (public, reads DB) 47 + ← renders updated /admin/structure page 48 + ``` 49 + 50 + **Delete pre-flight:** The AppView checks referential integrity synchronously *before* the PDS write: 51 + - Category delete: refuse with 409 if any boards reference that category in the DB 52 + - Board delete: refuse with 409 if any posts have `boardId` pointing to that board 53 + 54 + 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. 55 + 56 + **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. 57 + 58 + ### Web Server Permission Gates 59 + 60 + | Route | Required Permission | 61 + |-------|-------------------| 62 + | `GET /admin` | any of: `manageMembers`, `manageCategories`, `moderatePosts`, `banUsers`, `lockTopics` | 63 + | `GET /admin/members` | `manageMembers` | 64 + | `POST /admin/members/:did/role` | `manageRoles` | 65 + | `GET /admin/structure` | `manageCategories` | 66 + | `POST /admin/structure/categories` | `manageCategories` | 67 + | `POST /admin/structure/categories/:id/edit` | `manageCategories` | 68 + | `POST /admin/structure/categories/:id/delete` | `manageCategories` | 69 + | `POST /admin/structure/boards` | `manageCategories` | 70 + | `POST /admin/structure/boards/:id/edit` | `manageCategories` | 71 + | `POST /admin/structure/boards/:id/delete` | `manageCategories` | 72 + | `GET /admin/modlog` | any of: `moderatePosts`, `banUsers`, `lockTopics` | 73 + 74 + 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. 75 + 76 + ## New AppView Endpoints 77 + 78 + All added to `apps/appview/src/routes/admin.ts`. 79 + 80 + ### Category Management 81 + 82 + | Endpoint | Permission | Description | 83 + |----------|-----------|-------------| 84 + | `POST /api/admin/categories` | `manageCategories` | Create a new category. Writes `space.atbb.forum.category` record to Forum DID's PDS via ForumAgent. | 85 + | `PUT /api/admin/categories/:id` | `manageCategories` | Update name, description, or sortOrder. Fetches existing rkey from DB, calls `putRecord` with updated fields. | 86 + | `DELETE /api/admin/categories/:id` | `manageCategories` | Delete. Refuses with 409 if category has boards. Calls `deleteRecord` on PDS. | 87 + 88 + **Create/edit request body:** 89 + ```json 90 + { 91 + "name": "General Discussion", 92 + "description": "Talk about anything.", 93 + "sortOrder": 1 94 + } 95 + ``` 96 + 97 + **Create response (201):** 98 + ```json 99 + { 100 + "uri": "at://did:plc:.../space.atbb.forum.category/abc123", 101 + "cid": "bafyrei..." 102 + } 103 + ``` 104 + 105 + ### Board Management 106 + 107 + | Endpoint | Permission | Description | 108 + |----------|-----------|-------------| 109 + | `POST /api/admin/boards` | `manageCategories` | Create board under a category. Writes `space.atbb.forum.board` record with `categoryRef` strongRef. | 110 + | `PUT /api/admin/boards/:id` | `manageCategories` | Update name, description, sortOrder. | 111 + | `DELETE /api/admin/boards/:id` | `manageCategories` | Delete. Refuses with 409 if board has posts. Calls `deleteRecord` on PDS. | 112 + 113 + **Create request body:** 114 + ```json 115 + { 116 + "name": "General Chat", 117 + "description": "Casual conversation.", 118 + "sortOrder": 1, 119 + "categoryUri": "at://did:plc:.../space.atbb.forum.category/abc123", 120 + "categoryCid": "bafyrei..." 121 + } 122 + ``` 123 + 124 + 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`. 125 + 126 + ### Mod Action Log 127 + 128 + | Endpoint | Permission | Description | 129 + |----------|-----------|-------------| 130 + | `GET /api/admin/modlog` | `moderatePosts` OR `banUsers` OR `lockTopics` | Paginated list of mod actions. | 131 + 132 + **Query params:** `?limit=50&offset=0` 133 + 134 + **Response:** 135 + ```json 136 + { 137 + "actions": [ 138 + { 139 + "id": "123", 140 + "action": "space.atbb.modAction.ban", 141 + "moderatorDid": "did:plc:abc", 142 + "moderatorHandle": "alice.bsky.social", 143 + "subjectDid": "did:plc:xyz", 144 + "subjectHandle": "bob.bsky.social", 145 + "subjectPostUri": null, 146 + "reason": "Spam", 147 + "createdAt": "2026-02-26T12:01:00Z" 148 + } 149 + ], 150 + "total": 42, 151 + "offset": 0, 152 + "limit": 50 153 + } 154 + ``` 155 + 156 + 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). 157 + 158 + ## Page Designs 159 + 160 + ### `/admin` — Landing Page 161 + 162 + No API call on load. Renders navigation cards from `WebSession.permissions`. Cards not present for permissions the user lacks. 163 + 164 + ``` 165 + Admin Panel 166 + ┌──────────────┬────────────────┬──────────────────┐ 167 + │ 👥 Members │ 📁 Structure │ 📋 Mod Log │ 168 + │ │ │ │ 169 + │ View and │ Manage │ Audit trail of │ 170 + │ assign │ categories │ moderation │ 171 + │ member │ and boards │ actions │ 172 + │ roles │ │ │ 173 + └──────────────┴────────────────┴──────────────────┘ 174 + ``` 175 + 176 + A user with only `moderatePosts` sees only the Mod Log card. Attempting to access a gated page directly without the required permission returns 403. 177 + 178 + ### `/admin/members` — Member List 179 + 180 + 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`. 181 + 182 + ``` 183 + Members (47) 184 + 185 + Handle Role Joined 186 + alice.bsky.social Owner Jan 1 2026 — 187 + bob.bsky.social Moderator Jan 5 2026 [Moderator ▼] [Assign] 188 + carol.bsky.social Member Jan 8 2026 [Member ▼] [Assign] 189 + ... 190 + ``` 191 + 192 + 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. 193 + 194 + **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. 195 + 196 + **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. 197 + 198 + ### `/admin/structure` — Forum Structure 199 + 200 + 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. 201 + 202 + ``` 203 + Forum Structure 204 + 205 + ▾ General Discussion sortOrder: 1 [Edit] [Delete] 206 + General Chat sortOrder: 1 [Edit] [Delete] 207 + Introductions sortOrder: 2 [Edit] [Delete] 208 + [+ Add Board to this category] 209 + 210 + ▾ Projects sortOrder: 2 [Edit] [Delete] 211 + Showcase sortOrder: 1 [Edit] [Delete] 212 + [+ Add Board to this category] 213 + 214 + [+ Add Category] 215 + ``` 216 + 217 + - Edit forms expand inline via HTMX (or open as `<dialog>`) 218 + - Delete requires a `<dialog>` confirmation before submitting 219 + - 409 responses from the AppView render as inline user-friendly errors ("This category has boards — remove them first") 220 + - Sort order is a numeric text field; no drag-and-drop 221 + 222 + ### `/admin/modlog` — Mod Action Log 223 + 224 + Reads `GET /api/admin/modlog`. Simple offset pagination (50 per page). Read-only. 225 + 226 + ``` 227 + Mod Action Log 228 + 229 + Time Moderator Action Subject Reason 230 + 2026-02-26 12:01 alice.bsky.social Ban bob.bsky.social Spam 231 + 2026-02-26 11:45 alice.bsky.social Lock "Intro thread" Off-topic 232 + 2026-02-26 11:30 carol.bsky.social Hide post by dave… Inappropriate 233 + 234 + [← Previous] Page 1 of 3 [Next →] 235 + ``` 236 + 237 + Action labels are human-readable translations of the `space.atbb.modAction.*` token values. 238 + 239 + ## Error Handling 240 + 241 + | Scenario | Response | 242 + |----------|----------| 243 + | Web server: user lacks required permission | 403 page | 244 + | Web server: AppView 401 | Redirect to login | 245 + | AppView: category/board delete blocked by referential integrity | 409 with `{ error: "..." }` — web renders inline message | 246 + | AppView: ForumAgent PDS write fails (network) | 503 — web renders "Forum temporarily unavailable. Please try again." | 247 + | AppView: invalid input | 400 — web renders inline validation error | 248 + | AppView: record not found | 404 — web renders inline error | 249 + 250 + ## Testing 251 + 252 + ### AppView — Category/Board Endpoints 253 + - Create category → 201, PDS `putRecord` called with correct fields 254 + - Create with missing name → 400, no PDS write 255 + - Edit category name → 200, PDS `putRecord` called with updated name, same rkey 256 + - Delete empty category → 200, PDS `deleteRecord` called 257 + - Delete category with boards → 409, PDS `deleteRecord` NOT called 258 + - Delete board with posts → 409, PDS `deleteRecord` NOT called 259 + - All endpoints: unauthenticated → 401, lacks `manageCategories` → 403 260 + 261 + ### AppView — Mod Log Endpoint 262 + - Returns paginated list joined with actor and subject handles 263 + - Actions targeting a post (no subjectDid) → `subjectHandle` is null, `subjectPostUri` is populated 264 + - Unauthenticated → 401 265 + - Authenticated user with no mod permissions → 403 266 + 267 + ### Web Server — Admin Routes 268 + - `/admin` renders only cards for permissions the session user holds 269 + - `/admin/members` without `manageMembers` → 403 270 + - `/admin/structure` without `manageCategories` → 403 271 + - `/admin/modlog` without any mod permission → 403 272 + - Role assignment row only rendered when session user has `manageRoles` 273 + - Successful role assignment → HTMX swap updates the row 274 + - AppView 409 on structure delete → inline error message rendered, no crash 275 + 276 + ## Key Files 277 + 278 + **New files:** 279 + - `apps/web/src/routes/admin.tsx` — all four admin pages + proxy write routes 280 + - `apps/web/src/routes/__tests__/admin.test.tsx` — web admin route tests 281 + 282 + **Modified files:** 283 + - `apps/appview/src/routes/admin.ts` — add category/board CRUD + modlog endpoints 284 + - `apps/appview/src/routes/__tests__/admin.test.ts` — add tests for new endpoints 285 + - `apps/web/src/routes/index.ts` — register admin routes 286 + - `apps/web/src/lib/session.ts` — add `hasAnyAdminPermission()` helper predicate 287 + - `apps/web/src/styles/` — admin panel CSS (layout, table styles, structure tree)