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
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

ATB-43 Admin Members Page Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

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.

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).

Tech Stack: Hono (web server + AppView), Hono JSX, HTMX, typed-htmx, Vitest (web tests use global fetch mock; AppView tests use createTestContext())


Task 1: Add canManageRoles session helper#

Files:

  • Modify: apps/web/src/lib/session.ts
  • Test: apps/web/src/lib/__tests__/session.test.ts

Step 1: Write the failing tests

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:

// In the import line at the top, add canManageRoles:
import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog, canManageRoles } from "../session.js";

Then add this block at the end of the file:

describe("canManageRoles", () => {
  it("returns false for unauthenticated session", () => {
    const auth: WebSessionWithPermissions = {
      authenticated: false,
      permissions: new Set(),
    };
    expect(canManageRoles(auth)).toBe(false);
  });

  it("returns false when authenticated but missing manageRoles", () => {
    const auth: WebSessionWithPermissions = {
      authenticated: true,
      did: "did:plc:x",
      handle: "x.bsky.social",
      permissions: new Set(["space.atbb.permission.manageMembers"]),
    };
    expect(canManageRoles(auth)).toBe(false);
  });

  it("returns true with manageRoles permission", () => {
    const auth: WebSessionWithPermissions = {
      authenticated: true,
      did: "did:plc:x",
      handle: "x.bsky.social",
      permissions: new Set(["space.atbb.permission.manageRoles"]),
    };
    expect(canManageRoles(auth)).toBe(true);
  });

  it("returns true with wildcard (*) permission", () => {
    const auth: WebSessionWithPermissions = {
      authenticated: true,
      did: "did:plc:x",
      handle: "x.bsky.social",
      permissions: new Set(["*"]),
    };
    expect(canManageRoles(auth)).toBe(true);
  });
});

Also add WebSessionWithPermissions to the import if not already imported as a type — it's in ../session.js as an exported type.

Step 2: Run the tests to confirm they fail

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/web exec vitest run src/lib/__tests__/session.test.ts

Expected: 4 failures saying canManageRoles is not a function.

Step 3: Implement the helper

Add the following at the end of apps/web/src/lib/session.ts, after the canViewModLog function:

/** Returns true if the session grants permission to assign member roles. */
export function canManageRoles(auth: WebSessionWithPermissions): boolean {
  return (
    auth.authenticated &&
    (auth.permissions.has("space.atbb.permission.manageRoles") ||
      auth.permissions.has("*"))
  );
}

Step 4: Run tests to confirm they pass

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/web exec vitest run src/lib/__tests__/session.test.ts

Expected: all existing tests + 4 new canManageRoles tests pass.

Step 5: Commit

git add apps/web/src/lib/session.ts apps/web/src/lib/__tests__/session.test.ts
git commit -m "feat(web): add canManageRoles session helper (ATB-43)"

Task 2: Add uri field to GET /api/admin/roles AppView response#

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.

Files:

  • Modify: apps/appview/src/routes/admin.ts
  • Test: apps/appview/src/routes/__tests__/admin.test.ts

Step 1: Write the failing test

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):

describe("GET /api/admin/roles", () => {
  it("includes uri field in each role", async () => {
    // Seed a role
    const [role] = await ctx.db
      .insert(roles)
      .values({
        did: ctx.config.forumDid,
        rkey: "owner",
        cid: "bafytest",
        name: "Owner",
        description: "Forum owner",
        priority: 0,
        indexedAt: new Date(),
        createdAt: new Date(),
      })
      .returning();

    mockUser = { did: "did:plc:test-admin" };

    const res = await app.request("/api/admin/roles", {
      headers: { Cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(200);
    const data = await res.json() as { roles: Array<{ name: string; uri: string }> };
    expect(data.roles).toHaveLength(1);
    expect(data.roles[0].uri).toBe(
      `at://${ctx.config.forumDid}/space.atbb.forum.role/owner`
    );
  });
});

Step 2: Run the test to confirm it fails

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts

Expected: failure — data.roles[0].uri is undefined.

Step 3: Implement the change

In apps/appview/src/routes/admin.ts, find the GET /roles handler. Locate the select query inside it:

const rolesList = await ctx.db
  .select({
    id: roles.id,
    name: roles.name,
    description: roles.description,
    priority: roles.priority,
  })

Add rkey and did to the select:

const rolesList = await ctx.db
  .select({
    id: roles.id,
    name: roles.name,
    description: roles.description,
    priority: roles.priority,
    rkey: roles.rkey,
    did: roles.did,
  })

Then in the .map() that builds rolesWithPermissions, add the uri field:

return {
  id: role.id.toString(),
  name: role.name,
  description: role.description,
  permissions: perms.map((p) => p.permission),
  priority: role.priority,
  uri: `at://${role.did}/space.atbb.forum.role/${role.rkey}`,
};

Step 4: Run tests to confirm they pass

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/admin.test.ts

Expected: all existing tests + new uri test pass.

Step 5: Commit

git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "feat(appview): include uri in GET /api/admin/roles response (ATB-43)"

Task 3: Add admin member table CSS#

Files:

  • Modify: apps/web/public/static/css/theme.css

Step 1: Add CSS

At the very end of apps/web/public/static/css/theme.css, after the .admin-nav-card__description block, add:

/* ─── Admin Member Table ─────────────────────────────────────────────────── */

.admin-member-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: var(--space-md);
}

.admin-member-table th {
  text-align: left;
  padding: var(--space-sm) var(--space-md);
  border-bottom: calc(var(--border-width) * 2) solid var(--color-border);
  font-weight: var(--font-weight-bold);
  font-size: var(--font-size-sm);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

.admin-member-table td {
  padding: var(--space-sm) var(--space-md);
  border-bottom: var(--border-width) solid var(--color-border);
  vertical-align: middle;
}

.admin-member-table tbody tr:last-child td {
  border-bottom: none;
}

.role-badge {
  display: inline-block;
  padding: var(--space-xs) var(--space-sm);
  border: var(--border-width) solid var(--color-border);
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-bold);
  background-color: var(--color-surface);
}

.member-row__assign-form {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  flex-wrap: wrap;
}

.member-row__error {
  display: block;
  color: var(--color-danger);
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-bold);
  margin-top: var(--space-xs);
}

Step 2: Commit

git add apps/web/public/static/css/theme.css
git commit -m "style(web): add admin member table CSS classes (ATB-43)"

Task 4: Add GET /admin/members page route#

Files:

  • Modify: apps/web/src/routes/admin.tsx
  • Test: apps/web/src/routes/__tests__/admin.test.tsx

Step 1: Write the failing tests#

Add a new describe block to apps/web/src/routes/__tests__/admin.test.tsx:

describe("createAdminRoutes — GET /admin/members", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  /** Sets up the two-fetch mock sequence for an authenticated session. */
  function setupSession(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  const SAMPLE_MEMBERS = [
    { 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" },
    { 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" },
  ];

  const SAMPLE_ROLES = [
    { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
    { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
  ];

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  // ── Auth guards ──────────────────────────────────────────────────────────

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 for authenticated user without manageMembers", async () => {
    setupSession(["space.atbb.permission.manageCategories"]);
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
  });

  // ── Successful renders ───────────────────────────────────────────────────

  it("renders member table with handles and role badges", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );
    // No roles fetch — no manageRoles permission

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("alice.bsky.social");
    expect(html).toContain("bob.bsky.social");
    expect(html).toContain("role-badge");
    expect(html).toContain("Owner");
    expect(html).toContain("Member");
  });

  it("renders joined date for members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    // Jan 1 2026
    expect(html).toContain("Jan");
    expect(html).toContain("2026");
  });

  it("hides role assignment form when user lacks manageRoles", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).not.toContain("hx-post");
    expect(html).not.toContain("Assign");
  });

  it("shows role assignment form when user has manageRoles", async () => {
    setupSession([
      "space.atbb.permission.manageMembers",
      "space.atbb.permission.manageRoles",
    ]);
    // members fetch
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false })
    );
    // roles fetch (parallel)
    mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES }));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("hx-post");
    expect(html).toContain("/admin/members/did:plc:bob/role");
    expect(html).toContain("Assign");
  });

  it("shows empty state when no members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: [], isTruncated: false })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    expect(html).toContain("No members");
  });

  it("shows truncated indicator when isTruncated is true", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    const html = await res.text();
    // Member count should include "+" indicator
    expect(html).toContain("+");
  });

  // ── Error handling ───────────────────────────────────────────────────────

  it("returns 503 on AppView network error fetching members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(503);
    const html = await res.text();
    expect(html).toContain("error-display");
  });

  it("returns 500 on AppView server error fetching members", async () => {
    setupSession(["space.atbb.permission.manageMembers"]);
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members", {
      headers: { cookie: "atbb_session=token" },
    });

    expect(res.status).toBe(500);
    const html = await res.text();
    expect(html).toContain("error-display");
  });
});

Step 2: Run tests to confirm they fail

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/web exec vitest run src/routes/__tests__/admin.test.tsx

Expected: all new tests fail — GET /admin/members returns 404.

Step 3: Implement the route

Replace the entire content of apps/web/src/routes/admin.tsx with the following:

import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import { PageHeader, Card, EmptyState, ErrorDisplay } from "../components/index.js";
import {
  getSessionWithPermissions,
  hasAnyAdminPermission,
  canManageMembers,
  canManageCategories,
  canViewModLog,
  canManageRoles,
} from "../lib/session.js";
import { isProgrammingError } from "../lib/errors.js";
import { logger } from "../lib/logger.js";

// ─── Types ─────────────────────────────────────────────────────────────────

interface MemberEntry {
  did: string;
  handle: string;
  role: string;
  roleUri: string | null;
  joinedAt: string | null;
}

interface RoleEntry {
  id: string;
  name: string;
  uri: string;
  priority: number;
}

// ─── Helpers ───────────────────────────────────────────────────────────────

function formatJoinedDate(isoString: string | null): string {
  if (!isoString) return "—";
  const d = new Date(isoString);
  if (isNaN(d.getTime())) return "—";
  return d.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
  });
}

// ─── Components ────────────────────────────────────────────────────────────

function MemberRow({
  member,
  roles,
  showRoleControls,
  errorMsg = null,
}: {
  member: MemberEntry;
  roles: RoleEntry[];
  showRoleControls: boolean;
  errorMsg?: string | null;
}) {
  const colSpan = showRoleControls ? 4 : 3;
  return (
    <tr>
      <td>{member.handle}</td>
      <td>
        <span class="role-badge">{member.role}</span>
      </td>
      <td>{formatJoinedDate(member.joinedAt)}</td>
      {showRoleControls && (
        <td>
          <form
            hx-post={`/admin/members/${member.did}/role`}
            hx-target="closest tr"
            hx-swap="outerHTML"
          >
            <input type="hidden" name="handle" value={member.handle} />
            <input type="hidden" name="joinedAt" value={member.joinedAt ?? ""} />
            <input type="hidden" name="currentRole" value={member.role} />
            <input type="hidden" name="currentRoleUri" value={member.roleUri ?? ""} />
            <input type="hidden" name="canManageRoles" value="1" />
            <input
              type="hidden"
              name="rolesJson"
              value={JSON.stringify(roles)}
            />
            <div class="member-row__assign-form">
              <label class="sr-only" for={`role-${member.did}`}>
                Assign role to {member.handle}
              </label>
              <select id={`role-${member.did}`} name="roleUri">
                {roles.map((role) => (
                  <option
                    value={role.uri}
                    selected={member.roleUri === role.uri}
                  >
                    {role.name}
                  </option>
                ))}
              </select>
              <button type="submit" class="btn btn-primary">
                Assign
              </button>
            </div>
            {errorMsg && (
              <span class="member-row__error">{errorMsg}</span>
            )}
          </form>
        </td>
      )}
    </tr>
  );
}

// ─── Routes ────────────────────────────────────────────────────────────────

export function createAdminRoutes(appviewUrl: string) {
  const app = new Hono();

  // ── GET /admin ────────────────────────────────────────────────────────────

  app.get("/admin", async (c) => {
    const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));

    if (!auth.authenticated) {
      return c.redirect("/login");
    }

    if (!hasAnyAdminPermission(auth)) {
      return c.html(
        <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
          <PageHeader title="Access Denied" />
          <p>You don&apos;t have permission to access the admin panel.</p>
        </BaseLayout>,
        403
      );
    }

    const showMembers = canManageMembers(auth);
    const showStructure = canManageCategories(auth);
    const showModLog = canViewModLog(auth);

    return c.html(
      <BaseLayout title="Admin Panel — atBB Forum" auth={auth}>
        <PageHeader title="Admin Panel" />
        <div class="admin-nav-grid">
          {showMembers && (
            <a href="/admin/members" class="admin-nav-card">
              <Card>
                <p class="admin-nav-card__icon" aria-hidden="true">👥</p>
                <p class="admin-nav-card__title">Members</p>
                <p class="admin-nav-card__description">View and assign member roles</p>
              </Card>
            </a>
          )}
          {showStructure && (
            <a href="/admin/structure" class="admin-nav-card">
              <Card>
                <p class="admin-nav-card__icon" aria-hidden="true">📁</p>
                <p class="admin-nav-card__title">Structure</p>
                <p class="admin-nav-card__description">Manage categories and boards</p>
              </Card>
            </a>
          )}
          {showModLog && (
            <a href="/admin/modlog" class="admin-nav-card">
              <Card>
                <p class="admin-nav-card__icon" aria-hidden="true">📋</p>
                <p class="admin-nav-card__title">Mod Log</p>
                <p class="admin-nav-card__description">Audit trail of moderation actions</p>
              </Card>
            </a>
          )}
        </div>
      </BaseLayout>
    );
  });

  // ── GET /admin/members ────────────────────────────────────────────────────

  app.get("/admin/members", async (c) => {
    const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));

    if (!auth.authenticated) {
      return c.redirect("/login");
    }

    if (!canManageMembers(auth)) {
      return c.html(
        <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
          <PageHeader title="Members" />
          <p>You don&apos;t have permission to manage members.</p>
        </BaseLayout>,
        403
      );
    }

    const cookie = c.req.header("cookie") ?? "";
    const showRoleControls = canManageRoles(auth);

    let membersRes: Response;
    let rolesRes: Response | null = null;

    try {
      [membersRes, rolesRes] = await Promise.all([
        fetch(`${appviewUrl}/api/admin/members`, { headers: { Cookie: cookie } }),
        showRoleControls
          ? fetch(`${appviewUrl}/api/admin/roles`, { headers: { Cookie: cookie } })
          : Promise.resolve(null),
      ]);
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      logger.error("Network error fetching members", {
        operation: "GET /admin/members",
        error: error instanceof Error ? error.message : String(error),
      });
      return c.html(
        <BaseLayout title="Members — atBB Forum" auth={auth}>
          <PageHeader title="Members" />
          <ErrorDisplay
            message="Unable to load members"
            detail="The forum is temporarily unavailable. Please try again."
          />
        </BaseLayout>,
        503
      );
    }

    if (!membersRes.ok) {
      logger.error("AppView returned error for members list", {
        operation: "GET /admin/members",
        status: membersRes.status,
      });
      return c.html(
        <BaseLayout title="Members — atBB Forum" auth={auth}>
          <PageHeader title="Members" />
          <ErrorDisplay
            message="Something went wrong"
            detail="Could not load member list. Please try again."
          />
        </BaseLayout>,
        500
      );
    }

    const membersData = (await membersRes.json()) as {
      members: MemberEntry[];
      isTruncated: boolean;
    };
    const rolesData =
      rolesRes?.ok ? ((await rolesRes.json()) as { roles: RoleEntry[] }) : null;

    const members = membersData.members;
    const roles = rolesData?.roles ?? [];
    const isTruncated = membersData.isTruncated;

    const title = `Members (${members.length}${isTruncated ? "+" : ""})`;

    return c.html(
      <BaseLayout title="Members — atBB Forum" auth={auth}>
        <PageHeader title={title} />
        {members.length === 0 ? (
          <EmptyState message="No members yet" />
        ) : (
          <div class="card">
            <table class="admin-member-table">
              <thead>
                <tr>
                  <th scope="col">Handle</th>
                  <th scope="col">Role</th>
                  <th scope="col">Joined</th>
                  {showRoleControls && <th scope="col">Assign Role</th>}
                </tr>
              </thead>
              <tbody>
                {members.map((member) => (
                  <MemberRow
                    member={member}
                    roles={roles}
                    showRoleControls={showRoleControls}
                  />
                ))}
              </tbody>
            </table>
          </div>
        )}
      </BaseLayout>
    );
  });

  // ── POST /admin/members/:did/role (HTMX proxy) ────────────────────────────

  app.post("/admin/members/:did/role", async (c) => {
    const targetDid = c.req.param("did");
    const cookie = c.req.header("cookie") ?? "";

    // Parse form body
    let body: Record<string, string | File>;
    try {
      body = await c.req.parseBody();
    } catch {
      return c.html(
        <tr>
          <td colspan="4">
            <span class="member-row__error">Invalid form submission.</span>
          </td>
        </tr>
      );
    }

    const roleUri = typeof body.roleUri === "string" ? body.roleUri.trim() : "";
    const handle = typeof body.handle === "string" ? body.handle : targetDid;
    const joinedAt = typeof body.joinedAt === "string" ? body.joinedAt : null;
    const currentRole = typeof body.currentRole === "string" ? body.currentRole : "";
    const currentRoleUri =
      typeof body.currentRoleUri === "string" && body.currentRoleUri
        ? body.currentRoleUri
        : null;
    const showRoleControls = body.canManageRoles === "1";

    let roles: RoleEntry[] = [];
    try {
      const rolesJson = typeof body.rolesJson === "string" ? body.rolesJson : "[]";
      roles = JSON.parse(rolesJson) as RoleEntry[];
    } catch {
      // Roles stay empty — dropdown won't render but row will still show
    }

    if (!roleUri) {
      return c.html(
        <MemberRow
          member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
          roles={roles}
          showRoleControls={showRoleControls}
          errorMsg="Please select a role."
        />
      );
    }

    // Forward to AppView
    let appviewRes: Response;
    try {
      appviewRes = await fetch(
        `${appviewUrl}/api/admin/members/${targetDid}/role`,
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Cookie: cookie,
          },
          body: JSON.stringify({ roleUri }),
        }
      );
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      logger.error("Network error proxying role assignment", {
        operation: "POST /admin/members/:did/role",
        targetDid,
        error: error instanceof Error ? error.message : String(error),
      });
      return c.html(
        <MemberRow
          member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
          roles={roles}
          showRoleControls={showRoleControls}
          errorMsg="Forum temporarily unavailable. Please try again."
        />
      );
    }

    if (appviewRes.ok) {
      const data = (await appviewRes.json()) as {
        roleAssigned: string;
        targetDid: string;
      };
      const newRoleName = data.roleAssigned || currentRole;
      return c.html(
        <MemberRow
          member={{ did: targetDid, handle, role: newRoleName, roleUri, joinedAt }}
          roles={roles}
          showRoleControls={showRoleControls}
        />
      );
    }

    // Map AppView errors to user-friendly messages
    let errorMsg: string;
    if (appviewRes.status === 403) {
      errorMsg = "Cannot assign a role with equal or higher authority than your own.";
    } else if (appviewRes.status === 404) {
      errorMsg = "Member or role not found.";
    } else if (appviewRes.status === 401) {
      errorMsg = "Your session has expired. Please log in again.";
    } else {
      logger.error("AppView returned error for role assignment", {
        operation: "POST /admin/members/:did/role",
        targetDid,
        status: appviewRes.status,
      });
      errorMsg = "Something went wrong. Please try again.";
    }

    return c.html(
      <MemberRow
        member={{ did: targetDid, handle, role: currentRole, roleUri: currentRoleUri, joinedAt }}
        roles={roles}
        showRoleControls={showRoleControls}
        errorMsg={errorMsg}
      />
    );
  });

  return app;
}

Step 4: Run tests to confirm they pass

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/web exec vitest run src/routes/__tests__/admin.test.tsx

Expected: all previous GET /admin tests still pass + all new GET /admin/members tests pass.

Step 5: Commit

git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "feat(web): add GET /admin/members page with role display (ATB-43)"

Task 5: Add proxy route tests and verify#

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.

Files:

  • Test: apps/web/src/routes/__tests__/admin.test.tsx

Step 1: Write the failing tests

Add another describe block to apps/web/src/routes/__tests__/admin.test.tsx:

describe("createAdminRoutes — POST /admin/members/:did/role", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  const SAMPLE_ROLES = [
    { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] },
    { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] },
  ];

  function makeFormBody(overrides: Partial<Record<string, string>> = {}): string {
    const params = new URLSearchParams({
      roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
      handle: "bob.bsky.social",
      joinedAt: "2026-01-05T00:00:00.000Z",
      currentRole: "Owner",
      currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner",
      canManageRoles: "1",
      rolesJson: JSON.stringify(SAMPLE_ROLES),
      ...overrides,
    });
    return params.toString();
  }

  async function loadAdminRoutes() {
    const { createAdminRoutes } = await import("../admin.js");
    return createAdminRoutes("http://localhost:3000");
  }

  // ── Success ──────────────────────────────────────────────────────────────

  it("returns updated <tr> with new role name on success", async () => {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("<tr");
    expect(html).toContain("Member"); // new role badge
    expect(html).toContain("bob.bsky.social"); // handle preserved
  });

  it("re-renders form with updated role selected on success", async () => {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" })
    );

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({
        roleUri: "at://did:plc:forum/space.atbb.forum.role/member",
      }),
    });

    const html = await res.text();
    // The new roleUri should be pre-selected in the dropdown
    expect(html).toContain(
      'value="at://did:plc:forum/space.atbb.forum.role/member"'
    );
  });

  // ── AppView errors ───────────────────────────────────────────────────────

  it("returns row with friendly error on AppView 403", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("<tr");
    expect(html).toContain("member-row__error");
    expect(html).toContain("equal or higher authority");
    // Preserves current role in badge
    expect(html).toContain("Owner");
  });

  it("returns row with friendly error on AppView 404", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("not found");
  });

  it("returns row with friendly error on AppView 500", async () => {
    mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("Something went wrong");
  });

  it("returns row with unavailable message on network error", async () => {
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    expect(html).toContain("temporarily unavailable");
  });

  it("returns row with error when roleUri is missing", async () => {
    const routes = await loadAdminRoutes();
    const res = await routes.request("/admin/members/did:plc:bob/role", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({ roleUri: "" }),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("member-row__error");
    // No AppView call should have been made
    expect(mockFetch).not.toHaveBeenCalled();
  });
});

Step 2: Run tests to confirm they fail

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/web exec vitest run src/routes/__tests__/admin.test.tsx

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.

Step 3: Run all tests to confirm nothing regressed

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/web exec vitest run

Expected: all web tests pass.

Step 4: Run AppView tests too

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm --filter @atbb/appview exec vitest run

Expected: all AppView tests pass.

Step 5: Commit

git add apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "test(web): add POST /admin/members/:did/role proxy tests (ATB-43)"

Task 6: Final verification#

Step 1: Run full test suite

PATH=/path/to/main-repo/.devenv/profile/bin:/bin:/usr/bin:$PATH \
  pnpm test

Expected: all tests in all packages pass.

Step 2: Update Linear issue

Update ATB-43 status to "In Review" and add a comment summarising what was implemented.

Step 3: Update project plan doc

Mark ATB-43 complete in docs/atproto-forum-plan.md if applicable.