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

feat(web): admin moderation UI — lock, hide, ban actions in topic view (ATB-24) (#48)

authored by

Malpercio and committed by
GitHub
5ae38d77 817e470b

+3126 -94
+214
apps/appview/src/routes/__tests__/admin.test.ts
··· 14 14 15 15 vi.mock("../../middleware/auth.js", () => ({ 16 16 requireAuth: vi.fn(() => async (c: any, next: any) => { 17 + if (!mockUser) { 18 + return c.json({ error: "Unauthorized" }, 401); 19 + } 17 20 c.set("user", mockUser); 18 21 await next(); 19 22 }), ··· 574 577 }); 575 578 }); 576 579 }); 580 + describe.sequential("GET /api/admin/members/me", () => { 581 + beforeEach(async () => { 582 + // Clean database to ensure no data pollution from other tests 583 + await ctx.cleanDatabase(); 584 + 585 + // Re-insert forum (deleted by cleanDatabase) 586 + await ctx.db.insert(forums).values({ 587 + did: ctx.config.forumDid, 588 + rkey: "self", 589 + cid: "bafytest", 590 + name: "Test Forum", 591 + description: "A test forum", 592 + indexedAt: new Date(), 593 + }); 594 + 595 + // Set mock user 596 + mockUser = { did: "did:plc:test-me" }; 597 + }); 598 + 599 + it("returns 401 when not authenticated", async () => { 600 + mockUser = null; // signals the requireAuth mock to return 401 601 + const res = await app.request("/api/admin/members/me"); 602 + expect(res.status).toBe(401); 603 + }); 604 + 605 + it("returns 404 when authenticated user has no membership record", async () => { 606 + // mockUser is set to did:plc:test-me but no membership record exists 607 + const res = await app.request("/api/admin/members/me"); 608 + 609 + expect(res.status).toBe(404); 610 + const data = await res.json(); 611 + expect(data.error).toBe("Membership not found"); 612 + }); 613 + 614 + it("returns 200 with membership, role, and permissions for a user with a linked role", async () => { 615 + // Insert role 616 + await ctx.db.insert(roles).values({ 617 + did: ctx.config.forumDid, 618 + rkey: "moderator", 619 + cid: "bafymoderator", 620 + name: "Moderator", 621 + description: "Moderator role", 622 + permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.editPosts"], 623 + priority: 20, 624 + createdAt: new Date(), 625 + indexedAt: new Date(), 626 + }); 627 + 628 + // Insert user 629 + await ctx.db.insert(users).values({ 630 + did: "did:plc:test-me", 631 + handle: "me.test", 632 + indexedAt: new Date(), 633 + }).onConflictDoNothing(); 634 + 635 + // Insert membership linked to role 636 + await ctx.db.insert(memberships).values({ 637 + did: "did:plc:test-me", 638 + rkey: "self", 639 + cid: "bafymembership", 640 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 641 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 642 + joinedAt: new Date("2026-01-15T00:00:00.000Z"), 643 + createdAt: new Date(), 644 + indexedAt: new Date(), 645 + }).onConflictDoNothing(); 646 + 647 + const res = await app.request("/api/admin/members/me"); 648 + 649 + expect(res.status).toBe(200); 650 + const data = await res.json(); 651 + expect(data).toMatchObject({ 652 + did: "did:plc:test-me", 653 + handle: "me.test", 654 + role: "Moderator", 655 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 656 + permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.editPosts"], 657 + }); 658 + }); 659 + 660 + it("returns 200 with empty permissions array when membership exists but role has no permissions", async () => { 661 + // Insert role with empty permissions 662 + await ctx.db.insert(roles).values({ 663 + did: ctx.config.forumDid, 664 + rkey: "guest-role", 665 + cid: "bafyguestrole", 666 + name: "Guest Role", 667 + description: "Role with no permissions", 668 + permissions: [], 669 + priority: 100, 670 + createdAt: new Date(), 671 + indexedAt: new Date(), 672 + }); 673 + 674 + // Insert user 675 + await ctx.db.insert(users).values({ 676 + did: "did:plc:test-me", 677 + handle: "me.test", 678 + indexedAt: new Date(), 679 + }).onConflictDoNothing(); 680 + 681 + // Insert membership linked to role 682 + await ctx.db.insert(memberships).values({ 683 + did: "did:plc:test-me", 684 + rkey: "self", 685 + cid: "bafymembership", 686 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 687 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/guest-role`, 688 + joinedAt: new Date(), 689 + createdAt: new Date(), 690 + indexedAt: new Date(), 691 + }).onConflictDoNothing(); 692 + 693 + const res = await app.request("/api/admin/members/me"); 694 + 695 + expect(res.status).toBe(200); 696 + const data = await res.json(); 697 + expect(data.permissions).toEqual([]); 698 + expect(data.role).toBe("Guest Role"); 699 + }); 700 + 701 + it("only returns the current user's membership, not other users'", async () => { 702 + // Insert role 703 + await ctx.db.insert(roles).values({ 704 + did: ctx.config.forumDid, 705 + rkey: "admin", 706 + cid: "bafyadmin", 707 + name: "Admin", 708 + description: "Admin role", 709 + permissions: ["*"], 710 + priority: 10, 711 + createdAt: new Date(), 712 + indexedAt: new Date(), 713 + }); 714 + 715 + // Insert current user with membership 716 + await ctx.db.insert(users).values({ 717 + did: "did:plc:test-me", 718 + handle: "me.test", 719 + indexedAt: new Date(), 720 + }).onConflictDoNothing(); 721 + 722 + await ctx.db.insert(memberships).values({ 723 + did: "did:plc:test-me", 724 + rkey: "self", 725 + cid: "bafymymembership", 726 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 727 + roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`, 728 + joinedAt: new Date(), 729 + createdAt: new Date(), 730 + indexedAt: new Date(), 731 + }).onConflictDoNothing(); 732 + 733 + // Insert another user with a different role 734 + await ctx.db.insert(users).values({ 735 + did: "did:plc:test-other", 736 + handle: "other.test", 737 + indexedAt: new Date(), 738 + }).onConflictDoNothing(); 739 + 740 + await ctx.db.insert(memberships).values({ 741 + did: "did:plc:test-other", 742 + rkey: "self", 743 + cid: "bafyothermembership", 744 + forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 745 + roleUri: null, 746 + joinedAt: new Date(), 747 + createdAt: new Date(), 748 + indexedAt: new Date(), 749 + }).onConflictDoNothing(); 750 + 751 + const res = await app.request("/api/admin/members/me"); 752 + 753 + expect(res.status).toBe(200); 754 + const data = await res.json(); 755 + // Should return only our user's data 756 + expect(data.did).toBe("did:plc:test-me"); 757 + expect(data.handle).toBe("me.test"); 758 + expect(data.role).toBe("Admin"); 759 + }); 760 + 761 + it("returns 'Guest' as role when membership has no roleUri", async () => { 762 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 763 + await ctx.db.insert(users).values({ 764 + did: "did:plc:test-guest", 765 + handle: "guest.bsky.social", 766 + indexedAt: new Date(), 767 + }); 768 + await ctx.db.insert(memberships).values({ 769 + did: "did:plc:test-guest", 770 + rkey: "guestrkey", 771 + cid: "bafymembership-guest", 772 + forumUri, 773 + roleUri: null, 774 + joinedAt: new Date(), 775 + createdAt: new Date(), 776 + indexedAt: new Date(), 777 + }); 778 + 779 + mockUser = { did: "did:plc:test-guest" }; 780 + const res = await app.request("/api/admin/members/me"); 781 + expect(res.status).toBe(200); 782 + const data = await res.json(); 783 + expect(data.did).toBe("did:plc:test-guest"); 784 + expect(data.role).toBe("Guest"); 785 + expect(data.roleUri).toBeNull(); 786 + expect(data.permissions).toEqual([]); 787 + }); 788 + }); 789 + 577 790 }); 791 +
+159 -87
apps/appview/src/routes/admin.ts
··· 5 5 import { requirePermission, getUserRole } from "../middleware/permissions.js"; 6 6 import { memberships, roles, users, forums } from "@atbb/db"; 7 7 import { eq, and, sql, asc } from "drizzle-orm"; 8 - import { isNetworkError } from "../lib/errors.js"; 8 + import { isNetworkError, isProgrammingError } from "../lib/errors.js"; 9 9 10 10 export function createAdminRoutes(ctx: AppContext) { 11 11 const app = new Hono<{ Variables: Variables }>(); ··· 48 48 return c.json({ error: "Invalid roleUri format" }, 400); 49 49 } 50 50 51 - // Validate role exists 52 - const [role] = await ctx.db 53 - .select() 54 - .from(roles) 55 - .where( 56 - and( 57 - eq(roles.did, ctx.config.forumDid), 58 - eq(roles.rkey, roleRkey) 51 + try { 52 + // Validate role exists 53 + const [role] = await ctx.db 54 + .select() 55 + .from(roles) 56 + .where( 57 + and( 58 + eq(roles.did, ctx.config.forumDid), 59 + eq(roles.rkey, roleRkey) 60 + ) 59 61 ) 60 - ) 61 - .limit(1); 62 + .limit(1); 62 63 63 - if (!role) { 64 - return c.json({ error: "Role not found" }, 404); 65 - } 64 + if (!role) { 65 + return c.json({ error: "Role not found" }, 404); 66 + } 66 67 67 - // Priority check: Can't assign role with equal or higher authority 68 - const assignerRole = await getUserRole(ctx, user.did); 69 - if (!assignerRole) { 70 - return c.json({ error: "You do not have a role assigned" }, 403); 71 - } 68 + // Priority check: Can't assign role with equal or higher authority 69 + const assignerRole = await getUserRole(ctx, user.did); 70 + if (!assignerRole) { 71 + return c.json({ error: "You do not have a role assigned" }, 403); 72 + } 72 73 73 - if (role.priority <= assignerRole.priority) { 74 - return c.json({ 75 - error: "Cannot assign role with equal or higher authority", 76 - yourPriority: assignerRole.priority, 77 - targetRolePriority: role.priority 78 - }, 403); 79 - } 74 + if (role.priority <= assignerRole.priority) { 75 + return c.json({ 76 + error: "Cannot assign role with equal or higher authority", 77 + yourPriority: assignerRole.priority, 78 + targetRolePriority: role.priority 79 + }, 403); 80 + } 80 81 81 - // Get target user's membership 82 - const [membership] = await ctx.db 83 - .select() 84 - .from(memberships) 85 - .where(eq(memberships.did, targetDid)) 86 - .limit(1); 82 + // Get target user's membership 83 + const [membership] = await ctx.db 84 + .select() 85 + .from(memberships) 86 + .where(eq(memberships.did, targetDid)) 87 + .limit(1); 87 88 88 - if (!membership) { 89 - return c.json({ error: "User is not a member of this forum" }, 404); 90 - } 89 + if (!membership) { 90 + return c.json({ error: "User is not a member of this forum" }, 404); 91 + } 91 92 92 - // Fetch forum CID for membership record 93 - const [forum] = await ctx.db 94 - .select({ cid: forums.cid }) 95 - .from(forums) 96 - .where(eq(forums.did, ctx.config.forumDid)) 97 - .limit(1); 93 + // Fetch forum CID for membership record 94 + const [forum] = await ctx.db 95 + .select({ cid: forums.cid }) 96 + .from(forums) 97 + .where(eq(forums.did, ctx.config.forumDid)) 98 + .limit(1); 98 99 99 - if (!forum) { 100 - return c.json({ error: "Forum record not found in database" }, 500); 101 - } 100 + if (!forum) { 101 + return c.json({ error: "Forum record not found in database" }, 500); 102 + } 102 103 103 - // Get ForumAgent for PDS write operations 104 - if (!ctx.forumAgent) { 105 - return c.json({ 106 - error: "Forum agent not available. Server configuration issue.", 107 - }, 500); 108 - } 104 + // Get ForumAgent for PDS write operations 105 + if (!ctx.forumAgent) { 106 + return c.json({ 107 + error: "Forum agent not available. Server configuration issue.", 108 + }, 500); 109 + } 109 110 110 - const agent = ctx.forumAgent.getAgent(); 111 - if (!agent) { 112 - return c.json({ 113 - error: "Forum agent not authenticated. Please try again later.", 114 - }, 503); 115 - } 111 + const agent = ctx.forumAgent.getAgent(); 112 + if (!agent) { 113 + return c.json({ 114 + error: "Forum agent not authenticated. Please try again later.", 115 + }, 503); 116 + } 116 117 117 - try { 118 - // Update membership record on user's PDS using ForumAgent 119 - await agent.com.atproto.repo.putRecord({ 120 - repo: targetDid, 121 - collection: "space.atbb.membership", 122 - rkey: membership.rkey, 123 - record: { 124 - $type: "space.atbb.membership", 125 - forum: { forum: { uri: membership.forumUri, cid: forum.cid } }, 126 - role: { role: { uri: roleUri, cid: role.cid } }, 127 - joinedAt: membership.joinedAt?.toISOString(), 128 - createdAt: membership.createdAt.toISOString(), 129 - }, 130 - }); 118 + try { 119 + // Update membership record on user's PDS using ForumAgent 120 + await agent.com.atproto.repo.putRecord({ 121 + repo: targetDid, 122 + collection: "space.atbb.membership", 123 + rkey: membership.rkey, 124 + record: { 125 + $type: "space.atbb.membership", 126 + forum: { forum: { uri: membership.forumUri, cid: forum.cid } }, 127 + role: { role: { uri: roleUri, cid: role.cid } }, 128 + joinedAt: membership.joinedAt?.toISOString(), 129 + createdAt: membership.createdAt.toISOString(), 130 + }, 131 + }); 131 132 132 - return c.json({ 133 - success: true, 134 - roleAssigned: role.name, 135 - targetDid, 136 - }); 133 + return c.json({ 134 + success: true, 135 + roleAssigned: role.name, 136 + targetDid, 137 + }); 138 + } catch (error) { 139 + if (isProgrammingError(error)) throw error; 140 + console.error("Failed to assign role", { 141 + operation: "POST /api/admin/members/:did/role", 142 + targetDid, 143 + roleUri, 144 + error: error instanceof Error ? error.message : String(error), 145 + }); 146 + 147 + // Classify error: network errors (503) vs server errors (500) 148 + if (error instanceof Error && isNetworkError(error)) { 149 + return c.json({ 150 + error: "Unable to reach user's PDS to update role. Please try again later.", 151 + }, 503); 152 + } 153 + 154 + return c.json({ 155 + error: "Failed to assign role due to server error. Please contact support.", 156 + }, 500); 157 + } 137 158 } catch (error) { 138 - console.error("Failed to assign role", { 159 + if (isProgrammingError(error)) throw error; 160 + console.error("Database error during role assignment", { 139 161 operation: "POST /api/admin/members/:did/role", 140 162 targetDid, 141 163 roleUri, 142 164 error: error instanceof Error ? error.message : String(error), 143 165 }); 144 - 145 - // Classify error: network errors (503) vs server errors (500) 146 - if (error instanceof Error && isNetworkError(error)) { 147 - return c.json({ 148 - error: "Unable to reach user's PDS to update role. Please try again later.", 149 - }, 503); 150 - } 151 - 152 - return c.json({ 153 - error: "Failed to assign role due to server error. Please contact support.", 154 - }, 500); 166 + return c.json({ error: "Server error. Please try again later." }, 500); 155 167 } 156 168 } 157 169 ); ··· 251 263 } 252 264 } 253 265 ); 266 + 267 + 268 + /** 269 + * GET /api/admin/members/me 270 + * 271 + * Returns the calling user's own membership, role name, and permissions. 272 + * Any authenticated user may call this — no special permission required. 273 + * Returns 404 if the user has no membership record for this forum. 274 + */ 275 + app.get("/members/me", requireAuth(ctx), async (c) => { 276 + const user = c.get("user")!; 277 + 278 + try { 279 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 280 + const [member] = await ctx.db 281 + .select({ 282 + did: memberships.did, 283 + handle: users.handle, 284 + roleUri: memberships.roleUri, 285 + roleName: roles.name, 286 + permissions: roles.permissions, 287 + }) 288 + .from(memberships) 289 + .leftJoin(users, eq(memberships.did, users.did)) 290 + .leftJoin( 291 + roles, 292 + sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 293 + ) 294 + .where( 295 + and( 296 + eq(memberships.did, user.did), 297 + eq(memberships.forumUri, forumUri) 298 + ) 299 + ) 300 + .limit(1); 301 + 302 + if (!member) { 303 + return c.json({ error: "Membership not found" }, 404); 304 + } 305 + 306 + return c.json({ 307 + did: member.did, 308 + handle: member.handle || user.did, 309 + role: member.roleName || "Guest", 310 + roleUri: member.roleUri, 311 + permissions: member.permissions || [], 312 + }); 313 + } catch (error) { 314 + if (isProgrammingError(error)) throw error; 315 + console.error("Failed to get current user membership", { 316 + operation: "GET /api/admin/members/me", 317 + did: user.did, 318 + error: error instanceof Error ? error.message : String(error), 319 + }); 320 + return c.json( 321 + { error: "Failed to retrieve your membership. Please try again later." }, 322 + 500 323 + ); 324 + } 325 + }); 254 326 255 327 return app; 256 328 }
+71
apps/web/public/static/css/theme.css
··· 258 258 color: var(--color-text-muted); 259 259 font-size: var(--font-size-sm); 260 260 } 261 + 262 + /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 263 + 264 + .post-card__mod-actions { 265 + display: flex; 266 + gap: var(--space-2); 267 + margin-top: var(--space-2); 268 + padding-top: var(--space-2); 269 + border-top: 1px solid var(--color-border); 270 + } 271 + 272 + .mod-btn { 273 + font-size: 0.75rem; 274 + padding: 0.25rem 0.6rem; 275 + border: 2px solid currentColor; 276 + border-radius: 0; 277 + cursor: pointer; 278 + background: transparent; 279 + font-family: inherit; 280 + font-weight: 700; 281 + text-transform: uppercase; 282 + letter-spacing: 0.05em; 283 + } 284 + 285 + .mod-btn--hide, 286 + .mod-btn--lock { 287 + color: var(--color-danger, #d00); 288 + } 289 + 290 + .mod-btn--hide:hover, 291 + .mod-btn--lock:hover { 292 + background: var(--color-danger, #d00); 293 + color: #fff; 294 + } 295 + 296 + .mod-btn--unhide, 297 + .mod-btn--unlock, 298 + .mod-btn--ban { 299 + color: var(--color-text-muted, #666); 300 + } 301 + 302 + .mod-btn--unhide:hover, 303 + .mod-btn--unlock:hover, 304 + .mod-btn--ban:hover { 305 + background: var(--color-text-muted, #666); 306 + color: #fff; 307 + } 308 + 309 + .topic-mod-controls { 310 + margin-bottom: var(--space-4); 311 + } 312 + 313 + .mod-dialog { 314 + border: 3px solid var(--color-border); 315 + border-radius: 0; 316 + padding: var(--space-6); 317 + max-width: 480px; 318 + width: 90vw; 319 + box-shadow: 6px 6px 0 var(--color-shadow); 320 + background: var(--color-bg); 321 + } 322 + 323 + .mod-dialog::backdrop { 324 + background: rgba(0, 0, 0, 0.5); 325 + } 326 + 327 + .mod-dialog__title { 328 + margin-top: 0; 329 + margin-bottom: var(--space-4); 330 + font-size: 1.25rem; 331 + }
+151 -1
apps/web/src/lib/__tests__/session.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 - import { getSession } from "../session.js"; 2 + import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers } from "../session.js"; 3 3 4 4 const mockFetch = vi.fn(); 5 5 ··· 181 181 expect(result).toEqual({ authenticated: false }); 182 182 }); 183 183 }); 184 + 185 + describe("getSessionWithPermissions", () => { 186 + beforeEach(() => { 187 + vi.stubGlobal("fetch", mockFetch); 188 + }); 189 + 190 + afterEach(() => { 191 + vi.unstubAllGlobals(); 192 + mockFetch.mockReset(); 193 + }); 194 + 195 + it("returns unauthenticated with empty permissions when no cookie", async () => { 196 + const result = await getSessionWithPermissions("http://localhost:3000"); 197 + expect(result).toMatchObject({ authenticated: false }); 198 + expect(result.permissions.size).toBe(0); 199 + }); 200 + 201 + it("returns authenticated with empty permissions when members/me returns 404", async () => { 202 + mockFetch.mockResolvedValueOnce({ 203 + ok: true, 204 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 205 + }); 206 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 207 + 208 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 209 + expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" }); 210 + expect(result.permissions.size).toBe(0); 211 + }); 212 + 213 + it("returns permissions as Set when members/me succeeds", async () => { 214 + mockFetch.mockResolvedValueOnce({ 215 + ok: true, 216 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }), 217 + }); 218 + mockFetch.mockResolvedValueOnce({ 219 + ok: true, 220 + json: () => Promise.resolve({ 221 + did: "did:plc:mod", 222 + handle: "mod.bsky.social", 223 + role: "Moderator", 224 + roleUri: "at://...", 225 + permissions: [ 226 + "space.atbb.permission.moderatePosts", 227 + "space.atbb.permission.lockTopics", 228 + "space.atbb.permission.banUsers", 229 + ], 230 + }), 231 + }); 232 + 233 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 234 + expect(result.authenticated).toBe(true); 235 + expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true); 236 + expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true); 237 + expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true); 238 + expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false); 239 + }); 240 + 241 + it("returns empty permissions without crashing when members/me call throws", async () => { 242 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 243 + mockFetch.mockResolvedValueOnce({ 244 + ok: true, 245 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 246 + }); 247 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 248 + 249 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 250 + expect(result.authenticated).toBe(true); 251 + expect(result.permissions.size).toBe(0); 252 + expect(consoleSpy).toHaveBeenCalledWith( 253 + expect.stringContaining("failed to fetch permissions"), 254 + expect.any(Object) 255 + ); 256 + consoleSpy.mockRestore(); 257 + }); 258 + 259 + it("does not log error when members/me returns 404 (expected for guests)", async () => { 260 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 261 + mockFetch.mockResolvedValueOnce({ 262 + ok: true, 263 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 264 + }); 265 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 266 + 267 + await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 268 + expect(consoleSpy).not.toHaveBeenCalled(); 269 + consoleSpy.mockRestore(); 270 + }); 271 + 272 + it("forwards cookie header to members/me call", async () => { 273 + mockFetch.mockResolvedValueOnce({ 274 + ok: true, 275 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 276 + }); 277 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 278 + 279 + await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken"); 280 + 281 + expect(mockFetch).toHaveBeenCalledTimes(2); 282 + const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit]; 283 + expect(url).toBe("http://localhost:3000/api/admin/members/me"); 284 + expect((init.headers as Record<string, string>)["Cookie"]).toBe("atbb_session=mytoken"); 285 + }); 286 + }); 287 + 288 + describe("permission helpers", () => { 289 + const modSession = { 290 + authenticated: true as const, 291 + did: "did:plc:mod", 292 + handle: "mod.bsky.social", 293 + permissions: new Set([ 294 + "space.atbb.permission.lockTopics", 295 + "space.atbb.permission.moderatePosts", 296 + "space.atbb.permission.banUsers", 297 + ]), 298 + }; 299 + 300 + const memberSession = { 301 + authenticated: true as const, 302 + did: "did:plc:member", 303 + handle: "member.bsky.social", 304 + permissions: new Set<string>(), 305 + }; 306 + 307 + const unauthSession = { authenticated: false as const, permissions: new Set<string>() }; 308 + 309 + it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true)); 310 + it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false)); 311 + it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false)); 312 + 313 + it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true)); 314 + it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false)); 315 + 316 + it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true)); 317 + it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false)); 318 + 319 + // Wildcard "*" permission — Owner role grants all permissions via the catch-all 320 + const ownerSession = { 321 + authenticated: true as const, 322 + did: "did:plc:owner", 323 + handle: "owner.bsky.social", 324 + permissions: new Set(["*"]), 325 + }; 326 + 327 + it("canLockTopics returns true for owner with wildcard permission", () => 328 + expect(canLockTopics(ownerSession)).toBe(true)); 329 + it("canModeratePosts returns true for owner with wildcard permission", () => 330 + expect(canModeratePosts(ownerSession)).toBe(true)); 331 + it("canBanUsers returns true for owner with wildcard permission", () => 332 + expect(canBanUsers(ownerSession)).toBe(true)); 333 + });
+88
apps/web/src/lib/session.ts
··· 55 55 return { authenticated: false }; 56 56 } 57 57 } 58 + 59 + /** 60 + * Extended session type that includes the user's role permissions. 61 + * Used on pages that need to conditionally render moderation UI. 62 + */ 63 + export type WebSessionWithPermissions = 64 + | { authenticated: false; permissions: Set<string> } 65 + | { authenticated: true; did: string; handle: string; permissions: Set<string> }; 66 + 67 + /** 68 + * Like getSession(), but also fetches the user's role permissions from 69 + * GET /api/admin/members/me. Use on pages that need to render mod buttons. 70 + * 71 + * Returns empty permissions on network errors or when user has no membership. 72 + * Never throws — always returns a usable session. 73 + */ 74 + export async function getSessionWithPermissions( 75 + appviewUrl: string, 76 + cookieHeader?: string 77 + ): Promise<WebSessionWithPermissions> { 78 + const session = await getSession(appviewUrl, cookieHeader); 79 + 80 + if (!session.authenticated) { 81 + return { authenticated: false, permissions: new Set() }; 82 + } 83 + 84 + let permissions = new Set<string>(); 85 + try { 86 + const res = await fetch(`${appviewUrl}/api/admin/members/me`, { 87 + headers: { Cookie: cookieHeader! }, 88 + }); 89 + 90 + if (res.ok) { 91 + const data = (await res.json()) as Record<string, unknown>; 92 + if (Array.isArray(data.permissions)) { 93 + permissions = new Set(data.permissions as string[]); 94 + } 95 + } else if (res.status !== 404) { 96 + // 404 = no membership = expected for guests, no log needed 97 + console.error( 98 + "getSessionWithPermissions: unexpected status from members/me", 99 + { 100 + operation: "GET /api/admin/members/me", 101 + did: session.did, 102 + status: res.status, 103 + } 104 + ); 105 + } 106 + } catch (error) { 107 + console.error( 108 + "getSessionWithPermissions: failed to fetch permissions — continuing with empty permissions", 109 + { 110 + operation: "GET /api/admin/members/me", 111 + did: session.did, 112 + error: error instanceof Error ? error.message : String(error), 113 + } 114 + ); 115 + } 116 + 117 + return { ...session, permissions }; 118 + } 119 + 120 + /** Returns true if the session grants permission to lock/unlock topics. */ 121 + export function canLockTopics(auth: WebSessionWithPermissions): boolean { 122 + return ( 123 + auth.authenticated && 124 + (auth.permissions.has("space.atbb.permission.lockTopics") || 125 + auth.permissions.has("*")) 126 + ); 127 + } 128 + 129 + /** Returns true if the session grants permission to hide/unhide posts. */ 130 + export function canModeratePosts(auth: WebSessionWithPermissions): boolean { 131 + return ( 132 + auth.authenticated && 133 + (auth.permissions.has("space.atbb.permission.moderatePosts") || 134 + auth.permissions.has("*")) 135 + ); 136 + } 137 + 138 + /** Returns true if the session grants permission to ban/unban users. */ 139 + export function canBanUsers(auth: WebSessionWithPermissions): boolean { 140 + return ( 141 + auth.authenticated && 142 + (auth.permissions.has("space.atbb.permission.banUsers") || 143 + auth.permissions.has("*")) 144 + ); 145 + }
+264
apps/web/src/routes/__tests__/mod.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createModActionRoute", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + // Default: AppView returns error 11 + mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }); 12 + }); 13 + 14 + afterEach(() => { 15 + vi.unstubAllGlobals(); 16 + vi.unstubAllEnvs(); 17 + mockFetch.mockReset(); 18 + }); 19 + 20 + function appviewSuccess() { 21 + return { 22 + ok: true, 23 + status: 200, 24 + json: () => Promise.resolve({ success: true }), 25 + }; 26 + } 27 + 28 + async function loadModRoutes() { 29 + const { createModActionRoute } = await import("../mod.js"); 30 + return createModActionRoute("http://localhost:3000"); 31 + } 32 + 33 + function modFormBody(fields: Record<string, string>) { 34 + return { 35 + method: "POST", 36 + headers: { 37 + "Content-Type": "application/x-www-form-urlencoded", 38 + cookie: "atbb_session=token", 39 + }, 40 + body: new URLSearchParams(fields).toString(), 41 + }; 42 + } 43 + 44 + // ─── Input validation ──────────────────────────────────────────────────── 45 + 46 + it("returns 400 error fragment when action is missing", async () => { 47 + const routes = await loadModRoutes(); 48 + const res = await routes.request( 49 + "/mod/action", 50 + modFormBody({ id: "1", reason: "test" }) 51 + ); 52 + expect(res.status).toBe(200); 53 + const html = await res.text(); 54 + expect(html).toContain("form-error"); 55 + expect(html).toContain("Unknown action"); 56 + expect(mockFetch).not.toHaveBeenCalled(); 57 + }); 58 + 59 + it("returns 400 error fragment when reason is empty", async () => { 60 + const routes = await loadModRoutes(); 61 + const res = await routes.request( 62 + "/mod/action", 63 + modFormBody({ action: "lock", id: "1", reason: "" }) 64 + ); 65 + expect(res.status).toBe(200); 66 + const html = await res.text(); 67 + expect(html).toContain("form-error"); 68 + expect(html).toContain("required"); 69 + expect(mockFetch).not.toHaveBeenCalled(); 70 + }); 71 + 72 + it("returns 400 error fragment when id is missing for lock action", async () => { 73 + const routes = await loadModRoutes(); 74 + const res = await routes.request( 75 + "/mod/action", 76 + modFormBody({ action: "lock", reason: "test" }) 77 + ); 78 + expect(res.status).toBe(200); 79 + const html = await res.text(); 80 + expect(html).toContain("form-error"); 81 + expect(html).toContain("required"); 82 + expect(mockFetch).not.toHaveBeenCalled(); 83 + }); 84 + 85 + // ─── Lock action ───────────────────────────────────────────────────────── 86 + 87 + it("posts to /api/mod/lock with topicId and reason for lock action", async () => { 88 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 89 + const routes = await loadModRoutes(); 90 + const res = await routes.request( 91 + "/mod/action", 92 + modFormBody({ action: "lock", id: "42", reason: "Off-topic thread" }) 93 + ); 94 + expect(res.status).toBe(200); 95 + expect(res.headers.get("HX-Refresh")).toBe("true"); 96 + 97 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 98 + expect(url).toBe("http://localhost:3000/api/mod/lock"); 99 + expect(init.method).toBe("POST"); 100 + const body = JSON.parse(init.body as string); 101 + expect(body.topicId).toBe("42"); 102 + expect(body.reason).toBe("Off-topic thread"); 103 + expect((init.headers as Record<string, string>)["Cookie"]).toContain("atbb_session=token"); 104 + }); 105 + 106 + it("sends DELETE to /api/mod/lock/:id for unlock action", async () => { 107 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 108 + const routes = await loadModRoutes(); 109 + const res = await routes.request( 110 + "/mod/action", 111 + modFormBody({ action: "unlock", id: "42", reason: "Thread reopened" }) 112 + ); 113 + expect(res.status).toBe(200); 114 + expect(res.headers.get("HX-Refresh")).toBe("true"); 115 + 116 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 117 + expect(url).toBe("http://localhost:3000/api/mod/lock/42"); 118 + expect(init.method).toBe("DELETE"); 119 + }); 120 + 121 + // ─── Hide action ───────────────────────────────────────────────────────── 122 + 123 + it("posts to /api/mod/hide with postId and reason for hide action", async () => { 124 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 125 + const routes = await loadModRoutes(); 126 + const res = await routes.request( 127 + "/mod/action", 128 + modFormBody({ action: "hide", id: "99", reason: "Spam" }) 129 + ); 130 + expect(res.status).toBe(200); 131 + expect(res.headers.get("HX-Refresh")).toBe("true"); 132 + 133 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 134 + expect(url).toBe("http://localhost:3000/api/mod/hide"); 135 + const body = JSON.parse(init.body as string); 136 + expect(body.postId).toBe("99"); 137 + }); 138 + 139 + it("sends DELETE to /api/mod/hide/:id for unhide action", async () => { 140 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 141 + const routes = await loadModRoutes(); 142 + await routes.request( 143 + "/mod/action", 144 + modFormBody({ action: "unhide", id: "99", reason: "Reinstated" }) 145 + ); 146 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 147 + expect(url).toBe("http://localhost:3000/api/mod/hide/99"); 148 + expect(init.method).toBe("DELETE"); 149 + }); 150 + 151 + // ─── Ban action ────────────────────────────────────────────────────────── 152 + 153 + it("posts to /api/mod/ban with targetDid and reason for ban action", async () => { 154 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 155 + const routes = await loadModRoutes(); 156 + const res = await routes.request( 157 + "/mod/action", 158 + modFormBody({ action: "ban", id: "did:plc:badactor", reason: "Harassment" }) 159 + ); 160 + expect(res.status).toBe(200); 161 + expect(res.headers.get("HX-Refresh")).toBe("true"); 162 + 163 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 164 + expect(url).toBe("http://localhost:3000/api/mod/ban"); 165 + const body = JSON.parse(init.body as string); 166 + expect(body.targetDid).toBe("did:plc:badactor"); 167 + }); 168 + 169 + it("sends DELETE to /api/mod/ban/:id for unban action", async () => { 170 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 171 + const routes = await loadModRoutes(); 172 + await routes.request( 173 + "/mod/action", 174 + modFormBody({ action: "unban", id: "did:plc:unbanned", reason: "Appeal accepted" }) 175 + ); 176 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 177 + expect(url).toBe("http://localhost:3000/api/mod/ban/did:plc:unbanned"); 178 + expect(init.method).toBe("DELETE"); 179 + }); 180 + 181 + // ─── Auth and conflict handling ────────────────────────────────────────── 182 + 183 + it("returns error fragment when AppView returns 401 (session expired)", async () => { 184 + mockFetch.mockResolvedValueOnce({ 185 + ok: false, 186 + status: 401, 187 + json: () => Promise.resolve({ error: "Unauthorized" }), 188 + }); 189 + const routes = await loadModRoutes(); 190 + const res = await routes.request( 191 + "/mod/action", 192 + modFormBody({ action: "lock", id: "1", reason: "test" }) 193 + ); 194 + expect(res.status).toBe(200); 195 + const html = await res.text(); 196 + expect(html).toContain("form-error"); 197 + expect(html).toContain("logged in"); 198 + expect(res.headers.get("HX-Refresh")).toBeNull(); 199 + }); 200 + 201 + it("returns error fragment when AppView returns 409 (action already active)", async () => { 202 + mockFetch.mockResolvedValueOnce({ 203 + ok: false, 204 + status: 409, 205 + json: () => Promise.resolve({ error: "Already locked" }), 206 + }); 207 + const routes = await loadModRoutes(); 208 + const res = await routes.request( 209 + "/mod/action", 210 + modFormBody({ action: "lock", id: "1", reason: "test" }) 211 + ); 212 + expect(res.status).toBe(200); 213 + const html = await res.text(); 214 + expect(html).toContain("form-error"); 215 + expect(html).toContain("already active"); 216 + expect(res.headers.get("HX-Refresh")).toBeNull(); 217 + }); 218 + 219 + // ─── Error handling ────────────────────────────────────────────────────── 220 + 221 + it("returns error fragment when AppView returns 403", async () => { 222 + mockFetch.mockResolvedValueOnce({ 223 + ok: false, 224 + status: 403, 225 + json: () => Promise.resolve({ error: "Insufficient permissions" }), 226 + }); 227 + const routes = await loadModRoutes(); 228 + const res = await routes.request( 229 + "/mod/action", 230 + modFormBody({ action: "lock", id: "1", reason: "test" }) 231 + ); 232 + expect(res.status).toBe(200); 233 + const html = await res.text(); 234 + expect(html).toContain("form-error"); 235 + expect(html).toContain("permission"); 236 + expect(res.headers.get("HX-Refresh")).toBeNull(); 237 + }); 238 + 239 + it("returns error fragment when AppView returns 503 (network error)", async () => { 240 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 241 + const routes = await loadModRoutes(); 242 + const res = await routes.request( 243 + "/mod/action", 244 + modFormBody({ action: "hide", id: "1", reason: "test" }) 245 + ); 246 + expect(res.status).toBe(200); 247 + const html = await res.text(); 248 + expect(html).toContain("form-error"); 249 + expect(html).toContain("unavailable"); 250 + }); 251 + 252 + it("returns error fragment when AppView returns 500", async () => { 253 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 254 + const routes = await loadModRoutes(); 255 + const res = await routes.request( 256 + "/mod/action", 257 + modFormBody({ action: "ban", id: "did:plc:x", reason: "test" }) 258 + ); 259 + expect(res.status).toBe(200); 260 + const html = await res.text(); 261 + expect(html).toContain("form-error"); 262 + expect(html).toContain("went wrong"); 263 + }); 264 + });
+170
apps/web/src/routes/__tests__/topics.test.tsx
··· 113 113 mockFetch.mockResolvedValueOnce(makeCategoryResponse()); 114 114 } 115 115 116 + function membersMeNotFound() { 117 + return { ok: false, status: 404 }; 118 + } 119 + 120 + function membersMeMod( 121 + permissions = [ 122 + "space.atbb.permission.moderatePosts", 123 + "space.atbb.permission.lockTopics", 124 + "space.atbb.permission.banUsers", 125 + ] 126 + ) { 127 + return { 128 + ok: true, 129 + json: () => 130 + Promise.resolve({ 131 + did: "did:plc:mod", 132 + handle: "mod.bsky.social", 133 + role: "Moderator", 134 + roleUri: "at://...", 135 + permissions, 136 + }), 137 + }; 138 + } 139 + 116 140 async function loadTopicsRoutes() { 117 141 const { createTopicsRoutes } = await import("../topics.js"); 118 142 return createTopicsRoutes("http://localhost:3000"); ··· 408 432 json: () => 409 433 Promise.resolve({ authenticated: true, did: "did:plc:user", handle: "user.bsky.social" }), 410 434 }); 435 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 411 436 setupSuccessfulFetch({ locked: true }); 412 437 const routes = await loadTopicsRoutes(); 413 438 const res = await routes.request("/topics/1", { ··· 442 467 handle: "user.bsky.social", 443 468 }), 444 469 }); 470 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 445 471 setupSuccessfulFetch(); 446 472 const routes = await loadTopicsRoutes(); 447 473 const res = await routes.request("/topics/1", { ··· 596 622 597 623 it("shows reply form with textarea when authenticated", async () => { 598 624 mockFetch.mockResolvedValueOnce(authSession); 625 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 599 626 mockFetch.mockResolvedValueOnce(makeTopicResponse()); 600 627 const routes = await loadTopicsRoutes(); 601 628 const res = await routes.request("/topics/1", { ··· 628 655 629 656 it("uses hx-post for reply form submission", async () => { 630 657 mockFetch.mockResolvedValueOnce(authSession); 658 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 631 659 mockFetch.mockResolvedValueOnce(makeTopicResponse()); 632 660 const routes = await loadTopicsRoutes(); 633 661 const res = await routes.request("/topics/1", { ··· 824 852 }); 825 853 const [, fetchOptions] = mockFetch.mock.calls[0]; 826 854 expect(fetchOptions.headers["Cookie"]).toContain("atbb_session=token"); 855 + }); 856 + 857 + // ─── Mod actions (permission-based rendering) ──────────────────────────────── 858 + 859 + it("shows lock button for user with lockTopics permission", async () => { 860 + mockFetch.mockResolvedValueOnce(authSession); 861 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.lockTopics"])); 862 + setupSuccessfulFetch(); 863 + const routes = await loadTopicsRoutes(); 864 + const res = await routes.request("/topics/1", { 865 + headers: { cookie: "atbb_session=token" }, 866 + }); 867 + const html = await res.text(); 868 + expect(html).toContain("Lock Topic"); 869 + expect(html).toContain("openModDialog"); 870 + }); 871 + 872 + it("does not show lock button for user without lockTopics permission", async () => { 873 + mockFetch.mockResolvedValueOnce(authSession); 874 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 875 + setupSuccessfulFetch(); 876 + const routes = await loadTopicsRoutes(); 877 + const res = await routes.request("/topics/1", { 878 + headers: { cookie: "atbb_session=token" }, 879 + }); 880 + const html = await res.text(); 881 + expect(html).not.toContain("Lock Topic"); 882 + }); 883 + 884 + it("shows Unlock Topic button when topic is locked and user has lockTopics permission", async () => { 885 + mockFetch.mockResolvedValueOnce(authSession); 886 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.lockTopics"])); 887 + setupSuccessfulFetch({ locked: true }); 888 + const routes = await loadTopicsRoutes(); 889 + const res = await routes.request("/topics/1", { 890 + headers: { cookie: "atbb_session=token" }, 891 + }); 892 + const html = await res.text(); 893 + expect(html).toContain("Unlock Topic"); 894 + }); 895 + 896 + it("shows hide button on each post for user with moderatePosts permission", async () => { 897 + mockFetch.mockResolvedValueOnce(authSession); 898 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.moderatePosts"])); 899 + const reply = makeReply({ id: "2", text: "A reply" }); 900 + setupSuccessfulFetch({ replies: [reply] }); 901 + const routes = await loadTopicsRoutes(); 902 + const res = await routes.request("/topics/1", { 903 + headers: { cookie: "atbb_session=token" }, 904 + }); 905 + const html = await res.text(); 906 + expect(html).toContain("Hide"); 907 + expect(html).toContain("openModDialog"); 908 + }); 909 + 910 + it("shows ban button on posts for user with banUsers permission", async () => { 911 + mockFetch.mockResolvedValueOnce(authSession); 912 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.banUsers"])); 913 + setupSuccessfulFetch(); 914 + const routes = await loadTopicsRoutes(); 915 + const res = await routes.request("/topics/1", { 916 + headers: { cookie: "atbb_session=token" }, 917 + }); 918 + const html = await res.text(); 919 + expect(html).toContain("Ban user"); 920 + }); 921 + 922 + it("shows mod dialog in page for users with any mod permission", async () => { 923 + mockFetch.mockResolvedValueOnce(authSession); 924 + mockFetch.mockResolvedValueOnce(membersMeMod()); 925 + setupSuccessfulFetch(); 926 + const routes = await loadTopicsRoutes(); 927 + const res = await routes.request("/topics/1", { 928 + headers: { cookie: "atbb_session=token" }, 929 + }); 930 + const html = await res.text(); 931 + expect(html).toContain("mod-dialog"); 932 + expect(html).toContain("<dialog"); 933 + }); 934 + 935 + it("does not show any mod buttons for unauthenticated users", async () => { 936 + setupSuccessfulFetch(); 937 + const routes = await loadTopicsRoutes(); 938 + const res = await routes.request("/topics/1"); 939 + const html = await res.text(); 940 + expect(html).not.toContain("Lock Topic"); 941 + expect(html).not.toContain("Hide"); 942 + expect(html).not.toContain("Ban user"); 943 + expect(html).not.toContain("mod-dialog"); 944 + }); 945 + 946 + it("ban button not rendered when post has no author", async () => { 947 + // Both OP and reply have null authors — ban button requires post.author?.did 948 + mockFetch.mockResolvedValueOnce(authSession); 949 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.banUsers"])); 950 + mockFetch.mockResolvedValueOnce({ 951 + ok: true, 952 + json: () => 953 + Promise.resolve({ 954 + topicId: "1", 955 + locked: false, 956 + pinned: false, 957 + post: { 958 + id: "1", 959 + did: "did:plc:author", 960 + rkey: "tid123", 961 + text: "OP text", 962 + forumUri: null, 963 + boardUri: null, 964 + boardId: "42", 965 + parentPostId: null, 966 + createdAt: "2025-01-01T00:00:00.000Z", 967 + author: null, 968 + }, 969 + replies: [makeReply({ author: null })], 970 + }), 971 + }); 972 + mockFetch.mockResolvedValueOnce(makeBoardResponse()); 973 + mockFetch.mockResolvedValueOnce(makeCategoryResponse()); 974 + const routes = await loadTopicsRoutes(); 975 + const res = await routes.request("/topics/1", { 976 + headers: { cookie: "atbb_session=token" }, 977 + }); 978 + const html = await res.text(); 979 + // No ban buttons when all posts have null authors 980 + expect(html).not.toContain("Ban user"); 981 + }); 982 + 983 + it("HTMX partial renders mod buttons for authenticated moderator", async () => { 984 + const allReplies = [makeReply({ id: "27", text: "HTMX partial reply" })]; 985 + // getSessionWithPermissions makes 2 fetches (session + members/me), then 1 for topic 986 + mockFetch.mockResolvedValueOnce(authSession); 987 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.moderatePosts"])); 988 + mockFetch.mockResolvedValueOnce(makeTopicResponse({ replies: allReplies })); 989 + const routes = await loadTopicsRoutes(); 990 + const res = await routes.request("/topics/1?offset=0", { 991 + headers: { "HX-Request": "true", cookie: "atbb_session=token" }, 992 + }); 993 + expect(res.status).toBe(200); 994 + const html = await res.text(); 995 + expect(html).toContain("HTMX partial reply"); 996 + expect(html).toContain("Hide"); // mod button present in paginated partial 827 997 }); 828 998 });
+3 -1
apps/web/src/routes/index.ts
··· 6 6 import { createLoginRoutes } from "./login.js"; 7 7 import { createNewTopicRoutes } from "./new-topic.js"; 8 8 import { createAuthRoutes } from "./auth.js"; 9 + import { createModActionRoute } from "./mod.js"; 9 10 10 11 const config = loadConfig(); 11 12 ··· 15 16 .route("/", createTopicsRoutes(config.appviewUrl)) 16 17 .route("/", createLoginRoutes(config.appviewUrl)) 17 18 .route("/", createNewTopicRoutes(config.appviewUrl)) 18 - .route("/", createAuthRoutes(config.appviewUrl)); 19 + .route("/", createAuthRoutes(config.appviewUrl)) 20 + .route("/", createModActionRoute(config.appviewUrl));
+152
apps/web/src/routes/mod.ts
··· 1 + import { Hono } from "hono"; 2 + import { isProgrammingError } from "../lib/errors.js"; 3 + 4 + /** 5 + * Single proxy endpoint for all moderation actions. 6 + * 7 + * Reads `action`, `id`, and `reason` from the form body and dispatches 8 + * to the correct AppView mod endpoint. Returns HX-Refresh on success 9 + * so HTMX reloads the current page to show updated state. 10 + * 11 + * Action dispatch table: 12 + * lock → POST /api/mod/lock body: { topicId, reason } 13 + * unlock → DELETE /api/mod/lock/:id body: { reason } 14 + * hide → POST /api/mod/hide body: { postId, reason } 15 + * unhide → DELETE /api/mod/hide/:id body: { reason } 16 + * ban → POST /api/mod/ban body: { targetDid, reason } 17 + * unban → DELETE /api/mod/ban/:id body: { reason } 18 + */ 19 + export function createModActionRoute(appviewUrl: string) { 20 + return new Hono().post("/mod/action", async (c) => { 21 + let body: Record<string, string | File>; 22 + try { 23 + body = await c.req.parseBody(); 24 + } catch { 25 + return c.html(`<p class="form-error">Invalid form submission.</p>`); 26 + } 27 + 28 + const action = typeof body.action === "string" ? body.action.trim() : ""; 29 + const id = typeof body.id === "string" ? body.id.trim() : ""; 30 + const reason = typeof body.reason === "string" ? body.reason.trim() : ""; 31 + const cookieHeader = c.req.header("cookie") ?? ""; 32 + 33 + // Validate action 34 + const validActions = ["lock", "unlock", "hide", "unhide", "ban", "unban"]; 35 + if (!validActions.includes(action)) { 36 + return c.html(`<p class="form-error">Unknown action.</p>`); 37 + } 38 + 39 + // Validate reason 40 + if (!reason) { 41 + return c.html(`<p class="form-error">Reason is required.</p>`); 42 + } 43 + if (reason.length > 3000) { 44 + return c.html(`<p class="form-error">Reason must not exceed 3000 characters.</p>`); 45 + } 46 + 47 + // Validate id 48 + if (!id) { 49 + return c.html(`<p class="form-error">Target ID is required.</p>`); 50 + } 51 + 52 + // Build AppView request 53 + let appviewEndpoint: string; 54 + let method: "POST" | "DELETE"; 55 + let appviewBody: Record<string, string>; 56 + 57 + switch (action) { 58 + case "lock": 59 + appviewEndpoint = `${appviewUrl}/api/mod/lock`; 60 + method = "POST"; 61 + appviewBody = { topicId: id, reason }; 62 + break; 63 + case "unlock": 64 + appviewEndpoint = `${appviewUrl}/api/mod/lock/${id}`; 65 + method = "DELETE"; 66 + appviewBody = { reason }; 67 + break; 68 + case "hide": 69 + appviewEndpoint = `${appviewUrl}/api/mod/hide`; 70 + method = "POST"; 71 + appviewBody = { postId: id, reason }; 72 + break; 73 + case "unhide": 74 + appviewEndpoint = `${appviewUrl}/api/mod/hide/${id}`; 75 + method = "DELETE"; 76 + appviewBody = { reason }; 77 + break; 78 + case "ban": 79 + appviewEndpoint = `${appviewUrl}/api/mod/ban`; 80 + method = "POST"; 81 + appviewBody = { targetDid: id, reason }; 82 + break; 83 + case "unban": 84 + appviewEndpoint = `${appviewUrl}/api/mod/ban/${id}`; 85 + method = "DELETE"; 86 + appviewBody = { reason }; 87 + break; 88 + default: 89 + return c.html(`<p class="form-error">Unknown action.</p>`); 90 + } 91 + 92 + // Forward to AppView 93 + let appviewRes: Response; 94 + try { 95 + appviewRes = await fetch(appviewEndpoint, { 96 + method, 97 + headers: { 98 + "Content-Type": "application/json", 99 + Cookie: cookieHeader, 100 + }, 101 + body: JSON.stringify(appviewBody), 102 + }); 103 + } catch (error) { 104 + if (isProgrammingError(error)) throw error; 105 + console.error("Failed to proxy mod action to AppView", { 106 + operation: `${method} ${appviewEndpoint}`, 107 + action, 108 + error: error instanceof Error ? error.message : String(error), 109 + }); 110 + return c.html( 111 + `<p class="form-error">Forum temporarily unavailable. Please try again.</p>` 112 + ); 113 + } 114 + 115 + if (appviewRes.ok) { 116 + return new Response(null, { 117 + status: 200, 118 + headers: { "HX-Refresh": "true" }, 119 + }); 120 + } 121 + 122 + // Handle error responses 123 + let errorMessage = "Something went wrong. Please try again."; 124 + 125 + if (appviewRes.status === 401) { 126 + console.error("AppView returned 401 for mod action — session may have expired", { 127 + operation: `${method} ${appviewEndpoint}`, 128 + action, 129 + }); 130 + errorMessage = "You must be logged in to perform this action."; 131 + } else if (appviewRes.status === 403) { 132 + console.error("AppView returned 403 for mod action — permission mismatch", { 133 + operation: `${method} ${appviewEndpoint}`, 134 + action, 135 + }); 136 + errorMessage = "You don't have permission for this action."; 137 + } else if (appviewRes.status === 404) { 138 + errorMessage = "Target not found."; 139 + } else if (appviewRes.status === 409) { 140 + // 409 = already active (e.g. locking an already-locked topic) 141 + errorMessage = "This action is already active."; 142 + } else if (appviewRes.status >= 500) { 143 + console.error("AppView returned server error for mod action", { 144 + operation: `${method} ${appviewEndpoint}`, 145 + action, 146 + status: appviewRes.status, 147 + }); 148 + } 149 + 150 + return c.html(`<p class="form-error">${errorMessage}</p>`); 151 + }); 152 + }
+123 -4
apps/web/src/routes/topics.tsx
··· 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader, EmptyState, ErrorDisplay } from "../components/index.js"; 4 4 import { fetchApi } from "../lib/api.js"; 5 - import { getSession } from "../lib/session.js"; 5 + import { 6 + getSessionWithPermissions, 7 + canLockTopics, 8 + canModeratePosts, 9 + canBanUsers, 10 + } from "../lib/session.js"; 6 11 import { 7 12 isProgrammingError, 8 13 isNetworkError, ··· 78 83 } 79 84 `; 80 85 86 + const MOD_DIALOG_SCRIPT = ` 87 + var MOD_TITLES = { 88 + lock: 'Lock Topic', unlock: 'Unlock Topic', 89 + hide: 'Hide Post', unhide: 'Unhide Post', 90 + ban: 'Ban User', unban: 'Unban User' 91 + }; 92 + function openModDialog(action, id) { 93 + document.getElementById('mod-dialog-action').value = action; 94 + document.getElementById('mod-dialog-id').value = id; 95 + document.getElementById('mod-dialog-title').textContent = MOD_TITLES[action] || 'Confirm'; 96 + document.getElementById('mod-dialog-error').innerHTML = ''; 97 + document.getElementById('mod-reason').value = ''; 98 + document.getElementById('mod-dialog').showModal(); 99 + } 100 + `; 101 + 81 102 // ─── Inline components ──────────────────────────────────────────────────────── 82 103 83 104 function PostCard({ 84 105 post, 85 106 postNumber, 86 107 isOP = false, 108 + modPerms = { canHide: false, canBan: false }, 87 109 }: { 88 110 post: PostResponse; 89 111 postNumber: number; 90 112 isOP?: boolean; 113 + modPerms?: { canHide: boolean; canBan: boolean }; 91 114 }) { 92 115 const handle = post.author?.handle ?? post.author?.did ?? post.did; 93 116 const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown"; ··· 102 125 <div class="post-card__body" style="white-space: pre-wrap"> 103 126 {post.text} 104 127 </div> 128 + {(modPerms.canHide || modPerms.canBan) && ( 129 + <div class="post-card__mod-actions"> 130 + {modPerms.canHide && ( 131 + <button 132 + class="mod-btn mod-btn--hide" 133 + type="button" 134 + onclick={`openModDialog('hide','${post.id}')`} 135 + > 136 + Hide 137 + </button> 138 + )} 139 + {modPerms.canBan && post.author?.did && ( 140 + <button 141 + class="mod-btn mod-btn--ban" 142 + type="button" 143 + onclick={`openModDialog('ban','${post.author.did}')`} 144 + > 145 + Ban user 146 + </button> 147 + )} 148 + </div> 149 + )} 105 150 </div> 106 151 ); 107 152 } 108 153 154 + function ModDialog() { 155 + return ( 156 + <dialog id="mod-dialog" class="mod-dialog"> 157 + <h2 id="mod-dialog-title" class="mod-dialog__title">Confirm Action</h2> 158 + <form 159 + hx-post="/mod/action" 160 + hx-target="#mod-dialog-error" 161 + hx-swap="innerHTML" 162 + hx-disabled-elt="[type=submit]" 163 + > 164 + <input type="hidden" id="mod-dialog-action" name="action" value="" /> 165 + <input type="hidden" id="mod-dialog-id" name="id" value="" /> 166 + <div class="form-group"> 167 + <label for="mod-reason">Reason</label> 168 + <textarea 169 + id="mod-reason" 170 + name="reason" 171 + rows={3} 172 + placeholder="Reason for this action…" 173 + /> 174 + </div> 175 + <div id="mod-dialog-error" /> 176 + <div class="form-actions"> 177 + <button type="submit" class="btn btn-danger"> 178 + Confirm 179 + </button> 180 + <button 181 + type="button" 182 + class="btn btn-secondary" 183 + onclick="document.getElementById('mod-dialog').close()" 184 + > 185 + Cancel 186 + </button> 187 + </div> 188 + </form> 189 + </dialog> 190 + ); 191 + } 192 + 109 193 function LoadMoreButton({ 110 194 topicId, 111 195 nextOffset, ··· 132 216 replies, 133 217 total, 134 218 offset, 219 + modPerms = { canHide: false, canBan: false }, 135 220 }: { 136 221 topicId: string; 137 222 replies: PostResponse[]; 138 223 total: number; 139 224 offset: number; 225 + modPerms?: { canHide: boolean; canBan: boolean }; 140 226 }) { 141 227 const nextOffset = offset + replies.length; 142 228 const hasMore = nextOffset < total; 143 229 return ( 144 230 <> 145 231 {replies.map((reply, i) => ( 146 - <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} /> 232 + <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} modPerms={modPerms} /> 147 233 ))} 148 234 {hasMore && <LoadMoreButton topicId={topicId} nextOffset={nextOffset} />} 149 235 </> ··· 175 261 176 262 // ── HTMX partial mode ──────────────────────────────────────────────────── 177 263 if (c.req.header("HX-Request")) { 264 + const partialAuth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 265 + const partialModPerms = { 266 + canHide: canModeratePosts(partialAuth), 267 + canBan: canBanUsers(partialAuth), 268 + canLock: canLockTopics(partialAuth), 269 + }; 178 270 try { 179 271 const data = await fetchApi<TopicDetailResponse>(`/topics/${topicId}`); 180 272 // TODO(ATB-33): switch to server-side offset/limit pagination like boards.tsx ··· 186 278 replies={pageReplies} 187 279 total={data.replies.length} 188 280 offset={offset} 281 + modPerms={partialModPerms} 189 282 />, 190 283 200 191 284 ); ··· 202 295 } 203 296 204 297 // ── Full page mode ──────────────────────────────────────────────────────── 205 - const auth = await getSession(appviewUrl, c.req.header("cookie")); 298 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 299 + const modPerms = { 300 + canHide: canModeratePosts(auth), 301 + canBan: canBanUsers(auth), 302 + canLock: canLockTopics(auth), 303 + }; 304 + const hasAnyModPerm = modPerms.canHide || modPerms.canBan || modPerms.canLock; 206 305 207 306 // Stage 1: fetch topic (fatal on failure) 208 307 let topicData: TopicDetailResponse; ··· 304 403 305 404 <PageHeader title={topicTitle} /> 306 405 406 + {modPerms.canLock && ( 407 + <div class="topic-mod-controls"> 408 + <button 409 + class={`mod-btn ${topicData.locked ? "mod-btn--unlock" : "mod-btn--lock"}`} 410 + type="button" 411 + onclick={`openModDialog('${topicData.locked ? "unlock" : "lock"}','${topicId}')`} 412 + > 413 + {topicData.locked ? "Unlock Topic" : "Lock Topic"} 414 + </button> 415 + </div> 416 + )} 417 + 307 418 {topicData.locked && ( 308 419 <div class="topic-locked-banner"> 309 420 <span class="topic-locked-banner__badge">Locked</span> ··· 311 422 </div> 312 423 )} 313 424 314 - <PostCard post={topicData.post} postNumber={1} isOP={true} /> 425 + <PostCard post={topicData.post} postNumber={1} isOP={true} modPerms={modPerms} /> 315 426 316 427 <div id="reply-list"> 317 428 {allReplies.length === 0 ? ( ··· 322 433 replies={initialReplies} 323 434 total={total} 324 435 offset={0} 436 + modPerms={modPerms} 325 437 /> 326 438 )} 327 439 </div> ··· 368 480 </p> 369 481 )} 370 482 </div> 483 + 484 + {hasAnyModPerm && ( 485 + <> 486 + <ModDialog /> 487 + <script dangerouslySetInnerHTML={{ __html: MOD_DIALOG_SCRIPT }} /> 488 + </> 489 + )} 371 490 </BaseLayout> 372 491 ); 373 492 })
+6 -1
docs/atproto-forum-plan.md
··· 198 198 - 421 tests total (added 78 new tests) — comprehensive coverage including auth, validation, business logic, infrastructure errors 199 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 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) 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) 202 207 - [x] **ATB-20: Enforce mod actions in read/write-path API responses** — **Complete:** 2026-02-16 203 208 - All API read endpoints filter soft-deleted posts (`deleted = false` in all queries) 204 209 - All API write endpoints (topic/post create) block banned users at request time
+203
docs/plans/2026-02-19-admin-moderation-ui-design.md
··· 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
+1522
docs/plans/2026-02-19-admin-moderation-ui.md
··· 1 + # Admin Moderation UI Implementation Plan (ATB-24) 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add lock/unlock, hide/unhide, and ban/unban mod action buttons to the topic view, visible only to users with appropriate permissions. 6 + 7 + **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. 8 + 9 + **Tech Stack:** Hono, JSX, HTMX 2.x, Vitest, Drizzle ORM, PostgreSQL 10 + 11 + --- 12 + 13 + ## Task 1: AppView — `GET /api/admin/members/me` 14 + 15 + Returns the calling user's own membership + permissions. No special permission required — any authenticated user may check their own role. 16 + 17 + **Files:** 18 + - Modify: `apps/appview/src/routes/admin.ts` 19 + - Modify: `apps/appview/src/routes/__tests__/admin.test.ts` 20 + 21 + ### Step 1: Write the failing test 22 + 23 + In `apps/appview/src/routes/__tests__/admin.test.ts`, add a new `describe` block after the existing ones: 24 + 25 + ```typescript 26 + describe("GET /api/admin/members/me", () => { 27 + it("returns 401 when not authenticated", async () => { 28 + // The requireAuth mock auto-passes, so temporarily bypass it: 29 + // We test 401 by checking the middleware is there (integration test in a separate file if needed) 30 + // For now, test the success cases through the mocked middleware 31 + expect(true).toBe(true); // placeholder — real auth tested at middleware level 32 + }); 33 + 34 + it("returns 404 when user has no membership", async () => { 35 + mockUser = { did: "did:plc:no-membership" }; 36 + const res = await app.request("/api/admin/members/me"); 37 + expect(res.status).toBe(404); 38 + const data = await res.json(); 39 + expect(data.error).toBeDefined(); 40 + }); 41 + 42 + it("returns membership with role and permissions for member with role", async () => { 43 + // Insert forum, user, membership, and role records 44 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 45 + await ctx.db.insert(forums).values({ 46 + did: ctx.config.forumDid, 47 + rkey: "self", 48 + cid: "bafyforum", 49 + name: "Test Forum", 50 + description: null, 51 + createdAt: new Date(), 52 + indexedAt: new Date(), 53 + }); 54 + 55 + await ctx.db.insert(users).values({ 56 + did: "did:plc:me", 57 + handle: "me.bsky.social", 58 + indexedAt: new Date(), 59 + }); 60 + 61 + const roleRkey = "moderatorrkey"; 62 + await ctx.db.insert(roles).values({ 63 + did: ctx.config.forumDid, 64 + rkey: roleRkey, 65 + cid: "bafyrole", 66 + name: "Moderator", 67 + description: null, 68 + permissions: ["space.atbb.permission.moderatePosts", "space.atbb.permission.lockTopics"], 69 + priority: 20, 70 + createdAt: new Date(), 71 + indexedAt: new Date(), 72 + }); 73 + 74 + const roleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${roleRkey}`; 75 + await ctx.db.insert(memberships).values({ 76 + did: "did:plc:me", 77 + rkey: "membershiprkey", 78 + cid: "bafymembership", 79 + forumUri, 80 + roleUri, 81 + joinedAt: new Date(), 82 + indexedAt: new Date(), 83 + }); 84 + 85 + mockUser = { did: "did:plc:me" }; 86 + const res = await app.request("/api/admin/members/me"); 87 + expect(res.status).toBe(200); 88 + const data = await res.json(); 89 + expect(data.did).toBe("did:plc:me"); 90 + expect(data.handle).toBe("me.bsky.social"); 91 + expect(data.role).toBe("Moderator"); 92 + expect(data.roleUri).toBe(roleUri); 93 + expect(data.permissions).toContain("space.atbb.permission.moderatePosts"); 94 + expect(data.permissions).toContain("space.atbb.permission.lockTopics"); 95 + }); 96 + 97 + it("returns membership with empty permissions when role has no permissions", async () => { 98 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 99 + await ctx.db.insert(forums).values({ 100 + did: ctx.config.forumDid, 101 + rkey: "self", 102 + cid: "bafyforum2", 103 + name: "Test Forum", 104 + description: null, 105 + createdAt: new Date(), 106 + indexedAt: new Date(), 107 + }); 108 + await ctx.db.insert(users).values({ 109 + did: "did:plc:member", 110 + handle: "member.bsky.social", 111 + indexedAt: new Date(), 112 + }); 113 + const roleRkey = "memberrkey"; 114 + await ctx.db.insert(roles).values({ 115 + did: ctx.config.forumDid, 116 + rkey: roleRkey, 117 + cid: "bafyrole2", 118 + name: "Member", 119 + description: null, 120 + permissions: [], 121 + priority: 30, 122 + createdAt: new Date(), 123 + indexedAt: new Date(), 124 + }); 125 + const roleUri = `at://${ctx.config.forumDid}/space.atbb.forum.role/${roleRkey}`; 126 + await ctx.db.insert(memberships).values({ 127 + did: "did:plc:member", 128 + rkey: "membershiprkey2", 129 + cid: "bafymembership2", 130 + forumUri, 131 + roleUri, 132 + joinedAt: new Date(), 133 + indexedAt: new Date(), 134 + }); 135 + 136 + mockUser = { did: "did:plc:member" }; 137 + const res = await app.request("/api/admin/members/me"); 138 + expect(res.status).toBe(200); 139 + const data = await res.json(); 140 + expect(data.permissions).toEqual([]); 141 + }); 142 + }); 143 + ``` 144 + 145 + Also add missing imports at the top of the test file (after existing imports): 146 + ```typescript 147 + import { forums } from "@atbb/db"; 148 + ``` 149 + 150 + ### Step 2: Run test to verify it fails 151 + 152 + ```bash 153 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 154 + pnpm --filter @atbb/appview test src/routes/__tests__/admin.test.ts 155 + ``` 156 + Expected: FAIL — `GET /api/admin/members/me` route doesn't exist yet. 157 + 158 + ### Step 3: Implement the endpoint 159 + 160 + In `apps/appview/src/routes/admin.ts`, add the following import at the top if `forums` isn't already imported: 161 + 162 + ```typescript 163 + import { memberships, roles, users, forums } from "@atbb/db"; 164 + ``` 165 + 166 + Then add this endpoint before `return app;` at the bottom: 167 + 168 + ```typescript 169 + /** 170 + * GET /api/admin/members/me 171 + * 172 + * Returns the calling user's own membership, role, and permissions. 173 + * Any authenticated user may call this — no special permission required. 174 + * Returns 404 if the user has no membership record. 175 + */ 176 + app.get("/members/me", requireAuth(ctx), async (c) => { 177 + const user = c.get("user")!; 178 + 179 + try { 180 + const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 181 + const [member] = await ctx.db 182 + .select({ 183 + did: memberships.did, 184 + handle: users.handle, 185 + roleUri: memberships.roleUri, 186 + roleName: roles.name, 187 + permissions: roles.permissions, 188 + }) 189 + .from(memberships) 190 + .leftJoin(users, eq(memberships.did, users.did)) 191 + .leftJoin( 192 + roles, 193 + sql`${memberships.roleUri} LIKE 'at://' || ${roles.did} || '/space.atbb.forum.role/' || ${roles.rkey}` 194 + ) 195 + .where( 196 + and( 197 + eq(memberships.did, user.did), 198 + eq(memberships.forumUri, forumUri) 199 + ) 200 + ) 201 + .limit(1); 202 + 203 + if (!member) { 204 + return c.json({ error: "Membership not found" }, 404); 205 + } 206 + 207 + return c.json({ 208 + did: member.did, 209 + handle: member.handle || user.did, 210 + role: member.roleName || "Member", 211 + roleUri: member.roleUri, 212 + permissions: member.permissions || [], 213 + }); 214 + } catch (error) { 215 + console.error("Failed to get current user membership", { 216 + operation: "GET /api/admin/members/me", 217 + did: user.did, 218 + error: error instanceof Error ? error.message : String(error), 219 + }); 220 + return c.json( 221 + { error: "Failed to retrieve your membership. Please try again later." }, 222 + 500 223 + ); 224 + } 225 + }); 226 + ``` 227 + 228 + You'll need these imports at the top of `admin.ts` if not already present: `and`, `eq`, `sql` from `drizzle-orm`. 229 + 230 + Check existing imports at the top of the file and add any missing ones: 231 + ```typescript 232 + import { eq, asc, and, sql } from "drizzle-orm"; 233 + ``` 234 + 235 + ### Step 4: Run tests to verify they pass 236 + 237 + ```bash 238 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 239 + pnpm --filter @atbb/appview test src/routes/__tests__/admin.test.ts 240 + ``` 241 + Expected: all tests PASS 242 + 243 + ### Step 5: Commit 244 + 245 + ```bash 246 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 247 + git commit -m "feat(appview): add GET /api/admin/members/me endpoint (ATB-24)" 248 + ``` 249 + 250 + --- 251 + 252 + ## Task 2: Web Server — `getSessionWithPermissions()` and permission helpers 253 + 254 + Add a new function to `session.ts` that extends authentication with permission data. Keeps the existing `getSession()` unchanged so other pages are unaffected. 255 + 256 + **Files:** 257 + - Modify: `apps/web/src/lib/session.ts` 258 + - Modify: `apps/web/src/lib/__tests__/session.test.ts` 259 + 260 + ### Step 1: Write the failing tests 261 + 262 + Add to `apps/web/src/lib/__tests__/session.test.ts` (after the existing `describe("getSession", ...)` block): 263 + 264 + ```typescript 265 + import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers } from "../session.js"; 266 + 267 + describe("getSessionWithPermissions", () => { 268 + beforeEach(() => { 269 + vi.stubGlobal("fetch", mockFetch); 270 + }); 271 + 272 + afterEach(() => { 273 + vi.unstubAllGlobals(); 274 + mockFetch.mockReset(); 275 + }); 276 + 277 + it("returns unauthenticated with empty permissions when no cookie", async () => { 278 + const result = await getSessionWithPermissions("http://localhost:3000"); 279 + expect(result).toMatchObject({ authenticated: false }); 280 + expect(result.permissions.size).toBe(0); 281 + }); 282 + 283 + it("returns authenticated with empty permissions when members/me returns 404", async () => { 284 + mockFetch.mockResolvedValueOnce({ 285 + ok: true, 286 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 287 + }); 288 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 289 + 290 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 291 + expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" }); 292 + expect(result.permissions.size).toBe(0); 293 + }); 294 + 295 + it("returns permissions as Set when members/me succeeds", async () => { 296 + mockFetch.mockResolvedValueOnce({ 297 + ok: true, 298 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }), 299 + }); 300 + mockFetch.mockResolvedValueOnce({ 301 + ok: true, 302 + json: () => Promise.resolve({ 303 + did: "did:plc:mod", 304 + handle: "mod.bsky.social", 305 + role: "Moderator", 306 + roleUri: "at://...", 307 + permissions: [ 308 + "space.atbb.permission.moderatePosts", 309 + "space.atbb.permission.lockTopics", 310 + "space.atbb.permission.banUsers", 311 + ], 312 + }), 313 + }); 314 + 315 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 316 + expect(result.authenticated).toBe(true); 317 + expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true); 318 + expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true); 319 + expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true); 320 + expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false); 321 + }); 322 + 323 + it("returns empty permissions without crashing when members/me call throws", async () => { 324 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 325 + mockFetch.mockResolvedValueOnce({ 326 + ok: true, 327 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 328 + }); 329 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 330 + 331 + const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 332 + expect(result.authenticated).toBe(true); 333 + expect(result.permissions.size).toBe(0); 334 + expect(consoleSpy).toHaveBeenCalledWith( 335 + expect.stringContaining("failed to fetch permissions"), 336 + expect.any(Object) 337 + ); 338 + consoleSpy.mockRestore(); 339 + }); 340 + 341 + it("does not log error when members/me returns 404 (expected for guests)", async () => { 342 + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 343 + mockFetch.mockResolvedValueOnce({ 344 + ok: true, 345 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 346 + }); 347 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 348 + 349 + await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); 350 + expect(consoleSpy).not.toHaveBeenCalled(); 351 + consoleSpy.mockRestore(); 352 + }); 353 + 354 + it("forwards cookie header to members/me call", async () => { 355 + mockFetch.mockResolvedValueOnce({ 356 + ok: true, 357 + json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), 358 + }); 359 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 360 + 361 + await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken"); 362 + 363 + expect(mockFetch).toHaveBeenCalledTimes(2); 364 + const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit]; 365 + expect(url).toBe("http://localhost:3000/api/admin/members/me"); 366 + expect((init.headers as Record<string, string>)["Cookie"]).toBe("atbb_session=mytoken"); 367 + }); 368 + }); 369 + 370 + describe("permission helpers", () => { 371 + const modSession = { 372 + authenticated: true as const, 373 + did: "did:plc:mod", 374 + handle: "mod.bsky.social", 375 + permissions: new Set([ 376 + "space.atbb.permission.lockTopics", 377 + "space.atbb.permission.moderatePosts", 378 + "space.atbb.permission.banUsers", 379 + ]), 380 + }; 381 + 382 + const memberSession = { 383 + authenticated: true as const, 384 + did: "did:plc:member", 385 + handle: "member.bsky.social", 386 + permissions: new Set<string>(), 387 + }; 388 + 389 + const unauthSession = { authenticated: false as const, permissions: new Set<string>() }; 390 + 391 + it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true)); 392 + it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false)); 393 + it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false)); 394 + 395 + it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true)); 396 + it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false)); 397 + 398 + it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true)); 399 + it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false)); 400 + }); 401 + ``` 402 + 403 + 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): 404 + ```typescript 405 + import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers } from "../session.js"; 406 + ``` 407 + 408 + ### Step 2: Run tests to verify they fail 409 + 410 + ```bash 411 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 412 + pnpm --filter @atbb/web test src/lib/__tests__/session.test.ts 413 + ``` 414 + Expected: FAIL — `getSessionWithPermissions` not found. 415 + 416 + ### Step 3: Implement the new functions 417 + 418 + In `apps/web/src/lib/session.ts`, add after the existing `WebSession` type and `getSession` function: 419 + 420 + ```typescript 421 + /** 422 + * Extended session type that includes the user's role permissions. 423 + * Used on pages that need to conditionally render moderation UI. 424 + */ 425 + export type WebSessionWithPermissions = 426 + | { authenticated: false; permissions: Set<string> } 427 + | { authenticated: true; did: string; handle: string; permissions: Set<string> }; 428 + 429 + /** 430 + * Like getSession(), but also fetches the user's role permissions from 431 + * GET /api/admin/members/me. Use on pages that need to render mod buttons. 432 + * 433 + * Returns empty permissions on network errors or when user has no membership. 434 + * Never throws — always returns a usable session. 435 + */ 436 + export async function getSessionWithPermissions( 437 + appviewUrl: string, 438 + cookieHeader?: string 439 + ): Promise<WebSessionWithPermissions> { 440 + const session = await getSession(appviewUrl, cookieHeader); 441 + 442 + if (!session.authenticated) { 443 + return { authenticated: false, permissions: new Set() }; 444 + } 445 + 446 + let permissions = new Set<string>(); 447 + try { 448 + const res = await fetch(`${appviewUrl}/api/admin/members/me`, { 449 + headers: { Cookie: cookieHeader! }, 450 + }); 451 + 452 + if (res.ok) { 453 + const data = (await res.json()) as Record<string, unknown>; 454 + if (Array.isArray(data.permissions)) { 455 + permissions = new Set(data.permissions as string[]); 456 + } 457 + } else if (res.status !== 404) { 458 + // 404 = no membership = expected for guests, no log 459 + console.error("getSessionWithPermissions: unexpected status from members/me", { 460 + operation: "GET /api/admin/members/me", 461 + status: res.status, 462 + }); 463 + } 464 + } catch (error) { 465 + console.error( 466 + "getSessionWithPermissions: failed to fetch permissions — continuing with empty permissions", 467 + { 468 + operation: "GET /api/admin/members/me", 469 + did: session.did, 470 + error: error instanceof Error ? error.message : String(error), 471 + } 472 + ); 473 + } 474 + 475 + return { ...session, permissions }; 476 + } 477 + 478 + /** Returns true if the session grants permission to lock/unlock topics. */ 479 + export function canLockTopics(auth: WebSessionWithPermissions): boolean { 480 + return auth.authenticated && auth.permissions.has("space.atbb.permission.lockTopics"); 481 + } 482 + 483 + /** Returns true if the session grants permission to hide/unhide posts. */ 484 + export function canModeratePosts(auth: WebSessionWithPermissions): boolean { 485 + return auth.authenticated && auth.permissions.has("space.atbb.permission.moderatePosts"); 486 + } 487 + 488 + /** Returns true if the session grants permission to ban/unban users. */ 489 + export function canBanUsers(auth: WebSessionWithPermissions): boolean { 490 + return auth.authenticated && auth.permissions.has("space.atbb.permission.banUsers"); 491 + } 492 + ``` 493 + 494 + ### Step 4: Run tests to verify they pass 495 + 496 + ```bash 497 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 498 + pnpm --filter @atbb/web test src/lib/__tests__/session.test.ts 499 + ``` 500 + Expected: all tests PASS 501 + 502 + ### Step 5: Commit 503 + 504 + ```bash 505 + git add apps/web/src/lib/session.ts apps/web/src/lib/__tests__/session.test.ts 506 + git commit -m "feat(web): add getSessionWithPermissions and canXxx helpers (ATB-24)" 507 + ``` 508 + 509 + --- 510 + 511 + ## Task 3: Web Server — `POST /mod/action` proxy route 512 + 513 + 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. 514 + 515 + **Files:** 516 + - Create: `apps/web/src/routes/mod.ts` 517 + - Create: `apps/web/src/routes/__tests__/mod.test.ts` 518 + - Modify: `apps/web/src/routes/index.ts` 519 + 520 + ### Step 1: Write the failing tests 521 + 522 + Create `apps/web/src/routes/__tests__/mod.test.ts`: 523 + 524 + ```typescript 525 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 526 + 527 + const mockFetch = vi.fn(); 528 + 529 + describe("createModActionRoute", () => { 530 + beforeEach(() => { 531 + vi.stubGlobal("fetch", mockFetch); 532 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 533 + vi.resetModules(); 534 + // Default: AppView returns error 535 + mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" }); 536 + }); 537 + 538 + afterEach(() => { 539 + vi.unstubAllGlobals(); 540 + vi.unstubAllEnvs(); 541 + mockFetch.mockReset(); 542 + }); 543 + 544 + function appviewSuccess() { 545 + return { 546 + ok: true, 547 + status: 200, 548 + json: () => Promise.resolve({ success: true }), 549 + }; 550 + } 551 + 552 + async function loadModRoutes() { 553 + const { createModActionRoute } = await import("../mod.js"); 554 + return createModActionRoute("http://localhost:3000"); 555 + } 556 + 557 + function modFormBody(fields: Record<string, string>) { 558 + return { 559 + method: "POST", 560 + headers: { 561 + "Content-Type": "application/x-www-form-urlencoded", 562 + cookie: "atbb_session=token", 563 + }, 564 + body: new URLSearchParams(fields).toString(), 565 + }; 566 + } 567 + 568 + // ─── Input validation ──────────────────────────────────────────────────── 569 + 570 + it("returns 400 error fragment when action is missing", async () => { 571 + const routes = await loadModRoutes(); 572 + const res = await routes.request( 573 + "/mod/action", 574 + modFormBody({ id: "1", reason: "test" }) 575 + ); 576 + expect(res.status).toBe(200); 577 + const html = await res.text(); 578 + expect(html).toContain("form-error"); 579 + expect(html).toContain("Unknown action"); 580 + expect(mockFetch).not.toHaveBeenCalled(); 581 + }); 582 + 583 + it("returns 400 error fragment when reason is empty", async () => { 584 + const routes = await loadModRoutes(); 585 + const res = await routes.request( 586 + "/mod/action", 587 + modFormBody({ action: "lock", id: "1", reason: "" }) 588 + ); 589 + expect(res.status).toBe(200); 590 + const html = await res.text(); 591 + expect(html).toContain("form-error"); 592 + expect(mockFetch).not.toHaveBeenCalled(); 593 + }); 594 + 595 + it("returns 400 error fragment when id is missing for lock action", async () => { 596 + const routes = await loadModRoutes(); 597 + const res = await routes.request( 598 + "/mod/action", 599 + modFormBody({ action: "lock", reason: "test" }) 600 + ); 601 + expect(res.status).toBe(200); 602 + const html = await res.text(); 603 + expect(html).toContain("form-error"); 604 + expect(mockFetch).not.toHaveBeenCalled(); 605 + }); 606 + 607 + // ─── Lock action ───────────────────────────────────────────────────────── 608 + 609 + it("posts to /api/mod/lock with topicId and reason for lock action", async () => { 610 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 611 + const routes = await loadModRoutes(); 612 + const res = await routes.request( 613 + "/mod/action", 614 + modFormBody({ action: "lock", id: "42", reason: "Off-topic thread" }) 615 + ); 616 + expect(res.status).toBe(200); 617 + expect(res.headers.get("HX-Refresh")).toBe("true"); 618 + 619 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 620 + expect(url).toBe("http://localhost:3000/api/mod/lock"); 621 + expect(init.method).toBe("POST"); 622 + const body = JSON.parse(init.body as string); 623 + expect(body.topicId).toBe("42"); 624 + expect(body.reason).toBe("Off-topic thread"); 625 + expect((init.headers as Record<string, string>)["Cookie"]).toContain("atbb_session=token"); 626 + }); 627 + 628 + it("sends DELETE to /api/mod/lock/:id for unlock action", async () => { 629 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 630 + const routes = await loadModRoutes(); 631 + const res = await routes.request( 632 + "/mod/action", 633 + modFormBody({ action: "unlock", id: "42", reason: "Thread reopened" }) 634 + ); 635 + expect(res.status).toBe(200); 636 + expect(res.headers.get("HX-Refresh")).toBe("true"); 637 + 638 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 639 + expect(url).toBe("http://localhost:3000/api/mod/lock/42"); 640 + expect(init.method).toBe("DELETE"); 641 + }); 642 + 643 + // ─── Hide action ───────────────────────────────────────────────────────── 644 + 645 + it("posts to /api/mod/hide with postId and reason for hide action", async () => { 646 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 647 + const routes = await loadModRoutes(); 648 + const res = await routes.request( 649 + "/mod/action", 650 + modFormBody({ action: "hide", id: "99", reason: "Spam" }) 651 + ); 652 + expect(res.status).toBe(200); 653 + expect(res.headers.get("HX-Refresh")).toBe("true"); 654 + 655 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 656 + expect(url).toBe("http://localhost:3000/api/mod/hide"); 657 + const body = JSON.parse(init.body as string); 658 + expect(body.postId).toBe("99"); 659 + }); 660 + 661 + it("sends DELETE to /api/mod/hide/:id for unhide action", async () => { 662 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 663 + const routes = await loadModRoutes(); 664 + await routes.request( 665 + "/mod/action", 666 + modFormBody({ action: "unhide", id: "99", reason: "Reinstated" }) 667 + ); 668 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 669 + expect(url).toBe("http://localhost:3000/api/mod/hide/99"); 670 + expect(init.method).toBe("DELETE"); 671 + }); 672 + 673 + // ─── Ban action ────────────────────────────────────────────────────────── 674 + 675 + it("posts to /api/mod/ban with targetDid and reason for ban action", async () => { 676 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 677 + const routes = await loadModRoutes(); 678 + const res = await routes.request( 679 + "/mod/action", 680 + modFormBody({ action: "ban", id: "did:plc:badactor", reason: "Harassment" }) 681 + ); 682 + expect(res.status).toBe(200); 683 + expect(res.headers.get("HX-Refresh")).toBe("true"); 684 + 685 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 686 + expect(url).toBe("http://localhost:3000/api/mod/ban"); 687 + const body = JSON.parse(init.body as string); 688 + expect(body.targetDid).toBe("did:plc:badactor"); 689 + }); 690 + 691 + it("sends DELETE to /api/mod/ban/:id for unban action", async () => { 692 + mockFetch.mockResolvedValueOnce(appviewSuccess()); 693 + const routes = await loadModRoutes(); 694 + await routes.request( 695 + "/mod/action", 696 + modFormBody({ action: "unban", id: "did:plc:unbanned", reason: "Appeal accepted" }) 697 + ); 698 + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; 699 + expect(url).toBe("http://localhost:3000/api/mod/ban/did:plc:unbanned"); 700 + expect(init.method).toBe("DELETE"); 701 + }); 702 + 703 + // ─── Error handling ────────────────────────────────────────────────────── 704 + 705 + it("returns error fragment when AppView returns 403", async () => { 706 + mockFetch.mockResolvedValueOnce({ 707 + ok: false, 708 + status: 403, 709 + json: () => Promise.resolve({ error: "Insufficient permissions" }), 710 + }); 711 + const routes = await loadModRoutes(); 712 + const res = await routes.request( 713 + "/mod/action", 714 + modFormBody({ action: "lock", id: "1", reason: "test" }) 715 + ); 716 + expect(res.status).toBe(200); 717 + const html = await res.text(); 718 + expect(html).toContain("form-error"); 719 + expect(html).toContain("permission"); 720 + expect(res.headers.get("HX-Refresh")).toBeNull(); 721 + }); 722 + 723 + it("returns error fragment when AppView returns 503 (network error)", async () => { 724 + mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); 725 + const routes = await loadModRoutes(); 726 + const res = await routes.request( 727 + "/mod/action", 728 + modFormBody({ action: "hide", id: "1", reason: "test" }) 729 + ); 730 + expect(res.status).toBe(200); 731 + const html = await res.text(); 732 + expect(html).toContain("form-error"); 733 + expect(html).toContain("unavailable"); 734 + }); 735 + 736 + it("returns error fragment when AppView returns 500", async () => { 737 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: "Internal Server Error" }); 738 + const routes = await loadModRoutes(); 739 + const res = await routes.request( 740 + "/mod/action", 741 + modFormBody({ action: "ban", id: "did:plc:x", reason: "test" }) 742 + ); 743 + expect(res.status).toBe(200); 744 + const html = await res.text(); 745 + expect(html).toContain("form-error"); 746 + expect(html).toContain("went wrong"); 747 + }); 748 + }); 749 + ``` 750 + 751 + ### Step 2: Run test to verify it fails 752 + 753 + ```bash 754 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 755 + pnpm --filter @atbb/web test src/routes/__tests__/mod.test.ts 756 + ``` 757 + Expected: FAIL — module not found. 758 + 759 + ### Step 3: Implement `mod.ts` 760 + 761 + Create `apps/web/src/routes/mod.ts`: 762 + 763 + ```typescript 764 + import { Hono } from "hono"; 765 + import { isProgrammingError } from "../lib/errors.js"; 766 + 767 + /** 768 + * Single proxy endpoint for all moderation actions. 769 + * 770 + * Reads `action`, `id`, and `reason` from the form body and dispatches 771 + * to the correct AppView mod endpoint. Returns HX-Refresh on success 772 + * so HTMX reloads the current page to show updated state. 773 + * 774 + * Action dispatch table: 775 + * lock → POST /api/mod/lock body: { topicId, reason } 776 + * unlock → DELETE /api/mod/lock/:id body: { reason } 777 + * hide → POST /api/mod/hide body: { postId, reason } 778 + * unhide → DELETE /api/mod/hide/:id body: { reason } 779 + * ban → POST /api/mod/ban body: { targetDid, reason } 780 + * unban → DELETE /api/mod/ban/:id body: { reason } 781 + */ 782 + export function createModActionRoute(appviewUrl: string) { 783 + return new Hono().post("/mod/action", async (c) => { 784 + let body: Record<string, string | File>; 785 + try { 786 + body = await c.req.parseBody(); 787 + } catch { 788 + return c.html(`<p class="form-error">Invalid form submission.</p>`); 789 + } 790 + 791 + const action = typeof body.action === "string" ? body.action.trim() : ""; 792 + const id = typeof body.id === "string" ? body.id.trim() : ""; 793 + const reason = typeof body.reason === "string" ? body.reason.trim() : ""; 794 + const cookieHeader = c.req.header("cookie") ?? ""; 795 + 796 + // Validate action 797 + const validActions = ["lock", "unlock", "hide", "unhide", "ban", "unban"]; 798 + if (!validActions.includes(action)) { 799 + return c.html(`<p class="form-error">Unknown action.</p>`); 800 + } 801 + 802 + // Validate reason 803 + if (!reason) { 804 + return c.html(`<p class="form-error">Reason is required.</p>`); 805 + } 806 + if (reason.length > 3000) { 807 + return c.html(`<p class="form-error">Reason must not exceed 3000 characters.</p>`); 808 + } 809 + 810 + // Validate id 811 + if (!id) { 812 + return c.html(`<p class="form-error">Target ID is required.</p>`); 813 + } 814 + 815 + // Build AppView request 816 + let appviewUrl_: string; 817 + let method: "POST" | "DELETE"; 818 + let appviewBody: Record<string, string>; 819 + 820 + switch (action) { 821 + case "lock": 822 + appviewUrl_ = `${appviewUrl}/api/mod/lock`; 823 + method = "POST"; 824 + appviewBody = { topicId: id, reason }; 825 + break; 826 + case "unlock": 827 + appviewUrl_ = `${appviewUrl}/api/mod/lock/${encodeURIComponent(id)}`; 828 + method = "DELETE"; 829 + appviewBody = { reason }; 830 + break; 831 + case "hide": 832 + appviewUrl_ = `${appviewUrl}/api/mod/hide`; 833 + method = "POST"; 834 + appviewBody = { postId: id, reason }; 835 + break; 836 + case "unhide": 837 + appviewUrl_ = `${appviewUrl}/api/mod/hide/${encodeURIComponent(id)}`; 838 + method = "DELETE"; 839 + appviewBody = { reason }; 840 + break; 841 + case "ban": 842 + appviewUrl_ = `${appviewUrl}/api/mod/ban`; 843 + method = "POST"; 844 + appviewBody = { targetDid: id, reason }; 845 + break; 846 + case "unban": 847 + appviewUrl_ = `${appviewUrl}/api/mod/ban/${encodeURIComponent(id)}`; 848 + method = "DELETE"; 849 + appviewBody = { reason }; 850 + break; 851 + default: 852 + return c.html(`<p class="form-error">Unknown action.</p>`); 853 + } 854 + 855 + // Forward to AppView 856 + let appviewRes: Response; 857 + try { 858 + appviewRes = await fetch(appviewUrl_, { 859 + method, 860 + headers: { 861 + "Content-Type": "application/json", 862 + Cookie: cookieHeader, 863 + }, 864 + body: JSON.stringify(appviewBody), 865 + }); 866 + } catch (error) { 867 + if (isProgrammingError(error)) throw error; 868 + console.error("Failed to proxy mod action to AppView", { 869 + operation: `${method} ${appviewUrl_}`, 870 + action, 871 + error: error instanceof Error ? error.message : String(error), 872 + }); 873 + return c.html( 874 + `<p class="form-error">Forum temporarily unavailable. Please try again.</p>` 875 + ); 876 + } 877 + 878 + if (appviewRes.ok) { 879 + return new Response(null, { 880 + status: 200, 881 + headers: { "HX-Refresh": "true" }, 882 + }); 883 + } 884 + 885 + // Handle error responses 886 + let errorMessage = "Something went wrong. Please try again."; 887 + 888 + if (appviewRes.status === 401) { 889 + errorMessage = "You must be logged in to perform this action."; 890 + } else if (appviewRes.status === 403) { 891 + errorMessage = "You don't have permission for this action."; 892 + } else if (appviewRes.status === 404) { 893 + errorMessage = "Target not found."; 894 + } else if (appviewRes.status === 409) { 895 + errorMessage = "This action is already active."; 896 + } else if (appviewRes.status >= 500) { 897 + console.error("AppView returned server error for mod action", { 898 + operation: `${method} ${appviewUrl_}`, 899 + action, 900 + status: appviewRes.status, 901 + }); 902 + } 903 + 904 + return c.html(`<p class="form-error">${errorMessage}</p>`); 905 + }); 906 + } 907 + ``` 908 + 909 + ### Step 4: Register the route in `index.ts` 910 + 911 + In `apps/web/src/routes/index.ts`, add the import and route: 912 + 913 + ```typescript 914 + import { createModActionRoute } from "./mod.js"; 915 + 916 + export const webRoutes = new Hono() 917 + .route("/", createHomeRoutes(config.appviewUrl)) 918 + .route("/", createBoardsRoutes(config.appviewUrl)) 919 + .route("/", createTopicsRoutes(config.appviewUrl)) 920 + .route("/", createLoginRoutes(config.appviewUrl)) 921 + .route("/", createNewTopicRoutes(config.appviewUrl)) 922 + .route("/", createAuthRoutes(config.appviewUrl)) 923 + .route("/", createModActionRoute(config.appviewUrl)); // add this line 924 + ``` 925 + 926 + ### Step 5: Run tests to verify they pass 927 + 928 + ```bash 929 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 930 + pnpm --filter @atbb/web test src/routes/__tests__/mod.test.ts 931 + ``` 932 + Expected: all tests PASS 933 + 934 + ### Step 6: Commit 935 + 936 + ```bash 937 + git add apps/web/src/routes/mod.ts \ 938 + apps/web/src/routes/__tests__/mod.test.ts \ 939 + apps/web/src/routes/index.ts 940 + git commit -m "feat(web): add POST /mod/action proxy route (ATB-24)" 941 + ``` 942 + 943 + --- 944 + 945 + ## Task 4: Topic Page — Mod Buttons, Dialog, and Lock Button 946 + 947 + 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. 948 + 949 + **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. 950 + 951 + **Files:** 952 + - Modify: `apps/web/src/routes/topics.tsx` 953 + - Modify: `apps/web/src/routes/__tests__/topics.test.tsx` 954 + 955 + ### Step 1: Update existing broken tests first 956 + 957 + In `apps/web/src/routes/__tests__/topics.test.tsx`: 958 + 959 + **Add a helper function** for the members/me mock (no membership — most tests don't need mod buttons): 960 + 961 + ```typescript 962 + function membersMeNotFound() { 963 + return { ok: false, status: 404 }; 964 + } 965 + 966 + function membersMeMod( 967 + permissions = [ 968 + "space.atbb.permission.moderatePosts", 969 + "space.atbb.permission.lockTopics", 970 + "space.atbb.permission.banUsers", 971 + ] 972 + ) { 973 + return { 974 + ok: true, 975 + json: () => 976 + Promise.resolve({ 977 + did: "did:plc:mod", 978 + handle: "mod.bsky.social", 979 + role: "Moderator", 980 + roleUri: "at://...", 981 + permissions, 982 + }), 983 + }; 984 + } 985 + ``` 986 + 987 + **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). 988 + 989 + The affected tests and their fix: 990 + 991 + **Test at ~line 404** ("shows locked message when topic is locked and user is authenticated"): 992 + ```typescript 993 + // Before: 994 + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, ... }) }); 995 + setupSuccessfulFetch({ locked: true }); 996 + // After — INSERT membersMeNotFound between auth and topic: 997 + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, ... }) }); 998 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 999 + setupSuccessfulFetch({ locked: true }); 1000 + ``` 1001 + 1002 + **Test at ~line 434** ("shows reply form slot for authenticated users"): 1003 + ```typescript 1004 + // Add membersMeNotFound after the authSession mock, before setupSuccessfulFetch 1005 + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, ... }) }); 1006 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 1007 + setupSuccessfulFetch(); 1008 + ``` 1009 + 1010 + **Test at ~line 597** ("shows reply form with textarea when authenticated"): 1011 + ```typescript 1012 + mockFetch.mockResolvedValueOnce(authSession); 1013 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); // ← add this 1014 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 1015 + ``` 1016 + 1017 + **Test at ~line 630** ("uses hx-post for reply form submission"): 1018 + ```typescript 1019 + mockFetch.mockResolvedValueOnce(authSession); 1020 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); // ← add this 1021 + mockFetch.mockResolvedValueOnce(makeTopicResponse()); 1022 + ``` 1023 + 1024 + 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). 1025 + 1026 + ### Step 2: Add new mod button tests 1027 + 1028 + In `topics.test.tsx`, add a new section: 1029 + 1030 + ```typescript 1031 + // ─── Mod actions (permission-based rendering) ──────────────────────────────── 1032 + 1033 + it("shows lock button for user with lockTopics permission", async () => { 1034 + mockFetch.mockResolvedValueOnce(authSession); 1035 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.lockTopics"])); 1036 + setupSuccessfulFetch(); 1037 + const routes = await loadTopicsRoutes(); 1038 + const res = await routes.request("/topics/1", { 1039 + headers: { cookie: "atbb_session=token" }, 1040 + }); 1041 + const html = await res.text(); 1042 + expect(html).toContain("Lock Topic"); 1043 + expect(html).toContain("openModDialog"); 1044 + }); 1045 + 1046 + it("does not show lock button for user without lockTopics permission", async () => { 1047 + mockFetch.mockResolvedValueOnce(authSession); 1048 + mockFetch.mockResolvedValueOnce(membersMeNotFound()); 1049 + setupSuccessfulFetch(); 1050 + const routes = await loadTopicsRoutes(); 1051 + const res = await routes.request("/topics/1", { 1052 + headers: { cookie: "atbb_session=token" }, 1053 + }); 1054 + const html = await res.text(); 1055 + expect(html).not.toContain("Lock Topic"); 1056 + }); 1057 + 1058 + it("shows Unlock Topic button when topic is locked and user has lockTopics permission", async () => { 1059 + mockFetch.mockResolvedValueOnce(authSession); 1060 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.lockTopics"])); 1061 + setupSuccessfulFetch({ locked: true }); 1062 + const routes = await loadTopicsRoutes(); 1063 + const res = await routes.request("/topics/1", { 1064 + headers: { cookie: "atbb_session=token" }, 1065 + }); 1066 + const html = await res.text(); 1067 + expect(html).toContain("Unlock Topic"); 1068 + }); 1069 + 1070 + it("shows hide button on each post for user with moderatePosts permission", async () => { 1071 + mockFetch.mockResolvedValueOnce(authSession); 1072 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.moderatePosts"])); 1073 + const reply = makeReply({ id: "2", text: "A reply" }); 1074 + setupSuccessfulFetch({ replies: [reply] }); 1075 + const routes = await loadTopicsRoutes(); 1076 + const res = await routes.request("/topics/1", { 1077 + headers: { cookie: "atbb_session=token" }, 1078 + }); 1079 + const html = await res.text(); 1080 + expect(html).toContain("Hide"); 1081 + expect(html).toContain("openModDialog"); 1082 + }); 1083 + 1084 + it("shows ban button on posts for user with banUsers permission", async () => { 1085 + mockFetch.mockResolvedValueOnce(authSession); 1086 + mockFetch.mockResolvedValueOnce(membersMeMod(["space.atbb.permission.banUsers"])); 1087 + setupSuccessfulFetch(); 1088 + const routes = await loadTopicsRoutes(); 1089 + const res = await routes.request("/topics/1", { 1090 + headers: { cookie: "atbb_session=token" }, 1091 + }); 1092 + const html = await res.text(); 1093 + expect(html).toContain("Ban user"); 1094 + }); 1095 + 1096 + it("shows mod dialog in page for users with any mod permission", async () => { 1097 + mockFetch.mockResolvedValueOnce(authSession); 1098 + mockFetch.mockResolvedValueOnce(membersMeMod()); 1099 + setupSuccessfulFetch(); 1100 + const routes = await loadTopicsRoutes(); 1101 + const res = await routes.request("/topics/1", { 1102 + headers: { cookie: "atbb_session=token" }, 1103 + }); 1104 + const html = await res.text(); 1105 + expect(html).toContain("mod-dialog"); 1106 + expect(html).toContain("<dialog"); 1107 + }); 1108 + 1109 + it("does not show any mod buttons for unauthenticated users", async () => { 1110 + setupSuccessfulFetch(); 1111 + const routes = await loadTopicsRoutes(); 1112 + const res = await routes.request("/topics/1"); 1113 + const html = await res.text(); 1114 + expect(html).not.toContain("Lock Topic"); 1115 + expect(html).not.toContain("Hide"); 1116 + expect(html).not.toContain("Ban user"); 1117 + expect(html).not.toContain("mod-dialog"); 1118 + }); 1119 + ``` 1120 + 1121 + ### Step 3: Run tests to verify the new ones fail (existing ones may also fail) 1122 + 1123 + ```bash 1124 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1125 + pnpm --filter @atbb/web test src/routes/__tests__/topics.test.tsx 1126 + ``` 1127 + Expected: new tests FAIL, some existing auth tests may now fail too. 1128 + 1129 + ### Step 4: Implement the topics.tsx changes 1130 + 1131 + In `apps/web/src/routes/topics.tsx`, make the following changes: 1132 + 1133 + **1. Update imports** — add the new session functions: 1134 + ```typescript 1135 + import { 1136 + getSessionWithPermissions, 1137 + canLockTopics, 1138 + canModeratePosts, 1139 + canBanUsers, 1140 + } from "../lib/session.js"; 1141 + ``` 1142 + Remove `getSession` from the import if it's no longer used. 1143 + 1144 + **2. Update `PostCard` component** — add `modPerms` prop: 1145 + ```typescript 1146 + function PostCard({ 1147 + post, 1148 + postNumber, 1149 + isOP = false, 1150 + modPerms = { canHide: false, canBan: false }, 1151 + }: { 1152 + post: PostResponse; 1153 + postNumber: number; 1154 + isOP?: boolean; 1155 + modPerms?: { canHide: boolean; canBan: boolean }; 1156 + }) { 1157 + const handle = post.author?.handle ?? post.author?.did ?? post.did; 1158 + const date = post.createdAt ? timeAgo(new Date(post.createdAt)) : "unknown"; 1159 + const cardClass = isOP ? "post-card post-card--op" : "post-card post-card--reply"; 1160 + return ( 1161 + <div class={cardClass} id={`post-${postNumber}`}> 1162 + <div class="post-card__header"> 1163 + <span class="post-card__number">#{postNumber}</span> 1164 + <span class="post-card__author">{handle}</span> 1165 + <span class="post-card__date">{date}</span> 1166 + </div> 1167 + <div class="post-card__body" style="white-space: pre-wrap"> 1168 + {post.text} 1169 + </div> 1170 + {(modPerms.canHide || modPerms.canBan) && ( 1171 + <div class="post-card__mod-actions"> 1172 + {modPerms.canHide && ( 1173 + <button 1174 + class="mod-btn mod-btn--hide" 1175 + type="button" 1176 + onclick={`openModDialog('hide','${post.id}')`} 1177 + > 1178 + Hide 1179 + </button> 1180 + )} 1181 + {modPerms.canBan && post.author?.did && ( 1182 + <button 1183 + class="mod-btn mod-btn--ban" 1184 + type="button" 1185 + onclick={`openModDialog('ban','${post.author.did}')`} 1186 + > 1187 + Ban user 1188 + </button> 1189 + )} 1190 + </div> 1191 + )} 1192 + </div> 1193 + ); 1194 + } 1195 + ``` 1196 + 1197 + **3. Add the `MOD_DIALOG_SCRIPT` constant** (after `REPLY_CHAR_COUNTER_SCRIPT`): 1198 + ```typescript 1199 + const MOD_DIALOG_SCRIPT = ` 1200 + var MOD_TITLES = { 1201 + lock: 'Lock Topic', unlock: 'Unlock Topic', 1202 + hide: 'Hide Post', unhide: 'Unhide Post', 1203 + ban: 'Ban User', unban: 'Unban User' 1204 + }; 1205 + function openModDialog(action, id) { 1206 + document.getElementById('mod-dialog-action').value = action; 1207 + document.getElementById('mod-dialog-id').value = id; 1208 + document.getElementById('mod-dialog-title').textContent = MOD_TITLES[action] || 'Confirm'; 1209 + document.getElementById('mod-dialog-error').innerHTML = ''; 1210 + document.getElementById('mod-reason').value = ''; 1211 + document.getElementById('mod-dialog').showModal(); 1212 + } 1213 + `; 1214 + ``` 1215 + 1216 + **4. Add `ModDialog` component**: 1217 + ```typescript 1218 + function ModDialog() { 1219 + return ( 1220 + <dialog id="mod-dialog" class="mod-dialog"> 1221 + <h2 id="mod-dialog-title" class="mod-dialog__title">Confirm Action</h2> 1222 + <form 1223 + hx-post="/mod/action" 1224 + hx-target="#mod-dialog-error" 1225 + hx-swap="innerHTML" 1226 + hx-disabled-elt="[type=submit]" 1227 + > 1228 + <input type="hidden" id="mod-dialog-action" name="action" value="" /> 1229 + <input type="hidden" id="mod-dialog-id" name="id" value="" /> 1230 + <div class="form-group"> 1231 + <label for="mod-reason">Reason</label> 1232 + <textarea 1233 + id="mod-reason" 1234 + name="reason" 1235 + rows={3} 1236 + placeholder="Reason for this action…" 1237 + /> 1238 + </div> 1239 + <div id="mod-dialog-error" /> 1240 + <div class="form-actions"> 1241 + <button type="submit" class="btn btn-danger"> 1242 + Confirm 1243 + </button> 1244 + <button 1245 + type="button" 1246 + class="btn btn-secondary" 1247 + onclick="document.getElementById('mod-dialog').close()" 1248 + > 1249 + Cancel 1250 + </button> 1251 + </div> 1252 + </form> 1253 + </dialog> 1254 + ); 1255 + } 1256 + ``` 1257 + 1258 + **5. Update the full page handler** — change `getSession` to `getSessionWithPermissions`: 1259 + ```typescript 1260 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1261 + ``` 1262 + 1263 + **6. Compute mod permission flags** (after fetching `auth`): 1264 + ```typescript 1265 + const modPerms = { 1266 + canHide: canModeratePosts(auth), 1267 + canBan: canBanUsers(auth), 1268 + canLock: canLockTopics(auth), 1269 + }; 1270 + const hasAnyModPerm = modPerms.canHide || modPerms.canBan || modPerms.canLock; 1271 + ``` 1272 + 1273 + **7. Add lock button** below the `PageHeader` in the JSX return: 1274 + ```tsx 1275 + {modPerms.canLock && ( 1276 + <div class="topic-mod-controls"> 1277 + <button 1278 + class={`mod-btn ${topicData.locked ? "mod-btn--unlock" : "mod-btn--lock"}`} 1279 + type="button" 1280 + onclick={`openModDialog('${topicData.locked ? "unlock" : "lock"}','${topicId}')`} 1281 + > 1282 + {topicData.locked ? "Unlock Topic" : "Lock Topic"} 1283 + </button> 1284 + </div> 1285 + )} 1286 + ``` 1287 + 1288 + **8. Update `PostCard` calls** to pass `modPerms`: 1289 + ```tsx 1290 + <PostCard post={topicData.post} postNumber={1} isOP={true} modPerms={modPerms} /> 1291 + ``` 1292 + and in `ReplyFragment`: 1293 + ```tsx 1294 + {replies.map((reply, i) => ( 1295 + <PostCard key={reply.id} post={reply} postNumber={offset + i + 2} modPerms={modPerms} /> 1296 + ))} 1297 + ``` 1298 + `ReplyFragment` needs a new `modPerms` prop too: 1299 + ```typescript 1300 + function ReplyFragment({ 1301 + topicId, replies, total, offset, modPerms, 1302 + }: { 1303 + topicId: string; 1304 + replies: PostResponse[]; 1305 + total: number; 1306 + offset: number; 1307 + modPerms: { canHide: boolean; canBan: boolean }; 1308 + }) { ... } 1309 + ``` 1310 + 1311 + **9. Add `ModDialog` and script** at the bottom of the page's JSX (before `</BaseLayout>`): 1312 + ```tsx 1313 + {hasAnyModPerm && ( 1314 + <> 1315 + <ModDialog /> 1316 + <script dangerouslySetInnerHTML={{ __html: MOD_DIALOG_SCRIPT }} /> 1317 + </> 1318 + )} 1319 + ``` 1320 + 1321 + **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`. 1322 + 1323 + ### Step 5: Run tests to verify they pass 1324 + 1325 + ```bash 1326 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1327 + pnpm --filter @atbb/web test src/routes/__tests__/topics.test.tsx 1328 + ``` 1329 + Expected: all tests PASS (including both updated existing tests and new mod button tests) 1330 + 1331 + ### Step 6: Run the full web test suite to catch any remaining breakage 1332 + 1333 + ```bash 1334 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1335 + pnpm --filter @atbb/web test 1336 + ``` 1337 + Expected: all tests PASS 1338 + 1339 + ### Step 7: Commit 1340 + 1341 + ```bash 1342 + git add apps/web/src/routes/topics.tsx apps/web/src/routes/__tests__/topics.test.tsx 1343 + git commit -m "feat(web): add mod action buttons and dialog to topic view (ATB-24)" 1344 + ``` 1345 + 1346 + --- 1347 + 1348 + ## Task 5: CSS for Mod UI Elements 1349 + 1350 + Add styles for the mod dialog, mod buttons, and the mod actions row on post cards. 1351 + 1352 + **Files:** 1353 + - Modify: `apps/web/public/static/css/theme.css` 1354 + 1355 + ### Step 1: Add CSS at the end of `theme.css` 1356 + 1357 + ```css 1358 + /* ─── Moderation UI ──────────────────────────────────────────────────────── */ 1359 + 1360 + .post-card__mod-actions { 1361 + display: flex; 1362 + gap: var(--space-2); 1363 + margin-top: var(--space-2); 1364 + padding-top: var(--space-2); 1365 + border-top: 1px solid var(--color-border); 1366 + } 1367 + 1368 + .mod-btn { 1369 + font-size: 0.75rem; 1370 + padding: 0.25rem 0.6rem; 1371 + border: 2px solid currentColor; 1372 + border-radius: 0; 1373 + cursor: pointer; 1374 + background: transparent; 1375 + font-family: inherit; 1376 + font-weight: 700; 1377 + text-transform: uppercase; 1378 + letter-spacing: 0.05em; 1379 + } 1380 + 1381 + .mod-btn--hide, 1382 + .mod-btn--lock { 1383 + color: var(--color-danger, #d00); 1384 + } 1385 + 1386 + .mod-btn--hide:hover, 1387 + .mod-btn--lock:hover { 1388 + background: var(--color-danger, #d00); 1389 + color: #fff; 1390 + } 1391 + 1392 + .mod-btn--unhide, 1393 + .mod-btn--unlock, 1394 + .mod-btn--ban { 1395 + color: var(--color-text-muted, #666); 1396 + } 1397 + 1398 + .mod-btn--unhide:hover, 1399 + .mod-btn--unlock:hover, 1400 + .mod-btn--ban:hover { 1401 + background: var(--color-text-muted, #666); 1402 + color: #fff; 1403 + } 1404 + 1405 + .topic-mod-controls { 1406 + margin-bottom: var(--space-4); 1407 + } 1408 + 1409 + .mod-dialog { 1410 + border: 3px solid var(--color-border); 1411 + border-radius: 0; 1412 + padding: var(--space-6); 1413 + max-width: 480px; 1414 + width: 90vw; 1415 + box-shadow: 6px 6px 0 var(--color-shadow); 1416 + background: var(--color-bg); 1417 + } 1418 + 1419 + .mod-dialog::backdrop { 1420 + background: rgba(0, 0, 0, 0.5); 1421 + } 1422 + 1423 + .mod-dialog__title { 1424 + margin-top: 0; 1425 + margin-bottom: var(--space-4); 1426 + font-size: 1.25rem; 1427 + } 1428 + ``` 1429 + 1430 + ### Step 2: Visual verification 1431 + 1432 + Run `pnpm dev` and navigate to a topic page while logged in as a mod. Verify: 1433 + - Lock button appears below the page title 1434 + - Post cards show "Hide" and "Ban user" buttons 1435 + - Clicking a button opens the dialog 1436 + - Dialog closes on Cancel 1437 + - CSS looks consistent with the neobrutal theme 1438 + 1439 + ### Step 3: Commit 1440 + 1441 + ```bash 1442 + git add apps/web/public/static/css/theme.css 1443 + git commit -m "style(web): add CSS for mod dialog and mod action buttons (ATB-24)" 1444 + ``` 1445 + 1446 + --- 1447 + 1448 + ## Task 6: Full Test Suite + Final Verification 1449 + 1450 + Run the complete test suite and verify the build succeeds. 1451 + 1452 + ### Step 1: Run all tests 1453 + 1454 + ```bash 1455 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm test 1456 + ``` 1457 + Expected: all tests PASS across all packages 1458 + 1459 + ### Step 2: Run full build 1460 + 1461 + ```bash 1462 + PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm build 1463 + ``` 1464 + Expected: BUILD successful 1465 + 1466 + ### Step 3: Update Linear issue 1467 + 1468 + Mark ATB-24 as Done in Linear. Add a comment: 1469 + 1470 + > 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. 1471 + 1472 + ### Step 4: Update plan document 1473 + 1474 + Mark the following in `docs/atproto-forum-plan.md`: 1475 + ``` 1476 + [x] Admin UI: ban user, lock topic, hide post (Phase 3 carryover) 1477 + [x] Mod action indicators (locked topic banner — already existed; mod buttons via permissions) 1478 + ``` 1479 + 1480 + ### Step 5: Final commit if any docs updated 1481 + 1482 + ```bash 1483 + git add docs/atproto-forum-plan.md 1484 + git commit -m "docs: mark ATB-24 admin moderation UI complete" 1485 + ``` 1486 + 1487 + --- 1488 + 1489 + ## Notes on the `makeReply` helper 1490 + 1491 + The `topics.test.tsx` file uses a `makeReply` function to create reply objects. If it doesn't exist yet, add it alongside `makeTopicResponse`: 1492 + 1493 + ```typescript 1494 + function makeReply(overrides: Record<string, unknown> = {}) { 1495 + return { 1496 + id: "2", 1497 + did: "did:plc:replier", 1498 + rkey: "replyrkey1", 1499 + text: "A reply", 1500 + forumUri: null, 1501 + boardUri: null, 1502 + boardId: "42", 1503 + parentPostId: "1", 1504 + createdAt: "2025-01-02T00:00:00.000Z", 1505 + author: { did: "did:plc:replier", handle: "replier.bsky.social" }, 1506 + ...overrides, 1507 + }; 1508 + } 1509 + ``` 1510 + 1511 + ## Devenv PATH reminder 1512 + 1513 + Replace `/path/to/.devenv/profile/bin` with the actual path from memory: 1514 + ``` 1515 + /Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin 1516 + ``` 1517 + 1518 + Full example: 1519 + ```bash 1520 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1521 + pnpm --filter @atbb/web test 1522 + ```