···198 - 421 tests total (added 78 new tests) — comprehensive coverage including auth, validation, business logic, infrastructure errors
199 - Files: `apps/appview/src/routes/mod.ts` (~700 lines), `apps/appview/src/routes/__tests__/mod.test.ts` (~3414 lines), `apps/appview/src/lib/errors.ts` (error classification helpers)
200 - Bruno API collection: `bruno/AppView API/Moderation/` (6 .bru files documenting all endpoints)
201-- [ ] Admin UI: ban user, lock topic, hide post (ATB-24)
00000202- [x] **ATB-20: Enforce mod actions in read/write-path API responses** — **Complete:** 2026-02-16
203 - All API read endpoints filter soft-deleted posts (`deleted = false` in all queries)
204 - All API write endpoints (topic/post create) block banned users at request time
···198 - 421 tests total (added 78 new tests) — comprehensive coverage including auth, validation, business logic, infrastructure errors
199 - Files: `apps/appview/src/routes/mod.ts` (~700 lines), `apps/appview/src/routes/__tests__/mod.test.ts` (~3414 lines), `apps/appview/src/lib/errors.ts` (error classification helpers)
200 - Bruno API collection: `bruno/AppView API/Moderation/` (6 .bru files documenting all endpoints)
201+- [x] **ATB-24: Admin moderation UI in web app** — **Complete:** 2026-02-19
202+ - `GET /api/admin/members/me` AppView endpoint returns current user's role + permissions
203+ - `getSessionWithPermissions()` + `canLockTopics()` / `canModeratePosts()` / `canBanUsers()` helpers in web session lib
204+ - `POST /mod/action` web proxy route dispatches lock/unlock/hide/unhide/ban/unban to AppView
205+ - Topic page renders lock button, per-post hide/ban buttons, and shared `<dialog>` confirmation modal — all gated on permissions
206+ - 507 tests total across appview + web (added ~35 new tests)
207- [x] **ATB-20: Enforce mod actions in read/write-path API responses** — **Complete:** 2026-02-16
208 - All API read endpoints filter soft-deleted posts (`deleted = false` in all queries)
209 - All API write endpoints (topic/post create) block banned users at request time
···1+# Admin Moderation UI Design (ATB-24)
2+3+**Date:** 2026-02-19
4+**Issue:** [ATB-24](https://linear.app/atbb/issue/ATB-24/admin-moderation-ui-in-web-app)
5+**Status:** Approved
6+7+## Summary
8+9+Add in-context moderation action buttons to the topic view: lock/unlock a topic, hide/unhide individual posts, and ban/unban post authors. Mod buttons appear only for users with appropriate permissions. Each destructive action requires confirmation via a `<dialog>` modal with a reason field. All mod actions proxy through the web server to the AppView's existing mod API.
10+11+## Scope
12+13+**In scope:**
14+- `GET /api/admin/members/me` endpoint on AppView (permission data for the current user)
15+- Extended `WebSession` type on the web server (adds `permissions: Set<string>`)
16+- Lock/unlock button on topic page header (for users with `lockTopics`)
17+- Hide/unhide button on each post card (for users with `moderatePosts`)
18+- Ban/unban button on each post card (for users with `banUsers`)
19+- `<dialog>` confirmation modal with reason textarea, shared across all hide and ban actions
20+- Web proxy routes in `apps/web/src/routes/mod.ts`
21+- Visual indicators: locked topic banner (already exists), hidden post placeholder
22+23+**Out of scope:**
24+- Admin panel page (`/admin`)
25+- Member list or role assignment UI
26+- Mod action audit log
27+28+## Architecture
29+30+The web server acts as proxy for all mod actions, matching the established pattern from `topics.tsx` and `new-topic.tsx`. The browser communicates only with the web server (port 3001). The web server forwards requests to AppView (port 3000) with the session cookie.
31+32+```
33+Browser
34+ → POST /mod/lock (web server, port 3001)
35+ → POST /api/mod/lock (AppView, port 3000)
36+ → Forum PDS (modAction record)
37+```
38+39+## Components
40+41+### 1. AppView: `GET /api/admin/members/me`
42+43+**File:** `apps/appview/src/routes/admin.ts`
44+45+- Requires `requireAuth(ctx)` only — any authenticated member may call it
46+- Returns the caller's own membership record joined with role + permissions
47+- Returns 404 if no membership record exists (guest)
48+49+**Response shape:**
50+```json
51+{
52+ "did": "did:plc:abc123",
53+ "handle": "alice.bsky.social",
54+ "role": "Moderator",
55+ "roleUri": "at://did:plc:.../space.atbb.forum.role/abc",
56+ "permissions": [
57+ "space.atbb.permission.moderatePosts",
58+ "space.atbb.permission.lockTopics",
59+ "space.atbb.permission.banUsers"
60+ ]
61+}
62+```
63+64+### 2. Web Server: Extended `WebSession`
65+66+**File:** `apps/web/src/lib/session.ts`
67+68+`getSession()` gains a second stage: when the user is authenticated, call `GET /api/admin/members/me` (forwarding the session cookie). On 404 or network error, treat permissions as empty.
69+70+```typescript
71+type WebSession =
72+ | { authenticated: false }
73+ | {
74+ authenticated: true;
75+ did: string;
76+ handle: string;
77+ permissions: Set<string>;
78+ };
79+```
80+81+Helper predicates for readability:
82+```typescript
83+function canLockTopics(auth: WebSession): boolean
84+function canModeratePosts(auth: WebSession): boolean
85+function canBanUsers(auth: WebSession): boolean
86+```
87+88+### 3. Web Server: Mod Proxy Routes
89+90+**New file:** `apps/web/src/routes/mod.ts`
91+92+| Web Route | Proxies To | AppView Method |
93+|-----------|-----------|----------------|
94+| `POST /mod/lock` | `/api/mod/lock` | POST |
95+| `POST /mod/unlock/:topicId` | `/api/mod/lock/:topicId` | DELETE |
96+| `POST /mod/hide` | `/api/mod/hide` | POST |
97+| `POST /mod/unhide/:postId` | `/api/mod/hide/:postId` | DELETE |
98+| `POST /mod/ban` | `/api/mod/ban` | POST |
99+| `POST /mod/unban/:did` | `/api/mod/ban/:did` | DELETE |
100+101+All routes use `POST` from the browser (HTMX forms do not support `DELETE`). The web server translates to the correct HTTP method when calling AppView.
102+103+Each route:
104+1. Parses form body with `c.req.parseBody()`
105+2. Validates required fields (ID + reason)
106+3. Forwards to AppView with `Cookie` header
107+4. Returns an HTMX fragment on success (updated button/post area)
108+5. Returns `<p class="form-error">` on failure (same pattern as reply form)
109+110+### 4. Topic Page Changes
111+112+**File:** `apps/web/src/routes/topics.tsx`
113+114+**Lock/unlock button:** Added below `PageHeader`. Visible when `canLockTopics(auth)` is true. Submits to `POST /mod/lock` or `POST /mod/unlock/:topicId`. On success, HTMX swaps the locked banner + button area.
115+116+**PostCard component:** Gains a `modPerms` prop. Renders a "Mod actions" row below post content when the user has any relevant permission.
117+118+```tsx
119+{modPerms.canHide && (
120+ <button
121+ class="mod-btn mod-btn--hide"
122+ onclick={`openModDialog('hide', '${post.id}')`}
123+ >
124+ Hide
125+ </button>
126+)}
127+{modPerms.canBan && (
128+ <button
129+ class="mod-btn mod-btn--ban"
130+ onclick={`openModDialog('ban', '${post.author?.did}')`}
131+ >
132+ Ban user
133+ </button>
134+)}
135+```
136+137+**Shared `<dialog>` element:** One dialog instance at the bottom of the page. A small inline script (`openModDialog(action, targetId)`) sets hidden `<input>` values and calls `dialog.showModal()`. On HTMX success, the dialog closes and the target element swaps in the updated fragment.
138+139+### 5. Hidden Post Indicator
140+141+Posts where `post.hidden === true` (once ATB-20 surfaces this flag in the API) render as:
142+143+```tsx
144+<div class="post-card post-card--hidden">
145+ <em>[This post was hidden by a moderator.]</em>
146+ {modPerms.canHide && <button ...>Unhide</button>}
147+</div>
148+```
149+150+Note: ATB-20 already excludes hidden posts from the replies array for non-mods. For mods, the API may need a `?includeModerationData=true` flag in a future ticket — for now, unhide buttons appear on visible posts only (the hidden post is already excluded from the response for the current user).
151+152+## Error Handling
153+154+| Scenario | Web Server Response |
155+|----------|-------------------|
156+| AppView 401 | `<p class="form-error">You must be logged in.</p>` |
157+| AppView 403 | `<p class="form-error">You don't have permission for this action.</p>` |
158+| AppView 404 | `<p class="form-error">Target not found.</p>` |
159+| AppView 503 (network) | `<p class="form-error">Forum temporarily unavailable. Please try again.</p>` |
160+| AppView 500 | `<p class="form-error">Something went wrong. Please try again.</p>` |
161+| Missing/invalid form fields | `<p class="form-error">[specific validation message]</p>` |
162+163+## Testing
164+165+### AppView (`GET /api/admin/members/me`)
166+- Authenticated member with role → 200 with permissions array
167+- Authenticated member with no role assigned → 200 with empty permissions
168+- Authenticated user with no membership → 404
169+- Unauthenticated request → 401
170+171+### Web Server (`session.ts`)
172+- Authenticated user with mod permissions → `permissions` Set populated
173+- Authenticated user with no membership (AppView 404) → `permissions` empty Set
174+- AppView unreachable for member check → `permissions` empty Set (non-fatal)
175+176+### Web Server (`mod.ts`)
177+- Valid lock request → 200, AppView called with correct JSON body and cookie
178+- Valid hide request → 200, correct HTMX fragment returned
179+- AppView returns 403 → error fragment, no crash
180+- AppView returns 503 → error fragment with "try again" message
181+- Missing reason field → 400, error fragment before AppView call
182+- Invalid topicId → 400, error fragment before AppView call
183+184+### Web Server (`topics.test.tsx`)
185+- Topic rendered for user with `lockTopics` permission → lock button present
186+- Topic rendered for user without mod permissions → no mod buttons
187+- Locked topic → locked banner present, reply form absent
188+189+## Key Files
190+191+**New files:**
192+- `apps/web/src/routes/mod.ts` — web proxy routes
193+- `apps/web/src/routes/__tests__/mod.test.ts` — proxy route tests
194+195+**Modified files:**
196+- `apps/appview/src/routes/admin.ts` — add `GET /api/admin/members/me`
197+- `apps/appview/src/routes/__tests__/admin.test.ts` — add tests for new endpoint
198+- `apps/web/src/lib/session.ts` — extend `WebSession` with permissions
199+- `apps/web/src/lib/__tests__/session.test.ts` — add permission fetch tests
200+- `apps/web/src/routes/topics.tsx` — add mod buttons, dialog, lock button
201+- `apps/web/src/routes/__tests__/topics.test.tsx` — add mod button tests
202+- `apps/web/src/routes/index.ts` — register mod routes
203+- `apps/web/src/styles/` — mod button and dialog CSS