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 panel member management page (ATB-43) (#73)

* feat(web): add canManageRoles session helper (ATB-43)

* feat(appview): include uri in GET /api/admin/roles response (ATB-43)

Add rkey and did fields to the roles DB query, then construct the AT URI
(at://<did>/space.atbb.forum.role/<rkey>) in the response map so the
admin members page dropdown can submit a valid roleUri.

* style(web): add admin member table CSS classes (ATB-43)

* feat(web): add GET /admin/members page and POST proxy route (ATB-43)

* fix(web): add manageRoles permission gate to POST proxy route (ATB-43)

* docs: mark ATB-42 and ATB-43 complete in project plan

* docs: add ATB-43 implementation plan

* fix(web): address PR review feedback on admin members page (ATB-43)

* fix(web): use canManageRoles(auth) instead of hardcoded false in rolesJson error path

authored by

Malpercio and committed by
GitHub
53a5e8f0 93acac75

+2314 -6
+26
apps/appview/src/routes/__tests__/admin.test.ts
··· 451 451 const data = await res.json(); 452 452 expect(data.roles).toEqual([]); 453 453 }); 454 + 455 + it("includes uri field constructed from did and rkey", async () => { 456 + // Seed a role matching the pattern used in this describe block 457 + await ctx.db.insert(roles).values({ 458 + did: ctx.config.forumDid, 459 + rkey: "moderator", 460 + cid: "bafymoderator", 461 + name: "Moderator", 462 + description: "Moderator", 463 + priority: 20, 464 + createdAt: new Date(), 465 + indexedAt: new Date(), 466 + }); 467 + 468 + const res = await app.request("/api/admin/roles"); 469 + 470 + expect(res.status).toBe(200); 471 + const data = await res.json() as { roles: Array<{ name: string; uri: string; id: string }> }; 472 + expect(data.roles.length).toBeGreaterThan(0); 473 + // Every role should have a uri field 474 + for (const role of data.roles) { 475 + expect(role.uri).toBeDefined(); 476 + expect(role.uri).toMatch(/^at:\/\//); 477 + expect(role.uri).toContain("/space.atbb.forum.role/"); 478 + } 479 + }); 454 480 }); 455 481 456 482 describe.sequential("GET /api/admin/members", () => {
+3
apps/appview/src/routes/admin.ts
··· 163 163 name: roles.name, 164 164 description: roles.description, 165 165 priority: roles.priority, 166 + rkey: roles.rkey, 167 + did: roles.did, 166 168 }) 167 169 .from(roles) 168 170 .where(eq(roles.did, ctx.config.forumDid)) ··· 180 182 description: role.description, 181 183 permissions: perms.map((p) => p.permission), 182 184 priority: role.priority, 185 + uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`, 183 186 }; 184 187 }) 185 188 );
+53
apps/web/public/static/css/theme.css
··· 946 946 color: var(--color-text-muted); 947 947 font-size: var(--font-size-sm); 948 948 } 949 + 950 + /* ─── Admin Member Table ─────────────────────────────────────────────────── */ 951 + 952 + .admin-member-table { 953 + width: 100%; 954 + border-collapse: collapse; 955 + margin-top: var(--space-md); 956 + } 957 + 958 + .admin-member-table th { 959 + text-align: left; 960 + padding: var(--space-sm) var(--space-md); 961 + border-bottom: calc(var(--border-width) * 2) solid var(--color-border); 962 + font-weight: var(--font-weight-bold); 963 + font-size: var(--font-size-sm); 964 + color: var(--color-text-muted); 965 + text-transform: uppercase; 966 + letter-spacing: 0.05em; 967 + } 968 + 969 + .admin-member-table td { 970 + padding: var(--space-sm) var(--space-md); 971 + border-bottom: var(--border-width) solid var(--color-border); 972 + vertical-align: middle; 973 + } 974 + 975 + .admin-member-table tbody tr:last-child td { 976 + border-bottom: none; 977 + } 978 + 979 + .role-badge { 980 + display: inline-block; 981 + padding: var(--space-xs) var(--space-sm); 982 + border: var(--border-width) solid var(--color-border); 983 + font-size: var(--font-size-sm); 984 + font-weight: var(--font-weight-bold); 985 + background-color: var(--color-surface); 986 + } 987 + 988 + .member-row__assign-form { 989 + display: flex; 990 + align-items: center; 991 + gap: var(--space-sm); 992 + flex-wrap: wrap; 993 + } 994 + 995 + .member-row__error { 996 + display: block; 997 + color: var(--color-danger); 998 + font-size: var(--font-size-sm); 999 + font-weight: var(--font-weight-bold); 1000 + margin-top: var(--space-xs); 1001 + }
+42 -1
apps/web/src/lib/__tests__/session.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 - import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog } from "../session.js"; 2 + import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog, canManageRoles } from "../session.js"; 3 + import type { WebSessionWithPermissions } from "../session.js"; 3 4 import { logger } from "../logger.js"; 4 5 5 6 vi.mock("../logger.js", () => ({ ··· 402 403 it("returns false for user with only an unrelated permission", () => 403 404 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.someOtherThing"))).toBe(false)); 404 405 }); 406 + 407 + describe("canManageRoles", () => { 408 + it("returns false for unauthenticated session", () => { 409 + const auth: WebSessionWithPermissions = { 410 + authenticated: false, 411 + permissions: new Set(), 412 + }; 413 + expect(canManageRoles(auth)).toBe(false); 414 + }); 415 + 416 + it("returns false when authenticated but missing manageRoles", () => { 417 + const auth: WebSessionWithPermissions = { 418 + authenticated: true, 419 + did: "did:plc:x", 420 + handle: "x.bsky.social", 421 + permissions: new Set(["space.atbb.permission.manageMembers"]), 422 + }; 423 + expect(canManageRoles(auth)).toBe(false); 424 + }); 425 + 426 + it("returns true with manageRoles permission", () => { 427 + const auth: WebSessionWithPermissions = { 428 + authenticated: true, 429 + did: "did:plc:x", 430 + handle: "x.bsky.social", 431 + permissions: new Set(["space.atbb.permission.manageRoles"]), 432 + }; 433 + expect(canManageRoles(auth)).toBe(true); 434 + }); 435 + 436 + it("returns true with wildcard (*) permission", () => { 437 + const auth: WebSessionWithPermissions = { 438 + authenticated: true, 439 + did: "did:plc:x", 440 + handle: "x.bsky.social", 441 + permissions: new Set(["*"]), 442 + }; 443 + expect(canManageRoles(auth)).toBe(true); 444 + }); 445 + });
+18 -1
apps/web/src/lib/session.ts
··· 148 148 ); 149 149 } 150 150 151 - /** Permission strings that constitute "any admin access". */ 151 + /** 152 + * Permission strings that constitute "any admin access". 153 + * Used to gate the /admin landing page. 154 + * 155 + * Note: `manageRoles` is intentionally absent. It is always exercised 156 + * through the /admin/members page, which requires `manageMembers` to access. 157 + * A user with only `manageRoles` would see the landing page but no nav cards, 158 + * which is confusing UX. `manageMembers` (already listed) covers that case. 159 + */ 152 160 const ADMIN_PERMISSIONS = [ 153 161 "space.atbb.permission.manageMembers", 154 162 "space.atbb.permission.manageCategories", ··· 197 205 auth.permissions.has("*")) 198 206 ); 199 207 } 208 + 209 + /** Returns true if the session grants permission to assign member roles. */ 210 + export function canManageRoles(auth: WebSessionWithPermissions): boolean { 211 + return ( 212 + auth.authenticated && 213 + (auth.permissions.has("space.atbb.permission.manageRoles") || 214 + auth.permissions.has("*")) 215 + ); 216 + }
+524
apps/web/src/routes/__tests__/admin.test.tsx
··· 195 195 expect(html).toContain("admin-nav-grid"); 196 196 }); 197 197 }); 198 + 199 + describe("createAdminRoutes — GET /admin/members", () => { 200 + beforeEach(() => { 201 + vi.stubGlobal("fetch", mockFetch); 202 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 203 + vi.resetModules(); 204 + }); 205 + 206 + afterEach(() => { 207 + vi.unstubAllGlobals(); 208 + vi.unstubAllEnvs(); 209 + mockFetch.mockReset(); 210 + }); 211 + 212 + function mockResponse(body: unknown, ok = true, status = 200) { 213 + return { 214 + ok, 215 + status, 216 + statusText: ok ? "OK" : "Error", 217 + json: () => Promise.resolve(body), 218 + }; 219 + } 220 + 221 + function setupSession(permissions: string[]) { 222 + mockFetch.mockResolvedValueOnce( 223 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 224 + ); 225 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 226 + } 227 + 228 + const SAMPLE_MEMBERS = [ 229 + { 230 + did: "did:plc:alice", 231 + handle: "alice.bsky.social", 232 + role: "Owner", 233 + roleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 234 + joinedAt: "2026-01-01T00:00:00.000Z", 235 + }, 236 + { 237 + did: "did:plc:bob", 238 + handle: "bob.bsky.social", 239 + role: "Member", 240 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 241 + joinedAt: "2026-01-05T00:00:00.000Z", 242 + }, 243 + ]; 244 + 245 + const SAMPLE_ROLES = [ 246 + { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 247 + { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 248 + ]; 249 + 250 + async function loadAdminRoutes() { 251 + const { createAdminRoutes } = await import("../admin.js"); 252 + return createAdminRoutes("http://localhost:3000"); 253 + } 254 + 255 + it("redirects unauthenticated users to /login", async () => { 256 + const routes = await loadAdminRoutes(); 257 + const res = await routes.request("/admin/members"); 258 + expect(res.status).toBe(302); 259 + expect(res.headers.get("location")).toBe("/login"); 260 + }); 261 + 262 + it("returns 403 for authenticated user without manageMembers", async () => { 263 + setupSession(["space.atbb.permission.manageCategories"]); 264 + const routes = await loadAdminRoutes(); 265 + const res = await routes.request("/admin/members", { 266 + headers: { cookie: "atbb_session=token" }, 267 + }); 268 + expect(res.status).toBe(403); 269 + }); 270 + 271 + it("renders member table with handles and role badges", async () => { 272 + setupSession(["space.atbb.permission.manageMembers"]); 273 + mockFetch.mockResolvedValueOnce( 274 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 275 + ); 276 + 277 + const routes = await loadAdminRoutes(); 278 + const res = await routes.request("/admin/members", { 279 + headers: { cookie: "atbb_session=token" }, 280 + }); 281 + 282 + expect(res.status).toBe(200); 283 + const html = await res.text(); 284 + expect(html).toContain("alice.bsky.social"); 285 + expect(html).toContain("bob.bsky.social"); 286 + expect(html).toContain("role-badge"); 287 + expect(html).toContain("Owner"); 288 + }); 289 + 290 + it("renders joined date for members", async () => { 291 + setupSession(["space.atbb.permission.manageMembers"]); 292 + mockFetch.mockResolvedValueOnce( 293 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 294 + ); 295 + 296 + const routes = await loadAdminRoutes(); 297 + const res = await routes.request("/admin/members", { 298 + headers: { cookie: "atbb_session=token" }, 299 + }); 300 + 301 + const html = await res.text(); 302 + expect(html).toContain("Jan"); 303 + expect(html).toContain("2026"); 304 + }); 305 + 306 + it("hides role assignment form when user lacks manageRoles", async () => { 307 + setupSession(["space.atbb.permission.manageMembers"]); 308 + mockFetch.mockResolvedValueOnce( 309 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 310 + ); 311 + 312 + const routes = await loadAdminRoutes(); 313 + const res = await routes.request("/admin/members", { 314 + headers: { cookie: "atbb_session=token" }, 315 + }); 316 + 317 + const html = await res.text(); 318 + expect(html).not.toContain("hx-post"); 319 + expect(html).not.toContain("Assign"); 320 + }); 321 + 322 + it("shows role assignment form when user has manageRoles", async () => { 323 + setupSession([ 324 + "space.atbb.permission.manageMembers", 325 + "space.atbb.permission.manageRoles", 326 + ]); 327 + mockFetch.mockResolvedValueOnce( 328 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 329 + ); 330 + mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES })); 331 + 332 + const routes = await loadAdminRoutes(); 333 + const res = await routes.request("/admin/members", { 334 + headers: { cookie: "atbb_session=token" }, 335 + }); 336 + 337 + const html = await res.text(); 338 + expect(html).toContain("hx-post"); 339 + expect(html).toContain("/admin/members/did:plc:bob/role"); 340 + expect(html).toContain("Assign"); 341 + }); 342 + 343 + it("shows empty state when no members", async () => { 344 + setupSession(["space.atbb.permission.manageMembers"]); 345 + mockFetch.mockResolvedValueOnce( 346 + mockResponse({ members: [], isTruncated: false }) 347 + ); 348 + 349 + const routes = await loadAdminRoutes(); 350 + const res = await routes.request("/admin/members", { 351 + headers: { cookie: "atbb_session=token" }, 352 + }); 353 + 354 + const html = await res.text(); 355 + expect(html).toContain("No members"); 356 + }); 357 + 358 + it("shows truncated indicator when isTruncated is true", async () => { 359 + setupSession(["space.atbb.permission.manageMembers"]); 360 + mockFetch.mockResolvedValueOnce( 361 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true }) 362 + ); 363 + 364 + const routes = await loadAdminRoutes(); 365 + const res = await routes.request("/admin/members", { 366 + headers: { cookie: "atbb_session=token" }, 367 + }); 368 + 369 + const html = await res.text(); 370 + expect(html).toContain("+"); 371 + }); 372 + 373 + it("returns 503 on AppView network error fetching members", async () => { 374 + setupSession(["space.atbb.permission.manageMembers"]); 375 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 376 + 377 + const routes = await loadAdminRoutes(); 378 + const res = await routes.request("/admin/members", { 379 + headers: { cookie: "atbb_session=token" }, 380 + }); 381 + 382 + expect(res.status).toBe(503); 383 + const html = await res.text(); 384 + expect(html).toContain("error-display"); 385 + }); 386 + 387 + it("returns 500 on AppView server error fetching members", async () => { 388 + setupSession(["space.atbb.permission.manageMembers"]); 389 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 390 + 391 + const routes = await loadAdminRoutes(); 392 + const res = await routes.request("/admin/members", { 393 + headers: { cookie: "atbb_session=token" }, 394 + }); 395 + 396 + expect(res.status).toBe(500); 397 + const html = await res.text(); 398 + expect(html).toContain("error-display"); 399 + }); 400 + 401 + it("redirects to /login when AppView members returns 401 (session expired)", async () => { 402 + setupSession(["space.atbb.permission.manageMembers"]); 403 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 404 + 405 + const routes = await loadAdminRoutes(); 406 + const res = await routes.request("/admin/members", { 407 + headers: { cookie: "atbb_session=token" }, 408 + }); 409 + 410 + expect(res.status).toBe(302); 411 + expect(res.headers.get("location")).toBe("/login"); 412 + }); 413 + 414 + it("renders page with empty role dropdown when roles fetch fails", async () => { 415 + setupSession([ 416 + "space.atbb.permission.manageMembers", 417 + "space.atbb.permission.manageRoles", 418 + ]); 419 + // members fetch succeeds 420 + mockFetch.mockResolvedValueOnce( 421 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 422 + ); 423 + // roles fetch fails 424 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 425 + 426 + const routes = await loadAdminRoutes(); 427 + const res = await routes.request("/admin/members", { 428 + headers: { cookie: "atbb_session=token" }, 429 + }); 430 + 431 + expect(res.status).toBe(200); 432 + const html = await res.text(); 433 + // Page still renders with member data 434 + expect(html).toContain("alice.bsky.social"); 435 + // Assign Role column still present (permission says yes, just no options) 436 + expect(html).toContain("hx-post"); 437 + }); 438 + }); 439 + 440 + describe("createAdminRoutes — POST /admin/members/:did/role", () => { 441 + beforeEach(() => { 442 + vi.stubGlobal("fetch", mockFetch); 443 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 444 + vi.resetModules(); 445 + }); 446 + 447 + afterEach(() => { 448 + vi.unstubAllGlobals(); 449 + vi.unstubAllEnvs(); 450 + mockFetch.mockReset(); 451 + }); 452 + 453 + function mockResponse(body: unknown, ok = true, status = 200) { 454 + return { 455 + ok, 456 + status, 457 + statusText: ok ? "OK" : "Error", 458 + json: () => Promise.resolve(body), 459 + }; 460 + } 461 + 462 + const SAMPLE_ROLES = [ 463 + { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 464 + { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 465 + ]; 466 + 467 + function makeFormBody(overrides: Partial<Record<string, string>> = {}): string { 468 + return new URLSearchParams({ 469 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 470 + handle: "bob.bsky.social", 471 + joinedAt: "2026-01-05T00:00:00.000Z", 472 + currentRole: "Owner", 473 + currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 474 + canManageRoles: "1", 475 + rolesJson: JSON.stringify(SAMPLE_ROLES), 476 + ...overrides, 477 + }).toString(); 478 + } 479 + 480 + async function loadAdminRoutes() { 481 + const { createAdminRoutes } = await import("../admin.js"); 482 + return createAdminRoutes("http://localhost:3000"); 483 + } 484 + 485 + function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) { 486 + mockFetch.mockResolvedValueOnce( 487 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 488 + ); 489 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 490 + } 491 + 492 + it("returns updated <tr> with new role name on success", async () => { 493 + setupPostSession(); 494 + mockFetch.mockResolvedValueOnce( 495 + mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 496 + ); 497 + 498 + const routes = await loadAdminRoutes(); 499 + const res = await routes.request("/admin/members/did:plc:bob/role", { 500 + method: "POST", 501 + headers: { 502 + "Content-Type": "application/x-www-form-urlencoded", 503 + cookie: "atbb_session=token", 504 + }, 505 + body: makeFormBody(), 506 + }); 507 + 508 + expect(res.status).toBe(200); 509 + const html = await res.text(); 510 + expect(html).toContain("<tr"); 511 + expect(html).toContain("Member"); 512 + expect(html).toContain("bob.bsky.social"); 513 + }); 514 + 515 + it("returns row with friendly error on AppView 403", async () => { 516 + setupPostSession(); 517 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); 518 + 519 + const routes = await loadAdminRoutes(); 520 + const res = await routes.request("/admin/members/did:plc:bob/role", { 521 + method: "POST", 522 + headers: { 523 + "Content-Type": "application/x-www-form-urlencoded", 524 + cookie: "atbb_session=token", 525 + }, 526 + body: makeFormBody(), 527 + }); 528 + 529 + expect(res.status).toBe(200); 530 + const html = await res.text(); 531 + expect(html).toContain("member-row__error"); 532 + expect(html).toContain("equal or higher authority"); 533 + expect(html).toContain("Owner"); // preserves current role 534 + }); 535 + 536 + it("returns row with friendly error on AppView 404", async () => { 537 + setupPostSession(); 538 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 539 + 540 + const routes = await loadAdminRoutes(); 541 + const res = await routes.request("/admin/members/did:plc:bob/role", { 542 + method: "POST", 543 + headers: { 544 + "Content-Type": "application/x-www-form-urlencoded", 545 + cookie: "atbb_session=token", 546 + }, 547 + body: makeFormBody(), 548 + }); 549 + 550 + expect(res.status).toBe(200); 551 + const html = await res.text(); 552 + expect(html).toContain("member-row__error"); 553 + expect(html).toContain("not found"); 554 + }); 555 + 556 + it("returns row with friendly error on AppView 500", async () => { 557 + setupPostSession(); 558 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 559 + 560 + const routes = await loadAdminRoutes(); 561 + const res = await routes.request("/admin/members/did:plc:bob/role", { 562 + method: "POST", 563 + headers: { 564 + "Content-Type": "application/x-www-form-urlencoded", 565 + cookie: "atbb_session=token", 566 + }, 567 + body: makeFormBody(), 568 + }); 569 + 570 + expect(res.status).toBe(200); 571 + const html = await res.text(); 572 + expect(html).toContain("member-row__error"); 573 + expect(html).toContain("Something went wrong"); 574 + }); 575 + 576 + it("returns row with unavailable message on network error", async () => { 577 + setupPostSession(); 578 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 579 + 580 + const routes = await loadAdminRoutes(); 581 + const res = await routes.request("/admin/members/did:plc:bob/role", { 582 + method: "POST", 583 + headers: { 584 + "Content-Type": "application/x-www-form-urlencoded", 585 + cookie: "atbb_session=token", 586 + }, 587 + body: makeFormBody(), 588 + }); 589 + 590 + expect(res.status).toBe(200); 591 + const html = await res.text(); 592 + expect(html).toContain("member-row__error"); 593 + expect(html).toContain("temporarily unavailable"); 594 + }); 595 + 596 + it("returns row with error and makes no AppView call when roleUri is missing", async () => { 597 + setupPostSession(); 598 + const routes = await loadAdminRoutes(); 599 + const res = await routes.request("/admin/members/did:plc:bob/role", { 600 + method: "POST", 601 + headers: { 602 + "Content-Type": "application/x-www-form-urlencoded", 603 + cookie: "atbb_session=token", 604 + }, 605 + body: makeFormBody({ roleUri: "" }), 606 + }); 607 + 608 + expect(res.status).toBe(200); 609 + const html = await res.text(); 610 + expect(html).toContain("member-row__error"); 611 + expect(mockFetch).not.toHaveBeenCalledWith( 612 + expect.stringContaining("/api/admin/members/did:plc:bob/role"), 613 + expect.anything() 614 + ); 615 + }); 616 + 617 + it("re-renders form with new role pre-selected in dropdown on success", async () => { 618 + setupPostSession(); 619 + mockFetch.mockResolvedValueOnce( 620 + mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 621 + ); 622 + 623 + const routes = await loadAdminRoutes(); 624 + const res = await routes.request("/admin/members/did:plc:bob/role", { 625 + method: "POST", 626 + headers: { 627 + "Content-Type": "application/x-www-form-urlencoded", 628 + cookie: "atbb_session=token", 629 + }, 630 + body: makeFormBody({ 631 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 632 + }), 633 + }); 634 + 635 + const html = await res.text(); 636 + // The newly assigned role URI should appear as the selected option value in the form 637 + expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member"); 638 + }); 639 + 640 + it("returns 401 error row for unauthenticated POST", async () => { 641 + // No session mock — no cookie 642 + const routes = await loadAdminRoutes(); 643 + const res = await routes.request("/admin/members/did:plc:bob/role", { 644 + method: "POST", 645 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 646 + body: makeFormBody(), 647 + }); 648 + 649 + expect(res.status).toBe(401); 650 + const html = await res.text(); 651 + expect(html).toContain("member-row__error"); 652 + expect(mockFetch).not.toHaveBeenCalledWith( 653 + expect.stringContaining("/api/admin/members/did:plc:bob/role"), 654 + expect.anything() 655 + ); 656 + }); 657 + 658 + it("returns 403 error row when user lacks manageRoles", async () => { 659 + setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles 660 + const routes = await loadAdminRoutes(); 661 + const res = await routes.request("/admin/members/did:plc:bob/role", { 662 + method: "POST", 663 + headers: { 664 + "Content-Type": "application/x-www-form-urlencoded", 665 + cookie: "atbb_session=token", 666 + }, 667 + body: makeFormBody(), 668 + }); 669 + 670 + expect(res.status).toBe(403); 671 + const html = await res.text(); 672 + expect(html).toContain("member-row__error"); 673 + // No AppView role assignment call should have been made 674 + expect(mockFetch).not.toHaveBeenCalledWith( 675 + expect.stringContaining("/api/admin/members/did:plc:bob/role"), 676 + expect.anything() 677 + ); 678 + }); 679 + 680 + it("returns row with session-expired error when AppView returns 401", async () => { 681 + setupPostSession(); 682 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 683 + 684 + const routes = await loadAdminRoutes(); 685 + const res = await routes.request("/admin/members/did:plc:bob/role", { 686 + method: "POST", 687 + headers: { 688 + "Content-Type": "application/x-www-form-urlencoded", 689 + cookie: "atbb_session=token", 690 + }, 691 + body: makeFormBody(), 692 + }); 693 + 694 + expect(res.status).toBe(200); 695 + const html = await res.text(); 696 + expect(html).toContain("member-row__error"); 697 + expect(html).toContain("session has expired"); 698 + }); 699 + 700 + it("returns error row with reload message when rolesJson is malformed", async () => { 701 + setupPostSession(); 702 + 703 + const routes = await loadAdminRoutes(); 704 + const res = await routes.request("/admin/members/did:plc:bob/role", { 705 + method: "POST", 706 + headers: { 707 + "Content-Type": "application/x-www-form-urlencoded", 708 + cookie: "atbb_session=token", 709 + }, 710 + body: makeFormBody({ rolesJson: "not-valid-json{{" }), 711 + }); 712 + 713 + expect(res.status).toBe(200); 714 + const html = await res.text(); 715 + expect(html).toContain("member-row__error"); 716 + expect(html).toContain("reload"); 717 + // No AppView call should have been made 718 + // (setupPostSession consumed 2 calls, then we check no more were made) 719 + expect(mockFetch).toHaveBeenCalledTimes(2); 720 + }); 721 + });
+408 -4
apps/web/src/routes/admin.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 - import { PageHeader, Card } from "../components/index.js"; 4 - import { getSessionWithPermissions, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog } from "../lib/session.js"; 3 + import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js"; 4 + import { 5 + getSessionWithPermissions, 6 + hasAnyAdminPermission, 7 + canManageMembers, 8 + canManageCategories, 9 + canViewModLog, 10 + canManageRoles, 11 + } from "../lib/session.js"; 12 + import { isProgrammingError } from "../lib/errors.js"; 13 + import { logger } from "../lib/logger.js"; 5 14 6 - // ─── Route ──────────────────────────────────────────────────────────────── 15 + // ─── Types ───────────────────────────────────────────────────────────────── 16 + 17 + interface MemberEntry { 18 + did: string; 19 + handle: string; 20 + role: string; 21 + roleUri: string | null; 22 + joinedAt: string | null; 23 + } 24 + 25 + interface RoleEntry { 26 + id: string; 27 + name: string; 28 + uri: string; 29 + priority: number; 30 + } 31 + 32 + // ─── Helpers ─────────────────────────────────────────────────────────────── 33 + 34 + function formatJoinedDate(isoString: string | null): string { 35 + if (!isoString) return "—"; 36 + const d = new Date(isoString); 37 + if (isNaN(d.getTime())) return "—"; 38 + return d.toLocaleDateString("en-US", { 39 + month: "short", 40 + day: "numeric", 41 + year: "numeric", 42 + }); 43 + } 44 + 45 + // ─── Components ──────────────────────────────────────────────────────────── 46 + 47 + function MemberRow({ 48 + member, 49 + roles, 50 + showRoleControls, 51 + errorMsg = null, 52 + }: { 53 + member: MemberEntry; 54 + roles: RoleEntry[]; 55 + showRoleControls: boolean; 56 + errorMsg?: string | null; 57 + }) { 58 + return ( 59 + <tr> 60 + <td>{member.handle}</td> 61 + <td> 62 + <span class="role-badge">{member.role}</span> 63 + </td> 64 + <td>{formatJoinedDate(member.joinedAt)}</td> 65 + {showRoleControls ? ( 66 + <td> 67 + <form 68 + hx-post={`/admin/members/${member.did}/role`} 69 + hx-target="closest tr" 70 + hx-swap="outerHTML" 71 + > 72 + <input type="hidden" name="handle" value={member.handle} /> 73 + <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} /> 74 + <input type="hidden" name="currentRole" value={member.role} /> 75 + <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} /> 76 + <input type="hidden" name="rolesJson" value={JSON.stringify(roles)} /> 77 + <div class="member-row__assign-form"> 78 + <label class="sr-only" for={`role-${member.did}`}> 79 + Assign role to {member.handle} 80 + </label> 81 + <select id={`role-${member.did}`} name="roleUri"> 82 + {roles.map((role) => ( 83 + <option value={role.uri} selected={member.roleUri === role.uri}> 84 + {role.name} 85 + </option> 86 + ))} 87 + </select> 88 + <button type="submit" class="btn btn-primary"> 89 + Assign 90 + </button> 91 + </div> 92 + {errorMsg && <span class="member-row__error">{errorMsg}</span>} 93 + </form> 94 + </td> 95 + ) : ( 96 + errorMsg && ( 97 + <td> 98 + <span class="member-row__error">{errorMsg}</span> 99 + </td> 100 + ) 101 + )} 102 + </tr> 103 + ); 104 + } 105 + 106 + // ─── Routes ──────────────────────────────────────────────────────────────── 7 107 8 108 export function createAdminRoutes(appviewUrl: string) { 9 - return new Hono().get("/admin", async (c) => { 109 + const app = new Hono(); 110 + 111 + // ── GET /admin ──────────────────────────────────────────────────────────── 112 + 113 + app.get("/admin", async (c) => { 10 114 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 11 115 12 116 if (!auth.authenticated) { ··· 62 166 </BaseLayout> 63 167 ); 64 168 }); 169 + 170 + // ── GET /admin/members ──────────────────────────────────────────────────── 171 + 172 + app.get("/admin/members", async (c) => { 173 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 174 + 175 + if (!auth.authenticated) { 176 + return c.redirect("/login"); 177 + } 178 + 179 + if (!canManageMembers(auth)) { 180 + return c.html( 181 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 182 + <PageHeader title="Members" /> 183 + <p>You don&apos;t have permission to manage members.</p> 184 + </BaseLayout>, 185 + 403 186 + ); 187 + } 188 + 189 + const cookie = c.req.header("cookie") ?? ""; 190 + const showRoleControls = canManageRoles(auth); 191 + 192 + let membersRes: Response; 193 + let rolesRes: Response | null = null; 194 + 195 + try { 196 + [membersRes, rolesRes] = await Promise.all([ 197 + fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }), 198 + showRoleControls 199 + ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } }) 200 + : Promise.resolve(null), 201 + ]); 202 + } catch (error) { 203 + if (isProgrammingError(error)) throw error; 204 + logger.error("Network error fetching members", { 205 + operation: "GET /admin/members", 206 + error: error instanceof Error ? error.message : String(error), 207 + }); 208 + return c.html( 209 + <BaseLayout title="Members — atBB Forum" auth={auth}> 210 + <PageHeader title="Members" /> 211 + <ErrorDisplay 212 + message="Unable to load members" 213 + detail="The forum is temporarily unavailable. Please try again." 214 + /> 215 + </BaseLayout>, 216 + 503 217 + ); 218 + } 219 + 220 + if (!membersRes.ok) { 221 + if (membersRes.status === 401) { 222 + return c.redirect("/login"); 223 + } 224 + logger.error("AppView returned error for members list", { 225 + operation: "GET /admin/members", 226 + status: membersRes.status, 227 + }); 228 + return c.html( 229 + <BaseLayout title="Members — atBB Forum" auth={auth}> 230 + <PageHeader title="Members" /> 231 + <ErrorDisplay 232 + message="Something went wrong" 233 + detail="Could not load member list. Please try again." 234 + /> 235 + </BaseLayout>, 236 + 500 237 + ); 238 + } 239 + 240 + const membersData = (await membersRes.json()) as { 241 + members: MemberEntry[]; 242 + isTruncated: boolean; 243 + }; 244 + let rolesData: { roles: RoleEntry[] } | null = null; 245 + if (rolesRes?.ok) { 246 + try { 247 + rolesData = (await rolesRes.json()) as { roles: RoleEntry[] }; 248 + } catch (error) { 249 + if (!(error instanceof SyntaxError)) throw error; 250 + logger.error("Malformed JSON from AppView roles response", { 251 + operation: "GET /admin/members", 252 + }); 253 + } 254 + } else if (rolesRes) { 255 + logger.error("AppView returned error for roles list", { 256 + operation: "GET /admin/members", 257 + status: rolesRes.status, 258 + }); 259 + } 260 + 261 + const members = membersData.members; 262 + const roles = rolesData?.roles ?? []; 263 + const isTruncated = membersData.isTruncated; 264 + const title = `Members (${members.length}${isTruncated ? "+" : ""})`; 265 + 266 + return c.html( 267 + <BaseLayout title="Members — atBB Forum" auth={auth}> 268 + <PageHeader title={title} /> 269 + {members.length === 0 ? ( 270 + <EmptyState message="No members yet" /> 271 + ) : ( 272 + <div class="card"> 273 + <table class="admin-member-table"> 274 + <thead> 275 + <tr> 276 + <th scope="col">Handle</th> 277 + <th scope="col">Role</th> 278 + <th scope="col">Joined</th> 279 + {showRoleControls && <th scope="col">Assign Role</th>} 280 + </tr> 281 + </thead> 282 + <tbody> 283 + {members.map((member) => ( 284 + <MemberRow 285 + member={member} 286 + roles={roles} 287 + showRoleControls={showRoleControls} 288 + /> 289 + ))} 290 + </tbody> 291 + </table> 292 + </div> 293 + )} 294 + </BaseLayout> 295 + ); 296 + }); 297 + 298 + // ── POST /admin/members/:did/role (HTMX proxy) ──────────────────────────── 299 + 300 + app.post("/admin/members/:did/role", async (c) => { 301 + // Permission gate — must come before body parsing 302 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 303 + if (!auth.authenticated) { 304 + return c.html( 305 + <tr> 306 + <td colspan={4}> 307 + <span class="member-row__error">You must be logged in to perform this action.</span> 308 + </td> 309 + </tr>, 310 + 401 311 + ); 312 + } 313 + if (!canManageRoles(auth)) { 314 + return c.html( 315 + <tr> 316 + <td colspan={4}> 317 + <span class="member-row__error">You don&apos;t have permission to assign roles.</span> 318 + </td> 319 + </tr>, 320 + 403 321 + ); 322 + } 323 + 324 + const targetDid = c.req.param("did"); 325 + const cookie = c.req.header("cookie") ?? ""; 326 + 327 + let body: Record<string, string | File>; 328 + try { 329 + body = await c.req.parseBody(); 330 + } catch (error) { 331 + if (isProgrammingError(error)) throw error; 332 + logger.error("Failed to parse form body", { 333 + operation: "POST /admin/members/:did/role", 334 + targetDid, 335 + }); 336 + return c.html( 337 + <tr> 338 + <td colspan={4}> 339 + <span class="member-row__error">Invalid form submission.</span> 340 + </td> 341 + </tr> 342 + ); 343 + } 344 + 345 + const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : ""; 346 + const handle = typeof body.handle === "string" ? body.handle : targetDid; 347 + const joinedAt = typeof body.joinedAt === "string" && body.joinedAt ? body.joinedAt : null; 348 + const currentRole = typeof body.currentRole === "string" ? body.currentRole : ""; 349 + const currentRoleUri = 350 + typeof body.currentRoleUri === "string" && body.currentRoleUri 351 + ? body.currentRoleUri 352 + : null; 353 + const showRoleControls = canManageRoles(auth); 354 + 355 + let roles: RoleEntry[] = []; 356 + try { 357 + const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]"; 358 + roles = JSON.parse(rolesJson) as RoleEntry[]; 359 + } catch (error) { 360 + if (!(error instanceof SyntaxError)) throw error; 361 + logger.warn("Malformed rolesJson in POST body", { 362 + operation: "POST /admin/members/:did/role", 363 + targetDid, 364 + }); 365 + return c.html( 366 + <MemberRow 367 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 368 + roles={[]} 369 + showRoleControls={canManageRoles(auth)} 370 + errorMsg="Role data was corrupted. Please reload the page." 371 + /> 372 + ); 373 + } 374 + 375 + if (!roleUri) { 376 + return c.html( 377 + <MemberRow 378 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 379 + roles={roles} 380 + showRoleControls={showRoleControls} 381 + errorMsg="Please select a role." 382 + /> 383 + ); 384 + } 385 + 386 + let appviewRes: Response; 387 + try { 388 + appviewRes = await fetch(`${appviewUrl}/api/admin/members/${targetDid}/role`, { 389 + method: "POST", 390 + headers: { 391 + "Content-Type": "application/json", 392 + Cookie: cookie, 393 + }, 394 + body: JSON.stringify({ roleUri }), 395 + }); 396 + } catch (error) { 397 + if (isProgrammingError(error)) throw error; 398 + logger.error("Network error proxying role assignment", { 399 + operation: "POST /admin/members/:did/role", 400 + targetDid, 401 + error: error instanceof Error ? error.message : String(error), 402 + }); 403 + return c.html( 404 + <MemberRow 405 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 406 + roles={roles} 407 + showRoleControls={showRoleControls} 408 + errorMsg="Forum temporarily unavailable. Please try again." 409 + /> 410 + ); 411 + } 412 + 413 + if (appviewRes.ok) { 414 + let data: { roleAssigned: string; targetDid: string }; 415 + try { 416 + data = (await appviewRes.json()) as { roleAssigned: string; targetDid: string }; 417 + } catch (error) { 418 + if (!(error instanceof SyntaxError)) throw error; 419 + logger.error("Malformed JSON from AppView role assignment response", { 420 + operation: "POST /admin/members/:did/role", 421 + targetDid, 422 + }); 423 + return c.html( 424 + <MemberRow 425 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 426 + roles={roles} 427 + showRoleControls={showRoleControls} 428 + errorMsg="Something went wrong. Please try again." 429 + /> 430 + ); 431 + } 432 + const newRoleName = data.roleAssigned || currentRole; 433 + return c.html( 434 + <MemberRow 435 + member={{ did: targetDid, handle, role: newRoleName, roleUri, joinedAt }} 436 + roles={roles} 437 + showRoleControls={showRoleControls} 438 + /> 439 + ); 440 + } 441 + 442 + let errorMsg: string; 443 + if (appviewRes.status === 403) { 444 + errorMsg = "Cannot assign a role with equal or higher authority than your own."; 445 + } else if (appviewRes.status === 404) { 446 + errorMsg = "Member or role not found."; 447 + } else if (appviewRes.status === 401) { 448 + errorMsg = "Your session has expired. Please log in again."; 449 + } else { 450 + logger.error("AppView returned error for role assignment", { 451 + operation: "POST /admin/members/:did/role", 452 + targetDid, 453 + status: appviewRes.status, 454 + }); 455 + errorMsg = "Something went wrong. Please try again."; 456 + } 457 + 458 + return c.html( 459 + <MemberRow 460 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 461 + roles={roles} 462 + showRoleControls={showRoleControls} 463 + errorMsg={errorMsg} 464 + /> 465 + ); 466 + }); 467 + 468 + return app; 65 469 }
+17
docs/atproto-forum-plan.md
··· 263 263 - ATB-30 | `apps/web/src/routes/login.tsx` — handle input form; `apps/web/src/routes/auth.tsx` — OAuth callback + session management; BaseLayout shows login/logout based on auth state 264 264 - [x] Admin panel: manage categories, view members, mod actions 265 265 - ATB-24 | Topic view mod buttons (lock/hide/ban) gated on permissions; `<dialog>` confirmation modal; `POST /mod/action` web proxy route; `getSessionWithPermissions()` for permission-aware rendering 266 + - [x] **ATB-42: Admin panel landing page and routing infrastructure** — **Complete:** 2026-02-28 267 + - `GET /admin` landing page with permission-gated nav cards (Members, Structure, Mod Log) 268 + - `hasAnyAdminPermission()` gate redirects non-admins; 403 for authenticated users without any admin permission 269 + - `canManageMembers()`, `canManageCategories()`, `canViewModLog()` helpers control which cards render 270 + - CSS: `.admin-nav-grid`, `.admin-nav-card`, `.admin-nav-card__icon/title/description`; neobrutal card style 271 + - Files: `apps/web/src/routes/admin.tsx`, `apps/web/public/static/css/theme.css` 272 + - [x] **ATB-43: Admin panel member management page (`/admin/members`)** — **Complete:** 2026-02-28 273 + - `GET /admin/members` renders full member table (handle, role badge, joined date) with `manageMembers` gate 274 + - Role assignment controls (`<select>` + Assign button) shown only when session has `manageRoles` 275 + - Parallel fetch: `GET /api/admin/members` + `GET /api/admin/roles` (roles skipped if no `manageRoles`) 276 + - HTMX `outerHTML` row swap: `POST /admin/members/:did/role` proxy → AppView → updated `<tr>` fragment 277 + - Hidden form inputs carry reconstruction data (handle, joinedAt, currentRole, currentRoleUri, rolesJson) — no extra API call on re-render 278 + - Web-layer permission gate on POST (auth + `manageRoles`) returns `<tr>` error fragment (not redirect) for HTMX compatibility 279 + - AppView: `GET /api/admin/roles` now includes `uri` field (`at://${did}/space.atbb.forum.role/${rkey}`) 280 + - `canManageRoles` session helper added to `apps/web/src/lib/session.ts` 281 + - 31 integration tests: auth guards, table render, role form visibility, HTMX success/error paths, 503/500 error display 282 + - Files: `apps/web/src/routes/admin.tsx`, `apps/web/src/lib/session.ts`, `apps/web/public/static/css/theme.css`, `apps/appview/src/routes/admin.ts` 266 283 - [x] Basic responsive design 267 284 - ATB-32 | Mobile-first responsive breakpoints (375px/768px/1024px), CSS-only hamburger nav via `<details>`/`<summary>`, token overrides for mobile, accessibility improvements (skip link, focus-visible, ARIA attributes, semantic HTML), 404 page, visual polish (transitions, hover states), SVG favicon 268 285 - [x] Show author handles in posts
+1223
docs/plans/2026-02-28-atb-43-admin-members-page.md
··· 1 + # ATB-43 Admin Members Page Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Add `GET /admin/members` page and `POST /admin/members/:did/role` proxy route to the web app, enabling admins to view all forum members and assign roles via inline HTMX row swaps. 6 + 7 + **Architecture:** The members page calls two AppView APIs in parallel (`/api/admin/members` and `/api/admin/roles`), renders a table of members, and conditionally shows role-assignment `<select>` + submit forms if the session user has `manageRoles`. Form submissions hit a web-server proxy route that forwards to the AppView and returns an updated `<tr>` fragment (HTMX `outerHTML` swap). The proxy needs no extra API calls because the form carries reconstruction data in hidden inputs (handle, joinedAt, current role, roles list). 8 + 9 + **Tech Stack:** Hono (web server + AppView), Hono JSX, HTMX, `typed-htmx`, Vitest (web tests use global `fetch` mock; AppView tests use `createTestContext()`) 10 + 11 + --- 12 + 13 + ## Task 1: Add `canManageRoles` session helper 14 + 15 + **Files:** 16 + - Modify: `apps/web/src/lib/session.ts` 17 + - Test: `apps/web/src/lib/__tests__/session.test.ts` 18 + 19 + **Step 1: Write the failing tests** 20 + 21 + Add the following `describe` block to `apps/web/src/lib/__tests__/session.test.ts`. Add `canManageRoles` to the existing import at the top of the file: 22 + 23 + ```typescript 24 + // In the import line at the top, add canManageRoles: 25 + import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog, canManageRoles } from "../session.js"; 26 + ``` 27 + 28 + Then add this block at the end of the file: 29 + 30 + ```typescript 31 + describe("canManageRoles", () => { 32 + it("returns false for unauthenticated session", () => { 33 + const auth: WebSessionWithPermissions = { 34 + authenticated: false, 35 + permissions: new Set(), 36 + }; 37 + expect(canManageRoles(auth)).toBe(false); 38 + }); 39 + 40 + it("returns false when authenticated but missing manageRoles", () => { 41 + const auth: WebSessionWithPermissions = { 42 + authenticated: true, 43 + did: "did:plc:x", 44 + handle: "x.bsky.social", 45 + permissions: new Set(["space.atbb.permission.manageMembers"]), 46 + }; 47 + expect(canManageRoles(auth)).toBe(false); 48 + }); 49 + 50 + it("returns true with manageRoles permission", () => { 51 + const auth: WebSessionWithPermissions = { 52 + authenticated: true, 53 + did: "did:plc:x", 54 + handle: "x.bsky.social", 55 + permissions: new Set(["space.atbb.permission.manageRoles"]), 56 + }; 57 + expect(canManageRoles(auth)).toBe(true); 58 + }); 59 + 60 + it("returns true with wildcard (*) permission", () => { 61 + const auth: WebSessionWithPermissions = { 62 + authenticated: true, 63 + did: "did:plc:x", 64 + handle: "x.bsky.social", 65 + permissions: new Set(["*"]), 66 + }; 67 + expect(canManageRoles(auth)).toBe(true); 68 + }); 69 + }); 70 + ``` 71 + 72 + Also add `WebSessionWithPermissions` to the import if not already imported as a type — it's in `../session.js` as an exported type. 73 + 74 + **Step 2: Run the tests to confirm they fail** 75 + 76 + ```sh 77 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 78 + pnpm --filter @atbb/web exec vitest run src/lib/__tests__/session.test.ts 79 + ``` 80 + 81 + Expected: 4 failures saying `canManageRoles is not a function`. 82 + 83 + **Step 3: Implement the helper** 84 + 85 + Add the following at the end of `apps/web/src/lib/session.ts`, after the `canViewModLog` function: 86 + 87 + ```typescript 88 + /** Returns true if the session grants permission to assign member roles. */ 89 + export function canManageRoles(auth: WebSessionWithPermissions): boolean { 90 + return ( 91 + auth.authenticated && 92 + (auth.permissions.has("space.atbb.permission.manageRoles") || 93 + auth.permissions.has("*")) 94 + ); 95 + } 96 + ``` 97 + 98 + **Step 4: Run tests to confirm they pass** 99 + 100 + ```sh 101 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 102 + pnpm --filter @atbb/web exec vitest run src/lib/__tests__/session.test.ts 103 + ``` 104 + 105 + Expected: all existing tests + 4 new `canManageRoles` tests pass. 106 + 107 + **Step 5: Commit** 108 + 109 + ```sh 110 + git add apps/web/src/lib/session.ts apps/web/src/lib/__tests__/session.test.ts 111 + git commit -m "feat(web): add canManageRoles session helper (ATB-43)" 112 + ``` 113 + 114 + --- 115 + 116 + ## Task 2: Add `uri` field to `GET /api/admin/roles` AppView response 117 + 118 + The members page dropdown needs role AT URIs (e.g. `at://did:plc:forum/space.atbb.forum.role/rkey`) to submit to `POST /api/admin/members/:did/role`. The current `/api/admin/roles` response omits the URI. This task adds it. 119 + 120 + **Files:** 121 + - Modify: `apps/appview/src/routes/admin.ts` 122 + - Test: `apps/appview/src/routes/__tests__/admin.test.ts` 123 + 124 + **Step 1: Write the failing test** 125 + 126 + In `apps/appview/src/routes/__tests__/admin.test.ts`, find the existing `describe.sequential("Admin Routes")` block and add this test inside it (near the other `/roles` tests if any, otherwise at an appropriate location): 127 + 128 + ```typescript 129 + describe("GET /api/admin/roles", () => { 130 + it("includes uri field in each role", async () => { 131 + // Seed a role 132 + const [role] = await ctx.db 133 + .insert(roles) 134 + .values({ 135 + did: ctx.config.forumDid, 136 + rkey: "owner", 137 + cid: "bafytest", 138 + name: "Owner", 139 + description: "Forum owner", 140 + priority: 0, 141 + indexedAt: new Date(), 142 + createdAt: new Date(), 143 + }) 144 + .returning(); 145 + 146 + mockUser = { did: "did:plc:test-admin" }; 147 + 148 + const res = await app.request("/api/admin/roles", { 149 + headers: { Cookie: "atbb_session=token" }, 150 + }); 151 + 152 + expect(res.status).toBe(200); 153 + const data = await res.json() as { roles: Array<{ name: string; uri: string }> }; 154 + expect(data.roles).toHaveLength(1); 155 + expect(data.roles[0].uri).toBe( 156 + `at://${ctx.config.forumDid}/space.atbb.forum.role/owner` 157 + ); 158 + }); 159 + }); 160 + ``` 161 + 162 + **Step 2: Run the test to confirm it fails** 163 + 164 + ```sh 165 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 166 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 167 + ``` 168 + 169 + Expected: failure — `data.roles[0].uri` is `undefined`. 170 + 171 + **Step 3: Implement the change** 172 + 173 + In `apps/appview/src/routes/admin.ts`, find the `GET /roles` handler. Locate the `select` query inside it: 174 + 175 + ```typescript 176 + const rolesList = await ctx.db 177 + .select({ 178 + id: roles.id, 179 + name: roles.name, 180 + description: roles.description, 181 + priority: roles.priority, 182 + }) 183 + ``` 184 + 185 + Add `rkey` and `did` to the select: 186 + 187 + ```typescript 188 + const rolesList = await ctx.db 189 + .select({ 190 + id: roles.id, 191 + name: roles.name, 192 + description: roles.description, 193 + priority: roles.priority, 194 + rkey: roles.rkey, 195 + did: roles.did, 196 + }) 197 + ``` 198 + 199 + Then in the `.map()` that builds `rolesWithPermissions`, add the `uri` field: 200 + 201 + ```typescript 202 + return { 203 + id: role.id.toString(), 204 + name: role.name, 205 + description: role.description, 206 + permissions: perms.map((p) => p.permission), 207 + priority: role.priority, 208 + uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`, 209 + }; 210 + ``` 211 + 212 + **Step 4: Run tests to confirm they pass** 213 + 214 + ```sh 215 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 216 + pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts 217 + ``` 218 + 219 + Expected: all existing tests + new `uri` test pass. 220 + 221 + **Step 5: Commit** 222 + 223 + ```sh 224 + git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts 225 + git commit -m "feat(appview): include uri in GET /api/admin/roles response (ATB-43)" 226 + ``` 227 + 228 + --- 229 + 230 + ## Task 3: Add admin member table CSS 231 + 232 + **Files:** 233 + - Modify: `apps/web/public/static/css/theme.css` 234 + 235 + **Step 1: Add CSS** 236 + 237 + At the very end of `apps/web/public/static/css/theme.css`, after the `.admin-nav-card__description` block, add: 238 + 239 + ```css 240 + /* ─── Admin Member Table ─────────────────────────────────────────────────── */ 241 + 242 + .admin-member-table { 243 + width: 100%; 244 + border-collapse: collapse; 245 + margin-top: var(--space-md); 246 + } 247 + 248 + .admin-member-table th { 249 + text-align: left; 250 + padding: var(--space-sm) var(--space-md); 251 + border-bottom: calc(var(--border-width) * 2) solid var(--color-border); 252 + font-weight: var(--font-weight-bold); 253 + font-size: var(--font-size-sm); 254 + color: var(--color-text-muted); 255 + text-transform: uppercase; 256 + letter-spacing: 0.05em; 257 + } 258 + 259 + .admin-member-table td { 260 + padding: var(--space-sm) var(--space-md); 261 + border-bottom: var(--border-width) solid var(--color-border); 262 + vertical-align: middle; 263 + } 264 + 265 + .admin-member-table tbody tr:last-child td { 266 + border-bottom: none; 267 + } 268 + 269 + .role-badge { 270 + display: inline-block; 271 + padding: var(--space-xs) var(--space-sm); 272 + border: var(--border-width) solid var(--color-border); 273 + font-size: var(--font-size-sm); 274 + font-weight: var(--font-weight-bold); 275 + background-color: var(--color-surface); 276 + } 277 + 278 + .member-row__assign-form { 279 + display: flex; 280 + align-items: center; 281 + gap: var(--space-sm); 282 + flex-wrap: wrap; 283 + } 284 + 285 + .member-row__error { 286 + display: block; 287 + color: var(--color-danger); 288 + font-size: var(--font-size-sm); 289 + font-weight: var(--font-weight-bold); 290 + margin-top: var(--space-xs); 291 + } 292 + ``` 293 + 294 + **Step 2: Commit** 295 + 296 + ```sh 297 + git add apps/web/public/static/css/theme.css 298 + git commit -m "style(web): add admin member table CSS classes (ATB-43)" 299 + ``` 300 + 301 + --- 302 + 303 + ## Task 4: Add `GET /admin/members` page route 304 + 305 + **Files:** 306 + - Modify: `apps/web/src/routes/admin.tsx` 307 + - Test: `apps/web/src/routes/__tests__/admin.test.tsx` 308 + 309 + ### Step 1: Write the failing tests 310 + 311 + Add a new `describe` block to `apps/web/src/routes/__tests__/admin.test.tsx`: 312 + 313 + ```typescript 314 + describe("createAdminRoutes — GET /admin/members", () => { 315 + beforeEach(() => { 316 + vi.stubGlobal("fetch", mockFetch); 317 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 318 + vi.resetModules(); 319 + }); 320 + 321 + afterEach(() => { 322 + vi.unstubAllGlobals(); 323 + vi.unstubAllEnvs(); 324 + mockFetch.mockReset(); 325 + }); 326 + 327 + function mockResponse(body: unknown, ok = true, status = 200) { 328 + return { 329 + ok, 330 + status, 331 + statusText: ok ? "OK" : "Error", 332 + json: () => Promise.resolve(body), 333 + }; 334 + } 335 + 336 + /** Sets up the two-fetch mock sequence for an authenticated session. */ 337 + function setupSession(permissions: string[]) { 338 + mockFetch.mockResolvedValueOnce( 339 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 340 + ); 341 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 342 + } 343 + 344 + const SAMPLE_MEMBERS = [ 345 + { did: "did:plc:alice", handle: "alice.bsky.social", role: "Owner", roleUri: "at://did:plc:forum/space.atbb.forum.role/owner", joinedAt: "2026-01-01T00:00:00.000Z" }, 346 + { did: "did:plc:bob", handle: "bob.bsky.social", role: "Member", roleUri: "at://did:plc:forum/space.atbb.forum.role/member", joinedAt: "2026-01-05T00:00:00.000Z" }, 347 + ]; 348 + 349 + const SAMPLE_ROLES = [ 350 + { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 351 + { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 352 + ]; 353 + 354 + async function loadAdminRoutes() { 355 + const { createAdminRoutes } = await import("../admin.js"); 356 + return createAdminRoutes("http://localhost:3000"); 357 + } 358 + 359 + // ── Auth guards ────────────────────────────────────────────────────────── 360 + 361 + it("redirects unauthenticated users to /login", async () => { 362 + const routes = await loadAdminRoutes(); 363 + const res = await routes.request("/admin/members"); 364 + expect(res.status).toBe(302); 365 + expect(res.headers.get("location")).toBe("/login"); 366 + }); 367 + 368 + it("returns 403 for authenticated user without manageMembers", async () => { 369 + setupSession(["space.atbb.permission.manageCategories"]); 370 + const routes = await loadAdminRoutes(); 371 + const res = await routes.request("/admin/members", { 372 + headers: { cookie: "atbb_session=token" }, 373 + }); 374 + expect(res.status).toBe(403); 375 + }); 376 + 377 + // ── Successful renders ─────────────────────────────────────────────────── 378 + 379 + it("renders member table with handles and role badges", async () => { 380 + setupSession(["space.atbb.permission.manageMembers"]); 381 + mockFetch.mockResolvedValueOnce( 382 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 383 + ); 384 + // No roles fetch — no manageRoles permission 385 + 386 + const routes = await loadAdminRoutes(); 387 + const res = await routes.request("/admin/members", { 388 + headers: { cookie: "atbb_session=token" }, 389 + }); 390 + 391 + expect(res.status).toBe(200); 392 + const html = await res.text(); 393 + expect(html).toContain("alice.bsky.social"); 394 + expect(html).toContain("bob.bsky.social"); 395 + expect(html).toContain("role-badge"); 396 + expect(html).toContain("Owner"); 397 + expect(html).toContain("Member"); 398 + }); 399 + 400 + it("renders joined date for members", async () => { 401 + setupSession(["space.atbb.permission.manageMembers"]); 402 + mockFetch.mockResolvedValueOnce( 403 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 404 + ); 405 + 406 + const routes = await loadAdminRoutes(); 407 + const res = await routes.request("/admin/members", { 408 + headers: { cookie: "atbb_session=token" }, 409 + }); 410 + 411 + const html = await res.text(); 412 + // Jan 1 2026 413 + expect(html).toContain("Jan"); 414 + expect(html).toContain("2026"); 415 + }); 416 + 417 + it("hides role assignment form when user lacks manageRoles", async () => { 418 + setupSession(["space.atbb.permission.manageMembers"]); 419 + mockFetch.mockResolvedValueOnce( 420 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 421 + ); 422 + 423 + const routes = await loadAdminRoutes(); 424 + const res = await routes.request("/admin/members", { 425 + headers: { cookie: "atbb_session=token" }, 426 + }); 427 + 428 + const html = await res.text(); 429 + expect(html).not.toContain("hx-post"); 430 + expect(html).not.toContain("Assign"); 431 + }); 432 + 433 + it("shows role assignment form when user has manageRoles", async () => { 434 + setupSession([ 435 + "space.atbb.permission.manageMembers", 436 + "space.atbb.permission.manageRoles", 437 + ]); 438 + // members fetch 439 + mockFetch.mockResolvedValueOnce( 440 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 441 + ); 442 + // roles fetch (parallel) 443 + mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES })); 444 + 445 + const routes = await loadAdminRoutes(); 446 + const res = await routes.request("/admin/members", { 447 + headers: { cookie: "atbb_session=token" }, 448 + }); 449 + 450 + const html = await res.text(); 451 + expect(html).toContain("hx-post"); 452 + expect(html).toContain("/admin/members/did:plc:bob/role"); 453 + expect(html).toContain("Assign"); 454 + }); 455 + 456 + it("shows empty state when no members", async () => { 457 + setupSession(["space.atbb.permission.manageMembers"]); 458 + mockFetch.mockResolvedValueOnce( 459 + mockResponse({ members: [], isTruncated: false }) 460 + ); 461 + 462 + const routes = await loadAdminRoutes(); 463 + const res = await routes.request("/admin/members", { 464 + headers: { cookie: "atbb_session=token" }, 465 + }); 466 + 467 + const html = await res.text(); 468 + expect(html).toContain("No members"); 469 + }); 470 + 471 + it("shows truncated indicator when isTruncated is true", async () => { 472 + setupSession(["space.atbb.permission.manageMembers"]); 473 + mockFetch.mockResolvedValueOnce( 474 + mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true }) 475 + ); 476 + 477 + const routes = await loadAdminRoutes(); 478 + const res = await routes.request("/admin/members", { 479 + headers: { cookie: "atbb_session=token" }, 480 + }); 481 + 482 + const html = await res.text(); 483 + // Member count should include "+" indicator 484 + expect(html).toContain("+"); 485 + }); 486 + 487 + // ── Error handling ─────────────────────────────────────────────────────── 488 + 489 + it("returns 503 on AppView network error fetching members", async () => { 490 + setupSession(["space.atbb.permission.manageMembers"]); 491 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 492 + 493 + const routes = await loadAdminRoutes(); 494 + const res = await routes.request("/admin/members", { 495 + headers: { cookie: "atbb_session=token" }, 496 + }); 497 + 498 + expect(res.status).toBe(503); 499 + const html = await res.text(); 500 + expect(html).toContain("error-display"); 501 + }); 502 + 503 + it("returns 500 on AppView server error fetching members", async () => { 504 + setupSession(["space.atbb.permission.manageMembers"]); 505 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 506 + 507 + const routes = await loadAdminRoutes(); 508 + const res = await routes.request("/admin/members", { 509 + headers: { cookie: "atbb_session=token" }, 510 + }); 511 + 512 + expect(res.status).toBe(500); 513 + const html = await res.text(); 514 + expect(html).toContain("error-display"); 515 + }); 516 + }); 517 + ``` 518 + 519 + **Step 2: Run tests to confirm they fail** 520 + 521 + ```sh 522 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 523 + pnpm --filter @atbb/web exec vitest run src/routes/__tests__/admin.test.tsx 524 + ``` 525 + 526 + Expected: all new tests fail — `GET /admin/members` returns 404. 527 + 528 + **Step 3: Implement the route** 529 + 530 + Replace the entire content of `apps/web/src/routes/admin.tsx` with the following: 531 + 532 + ```tsx 533 + import { Hono } from "hono"; 534 + import { BaseLayout } from "../layouts/base.js"; 535 + import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js"; 536 + import { 537 + getSessionWithPermissions, 538 + hasAnyAdminPermission, 539 + canManageMembers, 540 + canManageCategories, 541 + canViewModLog, 542 + canManageRoles, 543 + } from "../lib/session.js"; 544 + import { isProgrammingError } from "../lib/errors.js"; 545 + import { logger } from "../lib/logger.js"; 546 + 547 + // ─── Types ───────────────────────────────────────────────────────────────── 548 + 549 + interface MemberEntry { 550 + did: string; 551 + handle: string; 552 + role: string; 553 + roleUri: string | null; 554 + joinedAt: string | null; 555 + } 556 + 557 + interface RoleEntry { 558 + id: string; 559 + name: string; 560 + uri: string; 561 + priority: number; 562 + } 563 + 564 + // ─── Helpers ─────────────────────────────────────────────────────────────── 565 + 566 + function formatJoinedDate(isoString: string | null): string { 567 + if (!isoString) return "—"; 568 + const d = new Date(isoString); 569 + if (isNaN(d.getTime())) return "—"; 570 + return d.toLocaleDateString("en-US", { 571 + month: "short", 572 + day: "numeric", 573 + year: "numeric", 574 + }); 575 + } 576 + 577 + // ─── Components ──────────────────────────────────────────────────────────── 578 + 579 + function MemberRow({ 580 + member, 581 + roles, 582 + showRoleControls, 583 + errorMsg = null, 584 + }: { 585 + member: MemberEntry; 586 + roles: RoleEntry[]; 587 + showRoleControls: boolean; 588 + errorMsg?: string | null; 589 + }) { 590 + const colSpan = showRoleControls ? 4 : 3; 591 + return ( 592 + <tr> 593 + <td>{member.handle}</td> 594 + <td> 595 + <span class="role-badge">{member.role}</span> 596 + </td> 597 + <td>{formatJoinedDate(member.joinedAt)}</td> 598 + {showRoleControls && ( 599 + <td> 600 + <form 601 + hx-post={`/admin/members/${member.did}/role`} 602 + hx-target="closest tr" 603 + hx-swap="outerHTML" 604 + > 605 + <input type="hidden" name="handle" value={member.handle} /> 606 + <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} /> 607 + <input type="hidden" name="currentRole" value={member.role} /> 608 + <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} /> 609 + <input type="hidden" name="canManageRoles" value="1" /> 610 + <input 611 + type="hidden" 612 + name="rolesJson" 613 + value={JSON.stringify(roles)} 614 + /> 615 + <div class="member-row__assign-form"> 616 + <label class="sr-only" for={`role-${member.did}`}> 617 + Assign role to {member.handle} 618 + </label> 619 + <select id={`role-${member.did}`} name="roleUri"> 620 + {roles.map((role) => ( 621 + <option 622 + value={role.uri} 623 + selected={member.roleUri === role.uri} 624 + > 625 + {role.name} 626 + </option> 627 + ))} 628 + </select> 629 + <button type="submit" class="btn btn-primary"> 630 + Assign 631 + </button> 632 + </div> 633 + {errorMsg && ( 634 + <span class="member-row__error">{errorMsg}</span> 635 + )} 636 + </form> 637 + </td> 638 + )} 639 + </tr> 640 + ); 641 + } 642 + 643 + // ─── Routes ──────────────────────────────────────────────────────────────── 644 + 645 + export function createAdminRoutes(appviewUrl: string) { 646 + const app = new Hono(); 647 + 648 + // ── GET /admin ──────────────────────────────────────────────────────────── 649 + 650 + app.get("/admin", async (c) => { 651 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 652 + 653 + if (!auth.authenticated) { 654 + return c.redirect("/login"); 655 + } 656 + 657 + if (!hasAnyAdminPermission(auth)) { 658 + return c.html( 659 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 660 + <PageHeader title="Access Denied" /> 661 + <p>You don&apos;t have permission to access the admin panel.</p> 662 + </BaseLayout>, 663 + 403 664 + ); 665 + } 666 + 667 + const showMembers = canManageMembers(auth); 668 + const showStructure = canManageCategories(auth); 669 + const showModLog = canViewModLog(auth); 670 + 671 + return c.html( 672 + <BaseLayout title="Admin Panel — atBB Forum" auth={auth}> 673 + <PageHeader title="Admin Panel" /> 674 + <div class="admin-nav-grid"> 675 + {showMembers && ( 676 + <a href="/admin/members" class="admin-nav-card"> 677 + <Card> 678 + <p class="admin-nav-card__icon" aria-hidden="true">👥</p> 679 + <p class="admin-nav-card__title">Members</p> 680 + <p class="admin-nav-card__description">View and assign member roles</p> 681 + </Card> 682 + </a> 683 + )} 684 + {showStructure && ( 685 + <a href="/admin/structure" class="admin-nav-card"> 686 + <Card> 687 + <p class="admin-nav-card__icon" aria-hidden="true">📁</p> 688 + <p class="admin-nav-card__title">Structure</p> 689 + <p class="admin-nav-card__description">Manage categories and boards</p> 690 + </Card> 691 + </a> 692 + )} 693 + {showModLog && ( 694 + <a href="/admin/modlog" class="admin-nav-card"> 695 + <Card> 696 + <p class="admin-nav-card__icon" aria-hidden="true">📋</p> 697 + <p class="admin-nav-card__title">Mod Log</p> 698 + <p class="admin-nav-card__description">Audit trail of moderation actions</p> 699 + </Card> 700 + </a> 701 + )} 702 + </div> 703 + </BaseLayout> 704 + ); 705 + }); 706 + 707 + // ── GET /admin/members ──────────────────────────────────────────────────── 708 + 709 + app.get("/admin/members", async (c) => { 710 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 711 + 712 + if (!auth.authenticated) { 713 + return c.redirect("/login"); 714 + } 715 + 716 + if (!canManageMembers(auth)) { 717 + return c.html( 718 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 719 + <PageHeader title="Members" /> 720 + <p>You don&apos;t have permission to manage members.</p> 721 + </BaseLayout>, 722 + 403 723 + ); 724 + } 725 + 726 + const cookie = c.req.header("cookie") ?? ""; 727 + const showRoleControls = canManageRoles(auth); 728 + 729 + let membersRes: Response; 730 + let rolesRes: Response | null = null; 731 + 732 + try { 733 + [membersRes, rolesRes] = await Promise.all([ 734 + fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }), 735 + showRoleControls 736 + ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } }) 737 + : Promise.resolve(null), 738 + ]); 739 + } catch (error) { 740 + if (isProgrammingError(error)) throw error; 741 + logger.error("Network error fetching members", { 742 + operation: "GET /admin/members", 743 + error: error instanceof Error ? error.message : String(error), 744 + }); 745 + return c.html( 746 + <BaseLayout title="Members — atBB Forum" auth={auth}> 747 + <PageHeader title="Members" /> 748 + <ErrorDisplay 749 + message="Unable to load members" 750 + detail="The forum is temporarily unavailable. Please try again." 751 + /> 752 + </BaseLayout>, 753 + 503 754 + ); 755 + } 756 + 757 + if (!membersRes.ok) { 758 + logger.error("AppView returned error for members list", { 759 + operation: "GET /admin/members", 760 + status: membersRes.status, 761 + }); 762 + return c.html( 763 + <BaseLayout title="Members — atBB Forum" auth={auth}> 764 + <PageHeader title="Members" /> 765 + <ErrorDisplay 766 + message="Something went wrong" 767 + detail="Could not load member list. Please try again." 768 + /> 769 + </BaseLayout>, 770 + 500 771 + ); 772 + } 773 + 774 + const membersData = (await membersRes.json()) as { 775 + members: MemberEntry[]; 776 + isTruncated: boolean; 777 + }; 778 + const rolesData = 779 + rolesRes?.ok ? ((await rolesRes.json()) as { roles: RoleEntry[] }) : null; 780 + 781 + const members = membersData.members; 782 + const roles = rolesData?.roles ?? []; 783 + const isTruncated = membersData.isTruncated; 784 + 785 + const title = `Members (${members.length}${isTruncated ? "+" : ""})`; 786 + 787 + return c.html( 788 + <BaseLayout title="Members — atBB Forum" auth={auth}> 789 + <PageHeader title={title} /> 790 + {members.length === 0 ? ( 791 + <EmptyState message="No members yet" /> 792 + ) : ( 793 + <div class="card"> 794 + <table class="admin-member-table"> 795 + <thead> 796 + <tr> 797 + <th scope="col">Handle</th> 798 + <th scope="col">Role</th> 799 + <th scope="col">Joined</th> 800 + {showRoleControls && <th scope="col">Assign Role</th>} 801 + </tr> 802 + </thead> 803 + <tbody> 804 + {members.map((member) => ( 805 + <MemberRow 806 + member={member} 807 + roles={roles} 808 + showRoleControls={showRoleControls} 809 + /> 810 + ))} 811 + </tbody> 812 + </table> 813 + </div> 814 + )} 815 + </BaseLayout> 816 + ); 817 + }); 818 + 819 + // ── POST /admin/members/:did/role (HTMX proxy) ──────────────────────────── 820 + 821 + app.post("/admin/members/:did/role", async (c) => { 822 + const targetDid = c.req.param("did"); 823 + const cookie = c.req.header("cookie") ?? ""; 824 + 825 + // Parse form body 826 + let body: Record<string, string | File>; 827 + try { 828 + body = await c.req.parseBody(); 829 + } catch { 830 + return c.html( 831 + <tr> 832 + <td colspan="4"> 833 + <span class="member-row__error">Invalid form submission.</span> 834 + </td> 835 + </tr> 836 + ); 837 + } 838 + 839 + const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : ""; 840 + const handle = typeof body.handle === "string" ? body.handle : targetDid; 841 + const joinedAt = typeof body.joinedAt === "string" ? body.joinedAt : null; 842 + const currentRole = typeof body.currentRole === "string" ? body.currentRole : ""; 843 + const currentRoleUri = 844 + typeof body.currentRoleUri === "string" && body.currentRoleUri 845 + ? body.currentRoleUri 846 + : null; 847 + const showRoleControls = body.canManageRoles === "1"; 848 + 849 + let roles: RoleEntry[] = []; 850 + try { 851 + const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]"; 852 + roles = JSON.parse(rolesJson) as RoleEntry[]; 853 + } catch { 854 + // Roles stay empty — dropdown won't render but row will still show 855 + } 856 + 857 + if (!roleUri) { 858 + return c.html( 859 + <MemberRow 860 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 861 + roles={roles} 862 + showRoleControls={showRoleControls} 863 + errorMsg="Please select a role." 864 + /> 865 + ); 866 + } 867 + 868 + // Forward to AppView 869 + let appviewRes: Response; 870 + try { 871 + appviewRes = await fetch( 872 + `${appviewUrl}/api/admin/members/${targetDid}/role`, 873 + { 874 + method: "POST", 875 + headers: { 876 + "Content-Type": "application/json", 877 + Cookie: cookie, 878 + }, 879 + body: JSON.stringify({ roleUri }), 880 + } 881 + ); 882 + } catch (error) { 883 + if (isProgrammingError(error)) throw error; 884 + logger.error("Network error proxying role assignment", { 885 + operation: "POST /admin/members/:did/role", 886 + targetDid, 887 + error: error instanceof Error ? error.message : String(error), 888 + }); 889 + return c.html( 890 + <MemberRow 891 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 892 + roles={roles} 893 + showRoleControls={showRoleControls} 894 + errorMsg="Forum temporarily unavailable. Please try again." 895 + /> 896 + ); 897 + } 898 + 899 + if (appviewRes.ok) { 900 + const data = (await appviewRes.json()) as { 901 + roleAssigned: string; 902 + targetDid: string; 903 + }; 904 + const newRoleName = data.roleAssigned || currentRole; 905 + return c.html( 906 + <MemberRow 907 + member={{ did: targetDid, handle, role: newRoleName, roleUri, joinedAt }} 908 + roles={roles} 909 + showRoleControls={showRoleControls} 910 + /> 911 + ); 912 + } 913 + 914 + // Map AppView errors to user-friendly messages 915 + let errorMsg: string; 916 + if (appviewRes.status === 403) { 917 + errorMsg = "Cannot assign a role with equal or higher authority than your own."; 918 + } else if (appviewRes.status === 404) { 919 + errorMsg = "Member or role not found."; 920 + } else if (appviewRes.status === 401) { 921 + errorMsg = "Your session has expired. Please log in again."; 922 + } else { 923 + logger.error("AppView returned error for role assignment", { 924 + operation: "POST /admin/members/:did/role", 925 + targetDid, 926 + status: appviewRes.status, 927 + }); 928 + errorMsg = "Something went wrong. Please try again."; 929 + } 930 + 931 + return c.html( 932 + <MemberRow 933 + member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }} 934 + roles={roles} 935 + showRoleControls={showRoleControls} 936 + errorMsg={errorMsg} 937 + /> 938 + ); 939 + }); 940 + 941 + return app; 942 + } 943 + ``` 944 + 945 + **Step 4: Run tests to confirm they pass** 946 + 947 + ```sh 948 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 949 + pnpm --filter @atbb/web exec vitest run src/routes/__tests__/admin.test.tsx 950 + ``` 951 + 952 + Expected: all previous `GET /admin` tests still pass + all new `GET /admin/members` tests pass. 953 + 954 + **Step 5: Commit** 955 + 956 + ```sh 957 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx 958 + git commit -m "feat(web): add GET /admin/members page with role display (ATB-43)" 959 + ``` 960 + 961 + --- 962 + 963 + ## Task 5: Add proxy route tests and verify 964 + 965 + The proxy route is already implemented in Task 4's code. This task writes and runs tests for `POST /admin/members/:did/role` to verify error handling. 966 + 967 + **Files:** 968 + - Test: `apps/web/src/routes/__tests__/admin.test.tsx` 969 + 970 + **Step 1: Write the failing tests** 971 + 972 + Add another `describe` block to `apps/web/src/routes/__tests__/admin.test.tsx`: 973 + 974 + ```typescript 975 + describe("createAdminRoutes — POST /admin/members/:did/role", () => { 976 + beforeEach(() => { 977 + vi.stubGlobal("fetch", mockFetch); 978 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 979 + vi.resetModules(); 980 + }); 981 + 982 + afterEach(() => { 983 + vi.unstubAllGlobals(); 984 + vi.unstubAllEnvs(); 985 + mockFetch.mockReset(); 986 + }); 987 + 988 + function mockResponse(body: unknown, ok = true, status = 200) { 989 + return { 990 + ok, 991 + status, 992 + statusText: ok ? "OK" : "Error", 993 + json: () => Promise.resolve(body), 994 + }; 995 + } 996 + 997 + const SAMPLE_ROLES = [ 998 + { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 999 + { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 1000 + ]; 1001 + 1002 + function makeFormBody(overrides: Partial<Record<string, string>> = {}): string { 1003 + const params = new URLSearchParams({ 1004 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 1005 + handle: "bob.bsky.social", 1006 + joinedAt: "2026-01-05T00:00:00.000Z", 1007 + currentRole: "Owner", 1008 + currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 1009 + canManageRoles: "1", 1010 + rolesJson: JSON.stringify(SAMPLE_ROLES), 1011 + ...overrides, 1012 + }); 1013 + return params.toString(); 1014 + } 1015 + 1016 + async function loadAdminRoutes() { 1017 + const { createAdminRoutes } = await import("../admin.js"); 1018 + return createAdminRoutes("http://localhost:3000"); 1019 + } 1020 + 1021 + // ── Success ────────────────────────────────────────────────────────────── 1022 + 1023 + it("returns updated <tr> with new role name on success", async () => { 1024 + mockFetch.mockResolvedValueOnce( 1025 + mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 1026 + ); 1027 + 1028 + const routes = await loadAdminRoutes(); 1029 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1030 + method: "POST", 1031 + headers: { 1032 + "Content-Type": "application/x-www-form-urlencoded", 1033 + cookie: "atbb_session=token", 1034 + }, 1035 + body: makeFormBody(), 1036 + }); 1037 + 1038 + expect(res.status).toBe(200); 1039 + const html = await res.text(); 1040 + expect(html).toContain("<tr"); 1041 + expect(html).toContain("Member"); // new role badge 1042 + expect(html).toContain("bob.bsky.social"); // handle preserved 1043 + }); 1044 + 1045 + it("re-renders form with updated role selected on success", async () => { 1046 + mockFetch.mockResolvedValueOnce( 1047 + mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 1048 + ); 1049 + 1050 + const routes = await loadAdminRoutes(); 1051 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1052 + method: "POST", 1053 + headers: { 1054 + "Content-Type": "application/x-www-form-urlencoded", 1055 + cookie: "atbb_session=token", 1056 + }, 1057 + body: makeFormBody({ 1058 + roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 1059 + }), 1060 + }); 1061 + 1062 + const html = await res.text(); 1063 + // The new roleUri should be pre-selected in the dropdown 1064 + expect(html).toContain( 1065 + 'value="at://did:plc:forum/space.atbb.forum.role/member"' 1066 + ); 1067 + }); 1068 + 1069 + // ── AppView errors ─────────────────────────────────────────────────────── 1070 + 1071 + it("returns row with friendly error on AppView 403", async () => { 1072 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); 1073 + 1074 + const routes = await loadAdminRoutes(); 1075 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1076 + method: "POST", 1077 + headers: { 1078 + "Content-Type": "application/x-www-form-urlencoded", 1079 + cookie: "atbb_session=token", 1080 + }, 1081 + body: makeFormBody(), 1082 + }); 1083 + 1084 + expect(res.status).toBe(200); 1085 + const html = await res.text(); 1086 + expect(html).toContain("<tr"); 1087 + expect(html).toContain("member-row__error"); 1088 + expect(html).toContain("equal or higher authority"); 1089 + // Preserves current role in badge 1090 + expect(html).toContain("Owner"); 1091 + }); 1092 + 1093 + it("returns row with friendly error on AppView 404", async () => { 1094 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 1095 + 1096 + const routes = await loadAdminRoutes(); 1097 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1098 + method: "POST", 1099 + headers: { 1100 + "Content-Type": "application/x-www-form-urlencoded", 1101 + cookie: "atbb_session=token", 1102 + }, 1103 + body: makeFormBody(), 1104 + }); 1105 + 1106 + expect(res.status).toBe(200); 1107 + const html = await res.text(); 1108 + expect(html).toContain("member-row__error"); 1109 + expect(html).toContain("not found"); 1110 + }); 1111 + 1112 + it("returns row with friendly error on AppView 500", async () => { 1113 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 1114 + 1115 + const routes = await loadAdminRoutes(); 1116 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1117 + method: "POST", 1118 + headers: { 1119 + "Content-Type": "application/x-www-form-urlencoded", 1120 + cookie: "atbb_session=token", 1121 + }, 1122 + body: makeFormBody(), 1123 + }); 1124 + 1125 + expect(res.status).toBe(200); 1126 + const html = await res.text(); 1127 + expect(html).toContain("member-row__error"); 1128 + expect(html).toContain("Something went wrong"); 1129 + }); 1130 + 1131 + it("returns row with unavailable message on network error", async () => { 1132 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1133 + 1134 + const routes = await loadAdminRoutes(); 1135 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1136 + method: "POST", 1137 + headers: { 1138 + "Content-Type": "application/x-www-form-urlencoded", 1139 + cookie: "atbb_session=token", 1140 + }, 1141 + body: makeFormBody(), 1142 + }); 1143 + 1144 + expect(res.status).toBe(200); 1145 + const html = await res.text(); 1146 + expect(html).toContain("member-row__error"); 1147 + expect(html).toContain("temporarily unavailable"); 1148 + }); 1149 + 1150 + it("returns row with error when roleUri is missing", async () => { 1151 + const routes = await loadAdminRoutes(); 1152 + const res = await routes.request("/admin/members/did:plc:bob/role", { 1153 + method: "POST", 1154 + headers: { 1155 + "Content-Type": "application/x-www-form-urlencoded", 1156 + cookie: "atbb_session=token", 1157 + }, 1158 + body: makeFormBody({ roleUri: "" }), 1159 + }); 1160 + 1161 + expect(res.status).toBe(200); 1162 + const html = await res.text(); 1163 + expect(html).toContain("member-row__error"); 1164 + // No AppView call should have been made 1165 + expect(mockFetch).not.toHaveBeenCalled(); 1166 + }); 1167 + }); 1168 + ``` 1169 + 1170 + **Step 2: Run tests to confirm they fail** 1171 + 1172 + ```sh 1173 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1174 + pnpm --filter @atbb/web exec vitest run src/routes/__tests__/admin.test.tsx 1175 + ``` 1176 + 1177 + Expected: 8 new tests fail — the proxy route doesn't exist yet in your current test environment (though it's in the file from Task 4). If all 8 pass immediately, verify the implementation from Task 4 is in place. 1178 + 1179 + **Step 3: Run all tests to confirm nothing regressed** 1180 + 1181 + ```sh 1182 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1183 + pnpm --filter @atbb/web exec vitest run 1184 + ``` 1185 + 1186 + Expected: all web tests pass. 1187 + 1188 + **Step 4: Run AppView tests too** 1189 + 1190 + ```sh 1191 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1192 + pnpm --filter @atbb/appview exec vitest run 1193 + ``` 1194 + 1195 + Expected: all AppView tests pass. 1196 + 1197 + **Step 5: Commit** 1198 + 1199 + ```sh 1200 + git add apps/web/src/routes/__tests__/admin.test.tsx 1201 + git commit -m "test(web): add POST /admin/members/:did/role proxy tests (ATB-43)" 1202 + ``` 1203 + 1204 + --- 1205 + 1206 + ## Task 6: Final verification 1207 + 1208 + **Step 1: Run full test suite** 1209 + 1210 + ```sh 1211 + PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \ 1212 + pnpm test 1213 + ``` 1214 + 1215 + Expected: all tests in all packages pass. 1216 + 1217 + **Step 2: Update Linear issue** 1218 + 1219 + Update ATB-43 status to "In Review" and add a comment summarising what was implemented. 1220 + 1221 + **Step 3: Update project plan doc** 1222 + 1223 + Mark ATB-43 complete in `docs/atproto-forum-plan.md` if applicable.