···11+# Admin Moderation UI Design (ATB-24)
22+33+**Date:** 2026-02-19
44+**Issue:** [ATB-24](https://linear.app/atbb/issue/ATB-24/admin-moderation-ui-in-web-app)
55+**Status:** Approved
66+77+## Summary
88+99+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.
1010+1111+## Scope
1212+1313+**In scope:**
1414+- `GET /api/admin/members/me` endpoint on AppView (permission data for the current user)
1515+- Extended `WebSession` type on the web server (adds `permissions: Set<string>`)
1616+- Lock/unlock button on topic page header (for users with `lockTopics`)
1717+- Hide/unhide button on each post card (for users with `moderatePosts`)
1818+- Ban/unban button on each post card (for users with `banUsers`)
1919+- `<dialog>` confirmation modal with reason textarea, shared across all hide and ban actions
2020+- Web proxy routes in `apps/web/src/routes/mod.ts`
2121+- Visual indicators: locked topic banner (already exists), hidden post placeholder
2222+2323+**Out of scope:**
2424+- Admin panel page (`/admin`)
2525+- Member list or role assignment UI
2626+- Mod action audit log
2727+2828+## Architecture
2929+3030+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.
3131+3232+```
3333+Browser
3434+ → POST /mod/lock (web server, port 3001)
3535+ → POST /api/mod/lock (AppView, port 3000)
3636+ → Forum PDS (modAction record)
3737+```
3838+3939+## Components
4040+4141+### 1. AppView: `GET /api/admin/members/me`
4242+4343+**File:** `apps/appview/src/routes/admin.ts`
4444+4545+- Requires `requireAuth(ctx)` only — any authenticated member may call it
4646+- Returns the caller's own membership record joined with role + permissions
4747+- Returns 404 if no membership record exists (guest)
4848+4949+**Response shape:**
5050+```json
5151+{
5252+ "did": "did:plc:abc123",
5353+ "handle": "alice.bsky.social",
5454+ "role": "Moderator",
5555+ "roleUri": "at://did:plc:.../space.atbb.forum.role/abc",
5656+ "permissions": [
5757+ "space.atbb.permission.moderatePosts",
5858+ "space.atbb.permission.lockTopics",
5959+ "space.atbb.permission.banUsers"
6060+ ]
6161+}
6262+```
6363+6464+### 2. Web Server: Extended `WebSession`
6565+6666+**File:** `apps/web/src/lib/session.ts`
6767+6868+`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.
6969+7070+```typescript
7171+type WebSession =
7272+ | { authenticated: false }
7373+ | {
7474+ authenticated: true;
7575+ did: string;
7676+ handle: string;
7777+ permissions: Set<string>;
7878+ };
7979+```
8080+8181+Helper predicates for readability:
8282+```typescript
8383+function canLockTopics(auth: WebSession): boolean
8484+function canModeratePosts(auth: WebSession): boolean
8585+function canBanUsers(auth: WebSession): boolean
8686+```
8787+8888+### 3. Web Server: Mod Proxy Routes
8989+9090+**New file:** `apps/web/src/routes/mod.ts`
9191+9292+| Web Route | Proxies To | AppView Method |
9393+|-----------|-----------|----------------|
9494+| `POST /mod/lock` | `/api/mod/lock` | POST |
9595+| `POST /mod/unlock/:topicId` | `/api/mod/lock/:topicId` | DELETE |
9696+| `POST /mod/hide` | `/api/mod/hide` | POST |
9797+| `POST /mod/unhide/:postId` | `/api/mod/hide/:postId` | DELETE |
9898+| `POST /mod/ban` | `/api/mod/ban` | POST |
9999+| `POST /mod/unban/:did` | `/api/mod/ban/:did` | DELETE |
100100+101101+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.
102102+103103+Each route:
104104+1. Parses form body with `c.req.parseBody()`
105105+2. Validates required fields (ID + reason)
106106+3. Forwards to AppView with `Cookie` header
107107+4. Returns an HTMX fragment on success (updated button/post area)
108108+5. Returns `<p class="form-error">` on failure (same pattern as reply form)
109109+110110+### 4. Topic Page Changes
111111+112112+**File:** `apps/web/src/routes/topics.tsx`
113113+114114+**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.
115115+116116+**PostCard component:** Gains a `modPerms` prop. Renders a "Mod actions" row below post content when the user has any relevant permission.
117117+118118+```tsx
119119+{modPerms.canHide && (
120120+ <button
121121+ class="mod-btn mod-btn--hide"
122122+ onclick={`openModDialog('hide', '${post.id}')`}
123123+ >
124124+ Hide
125125+ </button>
126126+)}
127127+{modPerms.canBan && (
128128+ <button
129129+ class="mod-btn mod-btn--ban"
130130+ onclick={`openModDialog('ban', '${post.author?.did}')`}
131131+ >
132132+ Ban user
133133+ </button>
134134+)}
135135+```
136136+137137+**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.
138138+139139+### 5. Hidden Post Indicator
140140+141141+Posts where `post.hidden === true` (once ATB-20 surfaces this flag in the API) render as:
142142+143143+```tsx
144144+<div class="post-card post-card--hidden">
145145+ <em>[This post was hidden by a moderator.]</em>
146146+ {modPerms.canHide && <button ...>Unhide</button>}
147147+</div>
148148+```
149149+150150+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).
151151+152152+## Error Handling
153153+154154+| Scenario | Web Server Response |
155155+|----------|-------------------|
156156+| AppView 401 | `<p class="form-error">You must be logged in.</p>` |
157157+| AppView 403 | `<p class="form-error">You don't have permission for this action.</p>` |
158158+| AppView 404 | `<p class="form-error">Target not found.</p>` |
159159+| AppView 503 (network) | `<p class="form-error">Forum temporarily unavailable. Please try again.</p>` |
160160+| AppView 500 | `<p class="form-error">Something went wrong. Please try again.</p>` |
161161+| Missing/invalid form fields | `<p class="form-error">[specific validation message]</p>` |
162162+163163+## Testing
164164+165165+### AppView (`GET /api/admin/members/me`)
166166+- Authenticated member with role → 200 with permissions array
167167+- Authenticated member with no role assigned → 200 with empty permissions
168168+- Authenticated user with no membership → 404
169169+- Unauthenticated request → 401
170170+171171+### Web Server (`session.ts`)
172172+- Authenticated user with mod permissions → `permissions` Set populated
173173+- Authenticated user with no membership (AppView 404) → `permissions` empty Set
174174+- AppView unreachable for member check → `permissions` empty Set (non-fatal)
175175+176176+### Web Server (`mod.ts`)
177177+- Valid lock request → 200, AppView called with correct JSON body and cookie
178178+- Valid hide request → 200, correct HTMX fragment returned
179179+- AppView returns 403 → error fragment, no crash
180180+- AppView returns 503 → error fragment with "try again" message
181181+- Missing reason field → 400, error fragment before AppView call
182182+- Invalid topicId → 400, error fragment before AppView call
183183+184184+### Web Server (`topics.test.tsx`)
185185+- Topic rendered for user with `lockTopics` permission → lock button present
186186+- Topic rendered for user without mod permissions → no mod buttons
187187+- Locked topic → locked banner present, reply form absent
188188+189189+## Key Files
190190+191191+**New files:**
192192+- `apps/web/src/routes/mod.ts` — web proxy routes
193193+- `apps/web/src/routes/__tests__/mod.test.ts` — proxy route tests
194194+195195+**Modified files:**
196196+- `apps/appview/src/routes/admin.ts` — add `GET /api/admin/members/me`
197197+- `apps/appview/src/routes/__tests__/admin.test.ts` — add tests for new endpoint
198198+- `apps/web/src/lib/session.ts` — extend `WebSession` with permissions
199199+- `apps/web/src/lib/__tests__/session.test.ts` — add permission fetch tests
200200+- `apps/web/src/routes/topics.tsx` — add mod buttons, dialog, lock button
201201+- `apps/web/src/routes/__tests__/topics.test.tsx` — add mod button tests
202202+- `apps/web/src/routes/index.ts` — register mod routes
203203+- `apps/web/src/styles/` — mod button and dialog CSS
+1522
docs/plans/2026-02-19-admin-moderation-ui.md
···11+# Admin Moderation UI Implementation Plan (ATB-24)
22+33+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
44+55+**Goal:** Add lock/unlock, hide/unhide, and ban/unban mod action buttons to the topic view, visible only to users with appropriate permissions.
66+77+**Architecture:** The web server proxies all mod actions to the AppView's existing mod API (`POST/DELETE /api/mod/*`) via a single `POST /mod/action` endpoint. A new AppView endpoint (`GET /api/admin/members/me`) returns the current user's permissions. `topics.tsx` uses a new `getSessionWithPermissions()` to decide which buttons to render. Confirmation uses a shared `<dialog>` populated by a small inline script.
88+99+**Tech Stack:** Hono, JSX, HTMX 2.x, Vitest, Drizzle ORM, PostgreSQL
1010+1111+---
1212+1313+## Task 1: AppView — `GET /api/admin/members/me`
1414+1515+Returns the calling user's own membership + permissions. No special permission required — any authenticated user may check their own role.
1616+1717+**Files:**
1818+- Modify: `apps/appview/src/routes/admin.ts`
1919+- Modify: `apps/appview/src/routes/__tests__/admin.test.ts`
2020+2121+### Step 1: Write the failing test
2222+2323+In `apps/appview/src/routes/__tests__/admin.test.ts`, add a new `describe` block after the existing ones:
2424+2525+```typescript
2626+describe("GET /api/admin/members/me", () => {
2727+ it("returns 401 when not authenticated", async () => {
2828+ // The requireAuth mock auto-passes, so temporarily bypass it:
2929+ // We test 401 by checking the middleware is there (integration test in a separate file if needed)
3030+ // For now, test the success cases through the mocked middleware
3131+ expect(true).toBe(true); // placeholder — real auth tested at middleware level
3232+ });
3333+3434+ it("returns 404 when user has no membership", async () => {
3535+ mockUser = { did: "did:plc:no-membership" };
3636+ const res = await app.request("/api/admin/members/me");
3737+ expect(res.status).toBe(404);
3838+ const data = await res.json();
3939+ expect(data.error).toBeDefined();
4040+ });
4141+4242+ it("returns membership with role and permissions for member with role", async () => {
4343+ // Insert forum, user, membership, and role records
4444+ const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
4545+ await ctx.db.insert(forums).values({
4646+ did: ctx.config.forumDid,
4747+ rkey: "self",
4848+ cid: "bafyforum",
4949+ name: "Test Forum",
5050+ description: null,
5151+ createdAt: new Date(),
5252+ indexedAt: new Date(),
5353+ });
5454+5555+ await ctx.db.insert(users).values({
5656+ did: "did:plc:me",
5757+ handle: "me.bsky.social",
5858+ indexedAt: new Date(),
5959+ });
6060+6161+ const roleRkey = "moderatorrkey";
6262+ await ctx.db.insert(roles).values({
6363+ did: ctx.config.forumDid,
6464+ rkey: roleRkey,
6565+ cid: "bafyrole",
6666+ name: "Moderator",
6767+ description: null,
6868+ permissions: ["space.atbb.permission.moderatePosts", "space.atbb.permission.lockTopics"],
6969+ priority: 20,
7070+ createdAt: new Date(),
7171+ indexedAt: new Date(),
7272+ });
7373+7474+ const roleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${roleRkey}`;
7575+ await ctx.db.insert(memberships).values({
7676+ did: "did:plc:me",
7777+ rkey: "membershiprkey",
7878+ cid: "bafymembership",
7979+ forumUri,
8080+ roleUri,
8181+ joinedAt: new Date(),
8282+ indexedAt: new Date(),
8383+ });
8484+8585+ mockUser = { did: "did:plc:me" };
8686+ const res = await app.request("/api/admin/members/me");
8787+ expect(res.status).toBe(200);
8888+ const data = await res.json();
8989+ expect(data.did).toBe("did:plc:me");
9090+ expect(data.handle).toBe("me.bsky.social");
9191+ expect(data.role).toBe("Moderator");
9292+ expect(data.roleUri).toBe(roleUri);
9393+ expect(data.permissions).toContain("space.atbb.permission.moderatePosts");
9494+ expect(data.permissions).toContain("space.atbb.permission.lockTopics");
9595+ });
9696+9797+ it("returns membership with empty permissions when role has no permissions", async () => {
9898+ const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
9999+ await ctx.db.insert(forums).values({
100100+ did: ctx.config.forumDid,
101101+ rkey: "self",
102102+ cid: "bafyforum2",
103103+ name: "Test Forum",
104104+ description: null,
105105+ createdAt: new Date(),
106106+ indexedAt: new Date(),
107107+ });
108108+ await ctx.db.insert(users).values({
109109+ did: "did:plc:member",
110110+ handle: "member.bsky.social",
111111+ indexedAt: new Date(),
112112+ });
113113+ const roleRkey = "memberrkey";
114114+ await ctx.db.insert(roles).values({
115115+ did: ctx.config.forumDid,
116116+ rkey: roleRkey,
117117+ cid: "bafyrole2",
118118+ name: "Member",
119119+ description: null,
120120+ permissions: [],
121121+ priority: 30,
122122+ createdAt: new Date(),
123123+ indexedAt: new Date(),
124124+ });
125125+ const roleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${roleRkey}`;
126126+ await ctx.db.insert(memberships).values({
127127+ did: "did:plc:member",
128128+ rkey: "membershiprkey2",
129129+ cid: "bafymembership2",
130130+ forumUri,
131131+ roleUri,
132132+ joinedAt: new Date(),
133133+ indexedAt: new Date(),
134134+ });
135135+136136+ mockUser = { did: "did:plc:member" };
137137+ const res = await app.request("/api/admin/members/me");
138138+ expect(res.status).toBe(200);
139139+ const data = await res.json();
140140+ expect(data.permissions).toEqual([]);
141141+ });
142142+});
143143+```
144144+145145+Also add missing imports at the top of the test file (after existing imports):
146146+```typescript
147147+import { forums } from "@atbb/db";
148148+```
149149+150150+### Step 2: Run test to verify it fails
151151+152152+```bash
153153+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
154154+ pnpm --filter @atbb/appview test src/routes/__tests__/admin.test.ts
155155+```
156156+Expected: FAIL — `GET /api/admin/members/me` route doesn't exist yet.
157157+158158+### Step 3: Implement the endpoint
159159+160160+In `apps/appview/src/routes/admin.ts`, add the following import at the top if `forums` isn't already imported:
161161+162162+```typescript
163163+import { memberships, roles, users, forums } from "@atbb/db";
164164+```
165165+166166+Then add this endpoint before `return app;` at the bottom:
167167+168168+```typescript
169169+/**
170170+ * GET /api/admin/members/me
171171+ *
172172+ * Returns the calling user's own membership, role, and permissions.
173173+ * Any authenticated user may call this — no special permission required.
174174+ * Returns 404 if the user has no membership record.
175175+ */
176176+app.get("/members/me", requireAuth(ctx), async (c) => {
177177+ const user = c.get("user")!;
178178+179179+ try {
180180+ const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`;
181181+ const [member] = await ctx.db
182182+ .select({
183183+ did: memberships.did,
184184+ handle: users.handle,
185185+ roleUri: memberships.roleUri,
186186+ roleName: roles.name,
187187+ permissions: roles.permissions,
188188+ })
189189+ .from(memberships)
190190+ .leftJoin(users, eq(memberships.did, users.did))
191191+ .leftJoin(
192192+ roles,
193193+ sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}`
194194+ )
195195+ .where(
196196+ and(
197197+ eq(memberships.did, user.did),
198198+ eq(memberships.forumUri, forumUri)
199199+ )
200200+ )
201201+ .limit(1);
202202+203203+ if (!member) {
204204+ return c.json({ error: "Membership not found" }, 404);
205205+ }
206206+207207+ return c.json({
208208+ did: member.did,
209209+ handle: member.handle || user.did,
210210+ role: member.roleName || "Member",
211211+ roleUri: member.roleUri,
212212+ permissions: member.permissions || [],
213213+ });
214214+ } catch (error) {
215215+ console.error("Failed to get current user membership", {
216216+ operation: "GET /api/admin/members/me",
217217+ did: user.did,
218218+ error: error instanceof Error ? error.message : String(error),
219219+ });
220220+ return c.json(
221221+ { error: "Failed to retrieve your membership. Please try again later." },
222222+ 500
223223+ );
224224+ }
225225+});
226226+```
227227+228228+You'll need these imports at the top of `admin.ts` if not already present: `and`, `eq`, `sql` from `drizzle-orm`.
229229+230230+Check existing imports at the top of the file and add any missing ones:
231231+```typescript
232232+import { eq, asc, and, sql } from "drizzle-orm";
233233+```
234234+235235+### Step 4: Run tests to verify they pass
236236+237237+```bash
238238+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
239239+ pnpm --filter @atbb/appview test src/routes/__tests__/admin.test.ts
240240+```
241241+Expected: all tests PASS
242242+243243+### Step 5: Commit
244244+245245+```bash
246246+git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
247247+git commit -m "feat(appview): add GET /api/admin/members/me endpoint (ATB-24)"
248248+```
249249+250250+---
251251+252252+## Task 2: Web Server — `getSessionWithPermissions()` and permission helpers
253253+254254+Add a new function to `session.ts` that extends authentication with permission data. Keeps the existing `getSession()` unchanged so other pages are unaffected.
255255+256256+**Files:**
257257+- Modify: `apps/web/src/lib/session.ts`
258258+- Modify: `apps/web/src/lib/__tests__/session.test.ts`
259259+260260+### Step 1: Write the failing tests
261261+262262+Add to `apps/web/src/lib/__tests__/session.test.ts` (after the existing `describe("getSession", ...)` block):
263263+264264+```typescript
265265+import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers } from "../session.js";
266266+267267+describe("getSessionWithPermissions", () => {
268268+ beforeEach(() => {
269269+ vi.stubGlobal("fetch", mockFetch);
270270+ });
271271+272272+ afterEach(() => {
273273+ vi.unstubAllGlobals();
274274+ mockFetch.mockReset();
275275+ });
276276+277277+ it("returns unauthenticated with empty permissions when no cookie", async () => {
278278+ const result = await getSessionWithPermissions("http://localhost:3000");
279279+ expect(result).toMatchObject({ authenticated: false });
280280+ expect(result.permissions.size).toBe(0);
281281+ });
282282+283283+ it("returns authenticated with empty permissions when members/me returns 404", async () => {
284284+ mockFetch.mockResolvedValueOnce({
285285+ ok: true,
286286+ json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
287287+ });
288288+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
289289+290290+ const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
291291+ expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" });
292292+ expect(result.permissions.size).toBe(0);
293293+ });
294294+295295+ it("returns permissions as Set when members/me succeeds", async () => {
296296+ mockFetch.mockResolvedValueOnce({
297297+ ok: true,
298298+ json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }),
299299+ });
300300+ mockFetch.mockResolvedValueOnce({
301301+ ok: true,
302302+ json: () => Promise.resolve({
303303+ did: "did:plc:mod",
304304+ handle: "mod.bsky.social",
305305+ role: "Moderator",
306306+ roleUri: "at://...",
307307+ permissions: [
308308+ "space.atbb.permission.moderatePosts",
309309+ "space.atbb.permission.lockTopics",
310310+ "space.atbb.permission.banUsers",
311311+ ],
312312+ }),
313313+ });
314314+315315+ const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
316316+ expect(result.authenticated).toBe(true);
317317+ expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true);
318318+ expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true);
319319+ expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true);
320320+ expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false);
321321+ });
322322+323323+ it("returns empty permissions without crashing when members/me call throws", async () => {
324324+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
325325+ mockFetch.mockResolvedValueOnce({
326326+ ok: true,
327327+ json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
328328+ });
329329+ mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
330330+331331+ const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
332332+ expect(result.authenticated).toBe(true);
333333+ expect(result.permissions.size).toBe(0);
334334+ expect(consoleSpy).toHaveBeenCalledWith(
335335+ expect.stringContaining("failed to fetch permissions"),
336336+ expect.any(Object)
337337+ );
338338+ consoleSpy.mockRestore();
339339+ });
340340+341341+ it("does not log error when members/me returns 404 (expected for guests)", async () => {
342342+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
343343+ mockFetch.mockResolvedValueOnce({
344344+ ok: true,
345345+ json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
346346+ });
347347+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
348348+349349+ await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
350350+ expect(consoleSpy).not.toHaveBeenCalled();
351351+ consoleSpy.mockRestore();
352352+ });
353353+354354+ it("forwards cookie header to members/me call", async () => {
355355+ mockFetch.mockResolvedValueOnce({
356356+ ok: true,
357357+ json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
358358+ });
359359+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
360360+361361+ await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken");
362362+363363+ expect(mockFetch).toHaveBeenCalledTimes(2);
364364+ const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit];
365365+ expect(url).toBe("http://localhost:3000/api/admin/members/me");
366366+ expect((init.headers as Record<string, string>)["Cookie"]).toBe("atbb_session=mytoken");
367367+ });
368368+});
369369+370370+describe("permission helpers", () => {
371371+ const modSession = {
372372+ authenticated: true as const,
373373+ did: "did:plc:mod",
374374+ handle: "mod.bsky.social",
375375+ permissions: new Set([
376376+ "space.atbb.permission.lockTopics",
377377+ "space.atbb.permission.moderatePosts",
378378+ "space.atbb.permission.banUsers",
379379+ ]),
380380+ };
381381+382382+ const memberSession = {
383383+ authenticated: true as const,
384384+ did: "did:plc:member",
385385+ handle: "member.bsky.social",
386386+ permissions: new Set<string>(),
387387+ };
388388+389389+ const unauthSession = { authenticated: false as const, permissions: new Set<string>() };
390390+391391+ it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true));
392392+ it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false));
393393+ it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false));
394394+395395+ it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true));
396396+ it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false));
397397+398398+ it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true));
399399+ it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false));
400400+});
401401+```
402402+403403+Also update the import at the top of `session.test.ts` to include the new exports (they don't exist yet, so this will cause the test to fail):
404404+```typescript
405405+import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers } from "../session.js";
406406+```
407407+408408+### Step 2: Run tests to verify they fail
409409+410410+```bash
411411+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
412412+ pnpm --filter @atbb/web test src/lib/__tests__/session.test.ts
413413+```
414414+Expected: FAIL — `getSessionWithPermissions` not found.
415415+416416+### Step 3: Implement the new functions
417417+418418+In `apps/web/src/lib/session.ts`, add after the existing `WebSession` type and `getSession` function:
419419+420420+```typescript
421421+/**
422422+ * Extended session type that includes the user's role permissions.
423423+ * Used on pages that need to conditionally render moderation UI.
424424+ */
425425+export type WebSessionWithPermissions =
426426+ | { authenticated: false; permissions: Set<string> }
427427+ | { authenticated: true; did: string; handle: string; permissions: Set<string> };
428428+429429+/**
430430+ * Like getSession(), but also fetches the user's role permissions from
431431+ * GET /api/admin/members/me. Use on pages that need to render mod buttons.
432432+ *
433433+ * Returns empty permissions on network errors or when user has no membership.
434434+ * Never throws — always returns a usable session.
435435+ */
436436+export async function getSessionWithPermissions(
437437+ appviewUrl: string,
438438+ cookieHeader?: string
439439+): Promise<WebSessionWithPermissions> {
440440+ const session = await getSession(appviewUrl, cookieHeader);
441441+442442+ if (!session.authenticated) {
443443+ return { authenticated: false, permissions: new Set() };
444444+ }
445445+446446+ let permissions = new Set<string>();
447447+ try {
448448+ const res = await fetch(`${appviewUrl}/api/admin/members/me`, {
449449+ headers: { Cookie: cookieHeader! },
450450+ });
451451+452452+ if (res.ok) {
453453+ const data = (await res.json()) as Record<string, unknown>;
454454+ if (Array.isArray(data.permissions)) {
455455+ permissions = new Set(data.permissions as string[]);
456456+ }
457457+ } else if (res.status !== 404) {
458458+ // 404 = no membership = expected for guests, no log
459459+ console.error("getSessionWithPermissions: unexpected status from members/me", {
460460+ operation: "GET /api/admin/members/me",
461461+ status: res.status,
462462+ });
463463+ }
464464+ } catch (error) {
465465+ console.error(
466466+ "getSessionWithPermissions: failed to fetch permissions — continuing with empty permissions",
467467+ {
468468+ operation: "GET /api/admin/members/me",
469469+ did: session.did,
470470+ error: error instanceof Error ? error.message : String(error),
471471+ }
472472+ );
473473+ }
474474+475475+ return { ...session, permissions };
476476+}
477477+478478+/** Returns true if the session grants permission to lock/unlock topics. */
479479+export function canLockTopics(auth: WebSessionWithPermissions): boolean {
480480+ return auth.authenticated && auth.permissions.has("space.atbb.permission.lockTopics");
481481+}
482482+483483+/** Returns true if the session grants permission to hide/unhide posts. */
484484+export function canModeratePosts(auth: WebSessionWithPermissions): boolean {
485485+ return auth.authenticated && auth.permissions.has("space.atbb.permission.moderatePosts");
486486+}
487487+488488+/** Returns true if the session grants permission to ban/unban users. */
489489+export function canBanUsers(auth: WebSessionWithPermissions): boolean {
490490+ return auth.authenticated && auth.permissions.has("space.atbb.permission.banUsers");
491491+}
492492+```
493493+494494+### Step 4: Run tests to verify they pass
495495+496496+```bash
497497+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
498498+ pnpm --filter @atbb/web test src/lib/__tests__/session.test.ts
499499+```
500500+Expected: all tests PASS
501501+502502+### Step 5: Commit
503503+504504+```bash
505505+git add apps/web/src/lib/session.ts apps/web/src/lib/__tests__/session.test.ts
506506+git commit -m "feat(web): add getSessionWithPermissions and canXxx helpers (ATB-24)"
507507+```
508508+509509+---
510510+511511+## Task 3: Web Server — `POST /mod/action` proxy route
512512+513513+A single web endpoint handles all six mod actions (lock, unlock, hide, unhide, ban, unban). It reads an `action` field from the form body and dispatches to the correct AppView mod endpoint.
514514+515515+**Files:**
516516+- Create: `apps/web/src/routes/mod.ts`
517517+- Create: `apps/web/src/routes/__tests__/mod.test.ts`
518518+- Modify: `apps/web/src/routes/index.ts`
519519+520520+### Step 1: Write the failing tests
521521+522522+Create `apps/web/src/routes/__tests__/mod.test.ts`:
523523+524524+```typescript
525525+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
526526+527527+const mockFetch = vi.fn();
528528+529529+describe("createModActionRoute", () => {
530530+ beforeEach(() => {
531531+ vi.stubGlobal("fetch", mockFetch);
532532+ vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
533533+ vi.resetModules();
534534+ // Default: AppView returns error
535535+ mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
536536+ });
537537+538538+ afterEach(() => {
539539+ vi.unstubAllGlobals();
540540+ vi.unstubAllEnvs();
541541+ mockFetch.mockReset();
542542+ });
543543+544544+ function appviewSuccess() {
545545+ return {
546546+ ok: true,
547547+ status: 200,
548548+ json: () => Promise.resolve({ success: true }),
549549+ };
550550+ }
551551+552552+ async function loadModRoutes() {
553553+ const { createModActionRoute } = await import("../mod.js");
554554+ return createModActionRoute("http://localhost:3000");
555555+ }
556556+557557+ function modFormBody(fields: Record<string, string>) {
558558+ return {
559559+ method: "POST",
560560+ headers: {
561561+ "Content-Type": "application/x-www-form-urlencoded",
562562+ cookie: "atbb_session=token",
563563+ },
564564+ body: new URLSearchParams(fields).toString(),
565565+ };
566566+ }
567567+568568+ // ─── Input validation ────────────────────────────────────────────────────
569569+570570+ it("returns 400 error fragment when action is missing", async () => {
571571+ const routes = await loadModRoutes();
572572+ const res = await routes.request(
573573+ "/mod/action",
574574+ modFormBody({ id: "1", reason: "test" })
575575+ );
576576+ expect(res.status).toBe(200);
577577+ const html = await res.text();
578578+ expect(html).toContain("form-error");
579579+ expect(html).toContain("Unknown action");
580580+ expect(mockFetch).not.toHaveBeenCalled();
581581+ });
582582+583583+ it("returns 400 error fragment when reason is empty", async () => {
584584+ const routes = await loadModRoutes();
585585+ const res = await routes.request(
586586+ "/mod/action",
587587+ modFormBody({ action: "lock", id: "1", reason: "" })
588588+ );
589589+ expect(res.status).toBe(200);
590590+ const html = await res.text();
591591+ expect(html).toContain("form-error");
592592+ expect(mockFetch).not.toHaveBeenCalled();
593593+ });
594594+595595+ it("returns 400 error fragment when id is missing for lock action", async () => {
596596+ const routes = await loadModRoutes();
597597+ const res = await routes.request(
598598+ "/mod/action",
599599+ modFormBody({ action: "lock", reason: "test" })
600600+ );
601601+ expect(res.status).toBe(200);
602602+ const html = await res.text();
603603+ expect(html).toContain("form-error");
604604+ expect(mockFetch).not.toHaveBeenCalled();
605605+ });
606606+607607+ // ─── Lock action ─────────────────────────────────────────────────────────
608608+609609+ it("posts to /api/mod/lock with topicId and reason for lock action", async () => {
610610+ mockFetch.mockResolvedValueOnce(appviewSuccess());
611611+ const routes = await loadModRoutes();
612612+ const res = await routes.request(
613613+ "/mod/action",
614614+ modFormBody({ action: "lock", id: "42", reason: "Off-topic thread" })
615615+ );
616616+ expect(res.status).toBe(200);
617617+ expect(res.headers.get("HX-Refresh")).toBe("true");
618618+619619+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
620620+ expect(url).toBe("http://localhost:3000/api/mod/lock");
621621+ expect(init.method).toBe("POST");
622622+ const body = JSON.parse(init.body as string);
623623+ expect(body.topicId).toBe("42");
624624+ expect(body.reason).toBe("Off-topic thread");
625625+ expect((init.headers as Record<string, string>)["Cookie"]).toContain("atbb_session=token");
626626+ });
627627+628628+ it("sends DELETE to /api/mod/lock/:id for unlock action", async () => {
629629+ mockFetch.mockResolvedValueOnce(appviewSuccess());
630630+ const routes = await loadModRoutes();
631631+ const res = await routes.request(
632632+ "/mod/action",
633633+ modFormBody({ action: "unlock", id: "42", reason: "Thread reopened" })
634634+ );
635635+ expect(res.status).toBe(200);
636636+ expect(res.headers.get("HX-Refresh")).toBe("true");
637637+638638+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
639639+ expect(url).toBe("http://localhost:3000/api/mod/lock/42");
640640+ expect(init.method).toBe("DELETE");
641641+ });
642642+643643+ // ─── Hide action ─────────────────────────────────────────────────────────
644644+645645+ it("posts to /api/mod/hide with postId and reason for hide action", async () => {
646646+ mockFetch.mockResolvedValueOnce(appviewSuccess());
647647+ const routes = await loadModRoutes();
648648+ const res = await routes.request(
649649+ "/mod/action",
650650+ modFormBody({ action: "hide", id: "99", reason: "Spam" })
651651+ );
652652+ expect(res.status).toBe(200);
653653+ expect(res.headers.get("HX-Refresh")).toBe("true");
654654+655655+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
656656+ expect(url).toBe("http://localhost:3000/api/mod/hide");
657657+ const body = JSON.parse(init.body as string);
658658+ expect(body.postId).toBe("99");
659659+ });
660660+661661+ it("sends DELETE to /api/mod/hide/:id for unhide action", async () => {
662662+ mockFetch.mockResolvedValueOnce(appviewSuccess());
663663+ const routes = await loadModRoutes();
664664+ await routes.request(
665665+ "/mod/action",
666666+ modFormBody({ action: "unhide", id: "99", reason: "Reinstated" })
667667+ );
668668+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
669669+ expect(url).toBe("http://localhost:3000/api/mod/hide/99");
670670+ expect(init.method).toBe("DELETE");
671671+ });
672672+673673+ // ─── Ban action ──────────────────────────────────────────────────────────
674674+675675+ it("posts to /api/mod/ban with targetDid and reason for ban action", async () => {
676676+ mockFetch.mockResolvedValueOnce(appviewSuccess());
677677+ const routes = await loadModRoutes();
678678+ const res = await routes.request(
679679+ "/mod/action",
680680+ modFormBody({ action: "ban", id: "did:plc:badactor", reason: "Harassment" })
681681+ );
682682+ expect(res.status).toBe(200);
683683+ expect(res.headers.get("HX-Refresh")).toBe("true");
684684+685685+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
686686+ expect(url).toBe("http://localhost:3000/api/mod/ban");
687687+ const body = JSON.parse(init.body as string);
688688+ expect(body.targetDid).toBe("did:plc:badactor");
689689+ });
690690+691691+ it("sends DELETE to /api/mod/ban/:id for unban action", async () => {
692692+ mockFetch.mockResolvedValueOnce(appviewSuccess());
693693+ const routes = await loadModRoutes();
694694+ await routes.request(
695695+ "/mod/action",
696696+ modFormBody({ action: "unban", id: "did:plc:unbanned", reason: "Appeal accepted" })
697697+ );
698698+ const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
699699+ expect(url).toBe("http://localhost:3000/api/mod/ban/did:plc:unbanned");
700700+ expect(init.method).toBe("DELETE");
701701+ });
702702+703703+ // ─── Error handling ──────────────────────────────────────────────────────
704704+705705+ it("returns error fragment when AppView returns 403", async () => {
706706+ mockFetch.mockResolvedValueOnce({
707707+ ok: false,
708708+ status: 403,
709709+ json: () => Promise.resolve({ error: "Insufficient permissions" }),
710710+ });
711711+ const routes = await loadModRoutes();
712712+ const res = await routes.request(
713713+ "/mod/action",
714714+ modFormBody({ action: "lock", id: "1", reason: "test" })
715715+ );
716716+ expect(res.status).toBe(200);
717717+ const html = await res.text();
718718+ expect(html).toContain("form-error");
719719+ expect(html).toContain("permission");
720720+ expect(res.headers.get("HX-Refresh")).toBeNull();
721721+ });
722722+723723+ it("returns error fragment when AppView returns 503 (network error)", async () => {
724724+ mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
725725+ const routes = await loadModRoutes();
726726+ const res = await routes.request(
727727+ "/mod/action",
728728+ modFormBody({ action: "hide", id: "1", reason: "test" })
729729+ );
730730+ expect(res.status).toBe(200);
731731+ const html = await res.text();
732732+ expect(html).toContain("form-error");
733733+ expect(html).toContain("unavailable");
734734+ });
735735+736736+ it("returns error fragment when AppView returns 500", async () => {
737737+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" });
738738+ const routes = await loadModRoutes();
739739+ const res = await routes.request(
740740+ "/mod/action",
741741+ modFormBody({ action: "ban", id: "did:plc:x", reason: "test" })
742742+ );
743743+ expect(res.status).toBe(200);
744744+ const html = await res.text();
745745+ expect(html).toContain("form-error");
746746+ expect(html).toContain("went wrong");
747747+ });
748748+});
749749+```
750750+751751+### Step 2: Run test to verify it fails
752752+753753+```bash
754754+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
755755+ pnpm --filter @atbb/web test src/routes/__tests__/mod.test.ts
756756+```
757757+Expected: FAIL — module not found.
758758+759759+### Step 3: Implement `mod.ts`
760760+761761+Create `apps/web/src/routes/mod.ts`:
762762+763763+```typescript
764764+import { Hono } from "hono";
765765+import { isProgrammingError } from "../lib/errors.js";
766766+767767+/**
768768+ * Single proxy endpoint for all moderation actions.
769769+ *
770770+ * Reads `action`, `id`, and `reason` from the form body and dispatches
771771+ * to the correct AppView mod endpoint. Returns HX-Refresh on success
772772+ * so HTMX reloads the current page to show updated state.
773773+ *
774774+ * Action dispatch table:
775775+ * lock → POST /api/mod/lock body: { topicId, reason }
776776+ * unlock → DELETE /api/mod/lock/:id body: { reason }
777777+ * hide → POST /api/mod/hide body: { postId, reason }
778778+ * unhide → DELETE /api/mod/hide/:id body: { reason }
779779+ * ban → POST /api/mod/ban body: { targetDid, reason }
780780+ * unban → DELETE /api/mod/ban/:id body: { reason }
781781+ */
782782+export function createModActionRoute(appviewUrl: string) {
783783+ return new Hono().post("/mod/action", async (c) => {
784784+ let body: Record<string, string | File>;
785785+ try {
786786+ body = await c.req.parseBody();
787787+ } catch {
788788+ return c.html(`<p class="form-error">Invalid form submission.</p>`);
789789+ }
790790+791791+ const action = typeof body.action === "string" ? body.action.trim() : "";
792792+ const id = typeof body.id === "string" ? body.id.trim() : "";
793793+ const reason = typeof body.reason === "string" ? body.reason.trim() : "";
794794+ const cookieHeader = c.req.header("cookie") ?? "";
795795+796796+ // Validate action
797797+ const validActions = ["lock", "unlock", "hide", "unhide", "ban", "unban"];
798798+ if (!validActions.includes(action)) {
799799+ return c.html(`<p class="form-error">Unknown action.</p>`);
800800+ }
801801+802802+ // Validate reason
803803+ if (!reason) {
804804+ return c.html(`<p class="form-error">Reason is required.</p>`);
805805+ }
806806+ if (reason.length > 3000) {
807807+ return c.html(`<p class="form-error">Reason must not exceed 3000 characters.</p>`);
808808+ }
809809+810810+ // Validate id
811811+ if (!id) {
812812+ return c.html(`<p class="form-error">Target ID is required.</p>`);
813813+ }
814814+815815+ // Build AppView request
816816+ let appviewUrl_: string;
817817+ let method: "POST" | "DELETE";
818818+ let appviewBody: Record<string, string>;
819819+820820+ switch (action) {
821821+ case "lock":
822822+ appviewUrl_ = `${appviewUrl}/api/mod/lock`;
823823+ method = "POST";
824824+ appviewBody = { topicId: id, reason };
825825+ break;
826826+ case "unlock":
827827+ appviewUrl_ = `${appviewUrl}/api/mod/lock/${encodeURIComponent(id)}`;
828828+ method = "DELETE";
829829+ appviewBody = { reason };
830830+ break;
831831+ case "hide":
832832+ appviewUrl_ = `${appviewUrl}/api/mod/hide`;
833833+ method = "POST";
834834+ appviewBody = { postId: id, reason };
835835+ break;
836836+ case "unhide":
837837+ appviewUrl_ = `${appviewUrl}/api/mod/hide/${encodeURIComponent(id)}`;
838838+ method = "DELETE";
839839+ appviewBody = { reason };
840840+ break;
841841+ case "ban":
842842+ appviewUrl_ = `${appviewUrl}/api/mod/ban`;
843843+ method = "POST";
844844+ appviewBody = { targetDid: id, reason };
845845+ break;
846846+ case "unban":
847847+ appviewUrl_ = `${appviewUrl}/api/mod/ban/${encodeURIComponent(id)}`;
848848+ method = "DELETE";
849849+ appviewBody = { reason };
850850+ break;
851851+ default:
852852+ return c.html(`<p class="form-error">Unknown action.</p>`);
853853+ }
854854+855855+ // Forward to AppView
856856+ let appviewRes: Response;
857857+ try {
858858+ appviewRes = await fetch(appviewUrl_, {
859859+ method,
860860+ headers: {
861861+ "Content-Type": "application/json",
862862+ Cookie: cookieHeader,
863863+ },
864864+ body: JSON.stringify(appviewBody),
865865+ });
866866+ } catch (error) {
867867+ if (isProgrammingError(error)) throw error;
868868+ console.error("Failed to proxy mod action to AppView", {
869869+ operation: `${method} ${appviewUrl_}`,
870870+ action,
871871+ error: error instanceof Error ? error.message : String(error),
872872+ });
873873+ return c.html(
874874+ `<p class="form-error">Forum temporarily unavailable. Please try again.</p>`
875875+ );
876876+ }
877877+878878+ if (appviewRes.ok) {
879879+ return new Response(null, {
880880+ status: 200,
881881+ headers: { "HX-Refresh": "true" },
882882+ });
883883+ }
884884+885885+ // Handle error responses
886886+ let errorMessage = "Something went wrong. Please try again.";
887887+888888+ if (appviewRes.status === 401) {
889889+ errorMessage = "You must be logged in to perform this action.";
890890+ } else if (appviewRes.status === 403) {
891891+ errorMessage = "You don't have permission for this action.";
892892+ } else if (appviewRes.status === 404) {
893893+ errorMessage = "Target not found.";
894894+ } else if (appviewRes.status === 409) {
895895+ errorMessage = "This action is already active.";
896896+ } else if (appviewRes.status >= 500) {
897897+ console.error("AppView returned server error for mod action", {
898898+ operation: `${method} ${appviewUrl_}`,
899899+ action,
900900+ status: appviewRes.status,
901901+ });
902902+ }
903903+904904+ return c.html(`<p class="form-error">${errorMessage}</p>`);
905905+ });
906906+}
907907+```
908908+909909+### Step 4: Register the route in `index.ts`
910910+911911+In `apps/web/src/routes/index.ts`, add the import and route:
912912+913913+```typescript
914914+import { createModActionRoute } from "./mod.js";
915915+916916+export const webRoutes = new Hono()
917917+ .route("/", createHomeRoutes(config.appviewUrl))
918918+ .route("/", createBoardsRoutes(config.appviewUrl))
919919+ .route("/", createTopicsRoutes(config.appviewUrl))
920920+ .route("/", createLoginRoutes(config.appviewUrl))
921921+ .route("/", createNewTopicRoutes(config.appviewUrl))
922922+ .route("/", createAuthRoutes(config.appviewUrl))
923923+ .route("/", createModActionRoute(config.appviewUrl)); // add this line
924924+```
925925+926926+### Step 5: Run tests to verify they pass
927927+928928+```bash
929929+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
930930+ pnpm --filter @atbb/web test src/routes/__tests__/mod.test.ts
931931+```
932932+Expected: all tests PASS
933933+934934+### Step 6: Commit
935935+936936+```bash
937937+git add apps/web/src/routes/mod.ts \
938938+ apps/web/src/routes/__tests__/mod.test.ts \
939939+ apps/web/src/routes/index.ts
940940+git commit -m "feat(web): add POST /mod/action proxy route (ATB-24)"
941941+```
942942+943943+---
944944+945945+## Task 4: Topic Page — Mod Buttons, Dialog, and Lock Button
946946+947947+Update `topics.tsx` to use `getSessionWithPermissions`, add lock/unlock button, add hide/unhide and ban/unban buttons on each post, and add the shared confirmation dialog.
948948+949949+**Key change:** existing tests that use an authenticated session with a cookie will now trigger a second fetch call for `GET /api/admin/members/me`. These tests must be updated to add a mock response for that call.
950950+951951+**Files:**
952952+- Modify: `apps/web/src/routes/topics.tsx`
953953+- Modify: `apps/web/src/routes/__tests__/topics.test.tsx`
954954+955955+### Step 1: Update existing broken tests first
956956+957957+In `apps/web/src/routes/__tests__/topics.test.tsx`:
958958+959959+**Add a helper function** for the members/me mock (no membership — most tests don't need mod buttons):
960960+961961+```typescript
962962+function membersMeNotFound() {
963963+ return { ok: false, status: 404 };
964964+}
965965+966966+function membersMeMod(
967967+ permissions = [
968968+ "space.atbb.permission.moderatePosts",
969969+ "space.atbb.permission.lockTopics",
970970+ "space.atbb.permission.banUsers",
971971+ ]
972972+) {
973973+ return {
974974+ ok: true,
975975+ json: () =>
976976+ Promise.resolve({
977977+ did: "did:plc:mod",
978978+ handle: "mod.bsky.social",
979979+ role: "Moderator",
980980+ roleUri: "at://...",
981981+ permissions,
982982+ }),
983983+ };
984984+}
985985+```
986986+987987+**Update all tests that pass a cookie header** to insert `membersMeNotFound()` between the auth session mock and the topic mock. Search for `cookie: "atbb_session=token"` in `topics.test.tsx` and find each test in "full page mode" (not POST /reply tests — those don't call getSession).
988988+989989+The affected tests and their fix:
990990+991991+**Test at ~line 404** ("shows locked message when topic is locked and user is authenticated"):
992992+```typescript
993993+// Before:
994994+mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, ... }) });
995995+setupSuccessfulFetch({ locked: true });
996996+// After — INSERT membersMeNotFound between auth and topic:
997997+mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, ... }) });
998998+mockFetch.mockResolvedValueOnce(membersMeNotFound());
999999+setupSuccessfulFetch({ locked: true });
10001000+```
10011001+10021002+**Test at ~line 434** ("shows reply form slot for authenticated users"):
10031003+```typescript
10041004+// Add membersMeNotFound after the authSession mock, before setupSuccessfulFetch
10051005+mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, ... }) });
10061006+mockFetch.mockResolvedValueOnce(membersMeNotFound());
10071007+setupSuccessfulFetch();
10081008+```
10091009+10101010+**Test at ~line 597** ("shows reply form with textarea when authenticated"):
10111011+```typescript
10121012+mockFetch.mockResolvedValueOnce(authSession);
10131013+mockFetch.mockResolvedValueOnce(membersMeNotFound()); // ← add this
10141014+mockFetch.mockResolvedValueOnce(makeTopicResponse());
10151015+```
10161016+10171017+**Test at ~line 630** ("uses hx-post for reply form submission"):
10181018+```typescript
10191019+mockFetch.mockResolvedValueOnce(authSession);
10201020+mockFetch.mockResolvedValueOnce(membersMeNotFound()); // ← add this
10211021+mockFetch.mockResolvedValueOnce(makeTopicResponse());
10221022+```
10231023+10241024+Search for any other tests in the file that pass `cookie: "atbb_session=token"` AND call GET endpoints (not POST /reply endpoints — those go through a different code path and don't call getSessionWithPermissions).
10251025+10261026+### Step 2: Add new mod button tests
10271027+10281028+In `topics.test.tsx`, add a new section:
10291029+10301030+```typescript
10311031+// ─── Mod actions (permission-based rendering) ────────────────────────────────
10321032+10331033+it("shows lock button for user with lockTopics permission", async () => {
10341034+ mockFetch.mockResolvedValueOnce(authSession);
10351035+ mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.lockTopics"]));
10361036+ setupSuccessfulFetch();
10371037+ const routes = await loadTopicsRoutes();
10381038+ const res = await routes.request("/topics/1", {
10391039+ headers: { cookie: "atbb_session=token" },
10401040+ });
10411041+ const html = await res.text();
10421042+ expect(html).toContain("Lock Topic");
10431043+ expect(html).toContain("openModDialog");
10441044+});
10451045+10461046+it("does not show lock button for user without lockTopics permission", async () => {
10471047+ mockFetch.mockResolvedValueOnce(authSession);
10481048+ mockFetch.mockResolvedValueOnce(membersMeNotFound());
10491049+ setupSuccessfulFetch();
10501050+ const routes = await loadTopicsRoutes();
10511051+ const res = await routes.request("/topics/1", {
10521052+ headers: { cookie: "atbb_session=token" },
10531053+ });
10541054+ const html = await res.text();
10551055+ expect(html).not.toContain("Lock Topic");
10561056+});
10571057+10581058+it("shows Unlock Topic button when topic is locked and user has lockTopics permission", async () => {
10591059+ mockFetch.mockResolvedValueOnce(authSession);
10601060+ mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.lockTopics"]));
10611061+ setupSuccessfulFetch({ locked: true });
10621062+ const routes = await loadTopicsRoutes();
10631063+ const res = await routes.request("/topics/1", {
10641064+ headers: { cookie: "atbb_session=token" },
10651065+ });
10661066+ const html = await res.text();
10671067+ expect(html).toContain("Unlock Topic");
10681068+});
10691069+10701070+it("shows hide button on each post for user with moderatePosts permission", async () => {
10711071+ mockFetch.mockResolvedValueOnce(authSession);
10721072+ mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.moderatePosts"]));
10731073+ const reply = makeReply({ id: "2", text: "A reply" });
10741074+ setupSuccessfulFetch({ replies: [reply] });
10751075+ const routes = await loadTopicsRoutes();
10761076+ const res = await routes.request("/topics/1", {
10771077+ headers: { cookie: "atbb_session=token" },
10781078+ });
10791079+ const html = await res.text();
10801080+ expect(html).toContain("Hide");
10811081+ expect(html).toContain("openModDialog");
10821082+});
10831083+10841084+it("shows ban button on posts for user with banUsers permission", async () => {
10851085+ mockFetch.mockResolvedValueOnce(authSession);
10861086+ mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.banUsers"]));
10871087+ setupSuccessfulFetch();
10881088+ const routes = await loadTopicsRoutes();
10891089+ const res = await routes.request("/topics/1", {
10901090+ headers: { cookie: "atbb_session=token" },
10911091+ });
10921092+ const html = await res.text();
10931093+ expect(html).toContain("Ban user");
10941094+});
10951095+10961096+it("shows mod dialog in page for users with any mod permission", async () => {
10971097+ mockFetch.mockResolvedValueOnce(authSession);
10981098+ mockFetch.mockResolvedValueOnce(membersMeMod());
10991099+ setupSuccessfulFetch();
11001100+ const routes = await loadTopicsRoutes();
11011101+ const res = await routes.request("/topics/1", {
11021102+ headers: { cookie: "atbb_session=token" },
11031103+ });
11041104+ const html = await res.text();
11051105+ expect(html).toContain("mod-dialog");
11061106+ expect(html).toContain("<dialog");
11071107+});
11081108+11091109+it("does not show any mod buttons for unauthenticated users", async () => {
11101110+ setupSuccessfulFetch();
11111111+ const routes = await loadTopicsRoutes();
11121112+ const res = await routes.request("/topics/1");
11131113+ const html = await res.text();
11141114+ expect(html).not.toContain("Lock Topic");
11151115+ expect(html).not.toContain("Hide");
11161116+ expect(html).not.toContain("Ban user");
11171117+ expect(html).not.toContain("mod-dialog");
11181118+});
11191119+```
11201120+11211121+### Step 3: Run tests to verify the new ones fail (existing ones may also fail)
11221122+11231123+```bash
11241124+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
11251125+ pnpm --filter @atbb/web test src/routes/__tests__/topics.test.tsx
11261126+```
11271127+Expected: new tests FAIL, some existing auth tests may now fail too.
11281128+11291129+### Step 4: Implement the topics.tsx changes
11301130+11311131+In `apps/web/src/routes/topics.tsx`, make the following changes:
11321132+11331133+**1. Update imports** — add the new session functions:
11341134+```typescript
11351135+import {
11361136+ getSessionWithPermissions,
11371137+ canLockTopics,
11381138+ canModeratePosts,
11391139+ canBanUsers,
11401140+} from "../lib/session.js";
11411141+```
11421142+Remove `getSession` from the import if it's no longer used.
11431143+11441144+**2. Update `PostCard` component** — add `modPerms` prop:
11451145+```typescript
11461146+function PostCard({
11471147+ post,
11481148+ postNumber,
11491149+ isOP = false,
11501150+ modPerms = { canHide: false, canBan: false },
11511151+}: {
11521152+ post: PostResponse;
11531153+ postNumber: number;
11541154+ isOP?: boolean;
11551155+ modPerms?: { canHide: boolean; canBan: boolean };
11561156+}) {
11571157+ const handle = post.author?.handle ?? post.author?.did ?? post.did;
11581158+ const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown";
11591159+ const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply";
11601160+ return (
11611161+ <div class={cardClass} id={`post-${postNumber}`}>
11621162+ <div class="post-card__header">
11631163+ <span class="post-card__number">#{postNumber}</span>
11641164+ <span class="post-card__author">{handle}</span>
11651165+ <span class="post-card__date">{date}</span>
11661166+ </div>
11671167+ <div class="post-card__body" style="white-space: pre-wrap">
11681168+ {post.text}
11691169+ </div>
11701170+ {(modPerms.canHide || modPerms.canBan) && (
11711171+ <div class="post-card__mod-actions">
11721172+ {modPerms.canHide && (
11731173+ <button
11741174+ class="mod-btn mod-btn--hide"
11751175+ type="button"
11761176+ onclick={`openModDialog('hide','${post.id}')`}
11771177+ >
11781178+ Hide
11791179+ </button>
11801180+ )}
11811181+ {modPerms.canBan && post.author?.did && (
11821182+ <button
11831183+ class="mod-btn mod-btn--ban"
11841184+ type="button"
11851185+ onclick={`openModDialog('ban','${post.author.did}')`}
11861186+ >
11871187+ Ban user
11881188+ </button>
11891189+ )}
11901190+ </div>
11911191+ )}
11921192+ </div>
11931193+ );
11941194+}
11951195+```
11961196+11971197+**3. Add the `MOD_DIALOG_SCRIPT` constant** (after `REPLY_CHAR_COUNTER_SCRIPT`):
11981198+```typescript
11991199+const MOD_DIALOG_SCRIPT = `
12001200+ var MOD_TITLES = {
12011201+ lock: 'Lock Topic', unlock: 'Unlock Topic',
12021202+ hide: 'Hide Post', unhide: 'Unhide Post',
12031203+ ban: 'Ban User', unban: 'Unban User'
12041204+ };
12051205+ function openModDialog(action, id) {
12061206+ document.getElementById('mod-dialog-action').value = action;
12071207+ document.getElementById('mod-dialog-id').value = id;
12081208+ document.getElementById('mod-dialog-title').textContent = MOD_TITLES[action] || 'Confirm';
12091209+ document.getElementById('mod-dialog-error').innerHTML = '';
12101210+ document.getElementById('mod-reason').value = '';
12111211+ document.getElementById('mod-dialog').showModal();
12121212+ }
12131213+`;
12141214+```
12151215+12161216+**4. Add `ModDialog` component**:
12171217+```typescript
12181218+function ModDialog() {
12191219+ return (
12201220+ <dialog id="mod-dialog" class="mod-dialog">
12211221+ <h2 id="mod-dialog-title" class="mod-dialog__title">Confirm Action</h2>
12221222+ <form
12231223+ hx-post="/mod/action"
12241224+ hx-target="#mod-dialog-error"
12251225+ hx-swap="innerHTML"
12261226+ hx-disabled-elt="[type=submit]"
12271227+ >
12281228+ <input type="hidden" id="mod-dialog-action" name="action" value="" />
12291229+ <input type="hidden" id="mod-dialog-id" name="id" value="" />
12301230+ <div class="form-group">
12311231+ <label for="mod-reason">Reason</label>
12321232+ <textarea
12331233+ id="mod-reason"
12341234+ name="reason"
12351235+ rows={3}
12361236+ placeholder="Reason for this action…"
12371237+ />
12381238+ </div>
12391239+ <div id="mod-dialog-error" />
12401240+ <div class="form-actions">
12411241+ <button type="submit" class="btn btn-danger">
12421242+ Confirm
12431243+ </button>
12441244+ <button
12451245+ type="button"
12461246+ class="btn btn-secondary"
12471247+ onclick="document.getElementById('mod-dialog').close()"
12481248+ >
12491249+ Cancel
12501250+ </button>
12511251+ </div>
12521252+ </form>
12531253+ </dialog>
12541254+ );
12551255+}
12561256+```
12571257+12581258+**5. Update the full page handler** — change `getSession` to `getSessionWithPermissions`:
12591259+```typescript
12601260+const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
12611261+```
12621262+12631263+**6. Compute mod permission flags** (after fetching `auth`):
12641264+```typescript
12651265+const modPerms = {
12661266+ canHide: canModeratePosts(auth),
12671267+ canBan: canBanUsers(auth),
12681268+ canLock: canLockTopics(auth),
12691269+};
12701270+const hasAnyModPerm = modPerms.canHide || modPerms.canBan || modPerms.canLock;
12711271+```
12721272+12731273+**7. Add lock button** below the `PageHeader` in the JSX return:
12741274+```tsx
12751275+{modPerms.canLock && (
12761276+ <div class="topic-mod-controls">
12771277+ <button
12781278+ class={`mod-btn ${topicData.locked ? "mod-btn--unlock" : "mod-btn--lock"}`}
12791279+ type="button"
12801280+ onclick={`openModDialog('${topicData.locked ? "unlock" : "lock"}','${topicId}')`}
12811281+ >
12821282+ {topicData.locked ? "Unlock Topic" : "Lock Topic"}
12831283+ </button>
12841284+ </div>
12851285+)}
12861286+```
12871287+12881288+**8. Update `PostCard` calls** to pass `modPerms`:
12891289+```tsx
12901290+<PostCard post={topicData.post} postNumber={1} isOP={true} modPerms={modPerms} />
12911291+```
12921292+and in `ReplyFragment`:
12931293+```tsx
12941294+{replies.map((reply, i) => (
12951295+ <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} modPerms={modPerms} />
12961296+))}
12971297+```
12981298+`ReplyFragment` needs a new `modPerms` prop too:
12991299+```typescript
13001300+function ReplyFragment({
13011301+ topicId, replies, total, offset, modPerms,
13021302+}: {
13031303+ topicId: string;
13041304+ replies: PostResponse[];
13051305+ total: number;
13061306+ offset: number;
13071307+ modPerms: { canHide: boolean; canBan: boolean };
13081308+}) { ... }
13091309+```
13101310+13111311+**9. Add `ModDialog` and script** at the bottom of the page's JSX (before `</BaseLayout>`):
13121312+```tsx
13131313+{hasAnyModPerm && (
13141314+ <>
13151315+ <ModDialog />
13161316+ <script dangerouslySetInnerHTML={{ __html: MOD_DIALOG_SCRIPT }} />
13171317+ </>
13181318+)}
13191319+```
13201320+13211321+**10. Update `BaseLayout` call** — `auth` type is now `WebSessionWithPermissions | undefined`. `BaseLayout` accepts `WebSession | undefined`. Since `WebSessionWithPermissions` is structurally compatible (has `authenticated`, `did`, `handle`), this should work. If TypeScript complains, cast: `auth as WebSession`.
13221322+13231323+### Step 5: Run tests to verify they pass
13241324+13251325+```bash
13261326+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
13271327+ pnpm --filter @atbb/web test src/routes/__tests__/topics.test.tsx
13281328+```
13291329+Expected: all tests PASS (including both updated existing tests and new mod button tests)
13301330+13311331+### Step 6: Run the full web test suite to catch any remaining breakage
13321332+13331333+```bash
13341334+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \
13351335+ pnpm --filter @atbb/web test
13361336+```
13371337+Expected: all tests PASS
13381338+13391339+### Step 7: Commit
13401340+13411341+```bash
13421342+git add apps/web/src/routes/topics.tsx apps/web/src/routes/__tests__/topics.test.tsx
13431343+git commit -m "feat(web): add mod action buttons and dialog to topic view (ATB-24)"
13441344+```
13451345+13461346+---
13471347+13481348+## Task 5: CSS for Mod UI Elements
13491349+13501350+Add styles for the mod dialog, mod buttons, and the mod actions row on post cards.
13511351+13521352+**Files:**
13531353+- Modify: `apps/web/public/static/css/theme.css`
13541354+13551355+### Step 1: Add CSS at the end of `theme.css`
13561356+13571357+```css
13581358+/* ─── Moderation UI ──────────────────────────────────────────────────────── */
13591359+13601360+.post-card__mod-actions {
13611361+ display: flex;
13621362+ gap: var(--space-2);
13631363+ margin-top: var(--space-2);
13641364+ padding-top: var(--space-2);
13651365+ border-top: 1px solid var(--color-border);
13661366+}
13671367+13681368+.mod-btn {
13691369+ font-size: 0.75rem;
13701370+ padding: 0.25rem 0.6rem;
13711371+ border: 2px solid currentColor;
13721372+ border-radius: 0;
13731373+ cursor: pointer;
13741374+ background: transparent;
13751375+ font-family: inherit;
13761376+ font-weight: 700;
13771377+ text-transform: uppercase;
13781378+ letter-spacing: 0.05em;
13791379+}
13801380+13811381+.mod-btn--hide,
13821382+.mod-btn--lock {
13831383+ color: var(--color-danger, #d00);
13841384+}
13851385+13861386+.mod-btn--hide:hover,
13871387+.mod-btn--lock:hover {
13881388+ background: var(--color-danger, #d00);
13891389+ color: #fff;
13901390+}
13911391+13921392+.mod-btn--unhide,
13931393+.mod-btn--unlock,
13941394+.mod-btn--ban {
13951395+ color: var(--color-text-muted, #666);
13961396+}
13971397+13981398+.mod-btn--unhide:hover,
13991399+.mod-btn--unlock:hover,
14001400+.mod-btn--ban:hover {
14011401+ background: var(--color-text-muted, #666);
14021402+ color: #fff;
14031403+}
14041404+14051405+.topic-mod-controls {
14061406+ margin-bottom: var(--space-4);
14071407+}
14081408+14091409+.mod-dialog {
14101410+ border: 3px solid var(--color-border);
14111411+ border-radius: 0;
14121412+ padding: var(--space-6);
14131413+ max-width: 480px;
14141414+ width: 90vw;
14151415+ box-shadow: 6px 6px 0 var(--color-shadow);
14161416+ background: var(--color-bg);
14171417+}
14181418+14191419+.mod-dialog::backdrop {
14201420+ background: rgba(0, 0, 0, 0.5);
14211421+}
14221422+14231423+.mod-dialog__title {
14241424+ margin-top: 0;
14251425+ margin-bottom: var(--space-4);
14261426+ font-size: 1.25rem;
14271427+}
14281428+```
14291429+14301430+### Step 2: Visual verification
14311431+14321432+Run `pnpm dev` and navigate to a topic page while logged in as a mod. Verify:
14331433+- Lock button appears below the page title
14341434+- Post cards show "Hide" and "Ban user" buttons
14351435+- Clicking a button opens the dialog
14361436+- Dialog closes on Cancel
14371437+- CSS looks consistent with the neobrutal theme
14381438+14391439+### Step 3: Commit
14401440+14411441+```bash
14421442+git add apps/web/public/static/css/theme.css
14431443+git commit -m "style(web): add CSS for mod dialog and mod action buttons (ATB-24)"
14441444+```
14451445+14461446+---
14471447+14481448+## Task 6: Full Test Suite + Final Verification
14491449+14501450+Run the complete test suite and verify the build succeeds.
14511451+14521452+### Step 1: Run all tests
14531453+14541454+```bash
14551455+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm test
14561456+```
14571457+Expected: all tests PASS across all packages
14581458+14591459+### Step 2: Run full build
14601460+14611461+```bash
14621462+PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm build
14631463+```
14641464+Expected: BUILD successful
14651465+14661466+### Step 3: Update Linear issue
14671467+14681468+Mark ATB-24 as Done in Linear. Add a comment:
14691469+14701470+> Implemented mod action buttons (lock/unlock, hide/unhide, ban/unban) on the topic view. New `GET /api/admin/members/me` AppView endpoint surfaces the user's own permissions. Web server `POST /mod/action` proxies all actions with reason confirmation via `<dialog>`. Mod buttons only render for users with matching permissions.
14711471+14721472+### Step 4: Update plan document
14731473+14741474+Mark the following in `docs/atproto-forum-plan.md`:
14751475+```
14761476+[x] Admin UI: ban user, lock topic, hide post (Phase 3 carryover)
14771477+[x] Mod action indicators (locked topic banner — already existed; mod buttons via permissions)
14781478+```
14791479+14801480+### Step 5: Final commit if any docs updated
14811481+14821482+```bash
14831483+git add docs/atproto-forum-plan.md
14841484+git commit -m "docs: mark ATB-24 admin moderation UI complete"
14851485+```
14861486+14871487+---
14881488+14891489+## Notes on the `makeReply` helper
14901490+14911491+The `topics.test.tsx` file uses a `makeReply` function to create reply objects. If it doesn't exist yet, add it alongside `makeTopicResponse`:
14921492+14931493+```typescript
14941494+function makeReply(overrides: Record<string, unknown> = {}) {
14951495+ return {
14961496+ id: "2",
14971497+ did: "did:plc:replier",
14981498+ rkey: "replyrkey1",
14991499+ text: "A reply",
15001500+ forumUri: null,
15011501+ boardUri: null,
15021502+ boardId: "42",
15031503+ parentPostId: "1",
15041504+ createdAt: "2025-01-02T00:00:00.000Z",
15051505+ author: { did: "did:plc:replier", handle: "replier.bsky.social" },
15061506+ ...overrides,
15071507+ };
15081508+}
15091509+```
15101510+15111511+## Devenv PATH reminder
15121512+15131513+Replace `/path/to/.devenv/profile/bin` with the actual path from memory:
15141514+```
15151515+/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin
15161516+```
15171517+15181518+Full example:
15191519+```bash
15201520+PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
15211521+ pnpm --filter @atbb/web test
15221522+```