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