···148148 );
149149}
150150151151-/** Permission strings that constitute "any admin access". */
151151+/**
152152+ * Permission strings that constitute "any admin access".
153153+ * Used to gate the /admin landing page.
154154+ *
155155+ * Note: `manageRoles` is intentionally absent. It is always exercised
156156+ * through the /admin/members page, which requires `manageMembers` to access.
157157+ * A user with only `manageRoles` would see the landing page but no nav cards,
158158+ * which is confusing UX. `manageMembers` (already listed) covers that case.
159159+ */
152160const ADMIN_PERMISSIONS = [
153161 "space.atbb.permission.manageMembers",
154162 "space.atbb.permission.manageCategories",
+80
apps/web/src/routes/__tests__/admin.test.tsx
···397397 const html = await res.text();
398398 expect(html).toContain("error-display");
399399 });
400400+401401+ it("redirects to /login when AppView members returns 401 (session expired)", async () => {
402402+ setupSession(["space.atbb.permission.manageMembers"]);
403403+ mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
404404+405405+ const routes = await loadAdminRoutes();
406406+ const res = await routes.request("/admin/members", {
407407+ headers: { cookie: "atbb_session=token" },
408408+ });
409409+410410+ expect(res.status).toBe(302);
411411+ expect(res.headers.get("location")).toBe("/login");
412412+ });
413413+414414+ it("renders page with empty role dropdown when roles fetch fails", async () => {
415415+ setupSession([
416416+ "space.atbb.permission.manageMembers",
417417+ "space.atbb.permission.manageRoles",
418418+ ]);
419419+ // members fetch succeeds
420420+ mockFetch.mockResolvedValueOnce(
421421+ mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
422422+ );
423423+ // roles fetch fails
424424+ mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
425425+426426+ const routes = await loadAdminRoutes();
427427+ const res = await routes.request("/admin/members", {
428428+ headers: { cookie: "atbb_session=token" },
429429+ });
430430+431431+ expect(res.status).toBe(200);
432432+ const html = await res.text();
433433+ // Page still renders with member data
434434+ expect(html).toContain("alice.bsky.social");
435435+ // Assign Role column still present (permission says yes, just no options)
436436+ expect(html).toContain("hx-post");
437437+ });
400438});
401439402440describe("createAdminRoutes — POST /admin/members/:did/role", () => {
···637675 expect.stringContaining("/api/admin/members/did:plc:bob/role"),
638676 expect.anything()
639677 );
678678+ });
679679+680680+ it("returns row with session-expired error when AppView returns 401", async () => {
681681+ setupPostSession();
682682+ mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
683683+684684+ const routes = await loadAdminRoutes();
685685+ const res = await routes.request("/admin/members/did:plc:bob/role", {
686686+ method: "POST",
687687+ headers: {
688688+ "Content-Type": "application/x-www-form-urlencoded",
689689+ cookie: "atbb_session=token",
690690+ },
691691+ body: makeFormBody(),
692692+ });
693693+694694+ expect(res.status).toBe(200);
695695+ const html = await res.text();
696696+ expect(html).toContain("member-row__error");
697697+ expect(html).toContain("session has expired");
698698+ });
699699+700700+ it("returns error row with reload message when rolesJson is malformed", async () => {
701701+ setupPostSession();
702702+703703+ const routes = await loadAdminRoutes();
704704+ const res = await routes.request("/admin/members/did:plc:bob/role", {
705705+ method: "POST",
706706+ headers: {
707707+ "Content-Type": "application/x-www-form-urlencoded",
708708+ cookie: "atbb_session=token",
709709+ },
710710+ body: makeFormBody({ rolesJson: "not-valid-json{{" }),
711711+ });
712712+713713+ expect(res.status).toBe(200);
714714+ const html = await res.text();
715715+ expect(html).toContain("member-row__error");
716716+ expect(html).toContain("reload");
717717+ // No AppView call should have been made
718718+ // (setupPostSession consumed 2 calls, then we check no more were made)
719719+ expect(mockFetch).toHaveBeenCalledTimes(2);
640720 });
641721});