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

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

+154 -11
+9 -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",
+80
apps/web/src/routes/__tests__/admin.test.tsx
··· 397 397 const html = await res.text(); 398 398 expect(html).toContain("error-display"); 399 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 + }); 400 438 }); 401 439 402 440 describe("createAdminRoutes — POST /admin/members/:did/role", () => { ··· 637 675 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 638 676 expect.anything() 639 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); 640 720 }); 641 721 });
+65 -10
apps/web/src/routes/admin.tsx
··· 62 62 <span class="role-badge">{member.role}</span> 63 63 </td> 64 64 <td>{formatJoinedDate(member.joinedAt)}</td> 65 - {showRoleControls && ( 65 + {showRoleControls ? ( 66 66 <td> 67 67 <form 68 68 hx-post={`/admin/members/${member.did}/role`} ··· 73 73 <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} /> 74 74 <input type="hidden" name="currentRole" value={member.role} /> 75 75 <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} /> 76 - <input type="hidden" name="canManageRoles" value="1" /> 77 76 <input type="hidden" name="rolesJson" value={JSON.stringify(roles)} /> 78 77 <div class="member-row__assign-form"> 79 78 <label class="sr-only" for={`role-${member.did}`}> ··· 93 92 {errorMsg && <span class="member-row__error">{errorMsg}</span>} 94 93 </form> 95 94 </td> 95 + ) : ( 96 + errorMsg && ( 97 + <td> 98 + <span class="member-row__error">{errorMsg}</span> 99 + </td> 100 + ) 96 101 )} 97 102 </tr> 98 103 ); ··· 213 218 } 214 219 215 220 if (!membersRes.ok) { 221 + if (membersRes.status === 401) { 222 + return c.redirect("/login"); 223 + } 216 224 logger.error("AppView returned error for members list", { 217 225 operation: "GET /admin/members", 218 226 status: membersRes.status, ··· 233 241 members: MemberEntry[]; 234 242 isTruncated: boolean; 235 243 }; 236 - const rolesData = rolesRes?.ok 237 - ? ((await rolesRes.json()) as { roles: RoleEntry[] }) 238 - : null; 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 + } 239 260 240 261 const members = membersData.members; 241 262 const roles = rolesData?.roles ?? []; ··· 306 327 let body: Record<string, string | File>; 307 328 try { 308 329 body = await c.req.parseBody(); 309 - } catch { 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 + }); 310 336 return c.html( 311 337 <tr> 312 338 <td colspan={4}> ··· 324 350 typeof body.currentRoleUri === "string" && body.currentRoleUri 325 351 ? body.currentRoleUri 326 352 : null; 327 - const showRoleControls = body.canManageRoles === "1"; 353 + const showRoleControls = canManageRoles(auth); 328 354 329 355 let roles: RoleEntry[] = []; 330 356 try { 331 357 const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]"; 332 358 roles = JSON.parse(rolesJson) as RoleEntry[]; 333 - } catch { 334 - // roles stays empty — row renders without dropdown 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={false} 370 + errorMsg="Role data was corrupted. Please reload the page." 371 + /> 372 + ); 335 373 } 336 374 337 375 if (!roleUri) { ··· 373 411 } 374 412 375 413 if (appviewRes.ok) { 376 - const data = (await appviewRes.json()) as { roleAssigned: string; targetDid: string }; 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 + } 377 432 const newRoleName = data.roleAssigned || currentRole; 378 433 return c.html( 379 434 <MemberRow