ATB-48: Admin Mod Action Log UI (/admin/modlog) Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add GET /admin/modlog — a paginated, read-only audit log of moderation actions — to the web app's admin panel.
Architecture: The web server proxies GET /api/admin/modlog?limit=50&offset=N from the AppView (already implemented in ATB-46), renders the result as an HTML table, and serves offset-based pagination via Next/Previous links. All permission gating uses the existing canViewModLog() session helper.
Tech Stack: Hono (web server), Hono JSX (templates), Vitest (tests). No new files — only apps/web/src/routes/admin.tsx and apps/web/src/routes/__tests__/admin.test.tsx are touched.
Context You Need to Know#
File locations#
- Route to modify:
apps/web/src/routes/admin.tsx(add new route beforereturn app;at line ~1249) - Test file to modify:
apps/web/src/routes/__tests__/admin.test.tsx(add newdescribeblock at the end) - Session helpers:
apps/web/src/lib/session.ts—canViewModLog()already exists - AppView endpoint already exists:
GET /api/admin/modloginapps/appview/src/routes/admin.ts
AppView response shape#
{
"actions": [
{
"id": "123",
"action": "space.atbb.modAction.ban",
"moderatorDid": "did:plc:abc",
"moderatorHandle": "alice.bsky.social",
"subjectDid": "did:plc:xyz",
"subjectHandle": "bob.bsky.social",
"subjectPostUri": null,
"reason": "Spam",
"createdAt": "2026-02-26T12:01:00Z"
}
],
"total": 42,
"offset": 0,
"limit": 50
}
Actual action token values (from mod.ts — differ from design doc)#
| Token | Human label |
|---|---|
space.atbb.modAction.ban |
Ban |
space.atbb.modAction.unban |
Unban |
space.atbb.modAction.lock |
Lock |
space.atbb.modAction.unlock |
Unlock |
space.atbb.modAction.delete |
Hide |
space.atbb.modAction.undelete |
Unhide |
Subject column logic#
- If
subjectPostUriis non-null → show the post URI (post-targeting action) - Else if
subjectHandleis non-null → show the handle (user-targeting action) - Else → show
subjectDidas fallback (handle not indexed)
Permission gate#
Any of: space.atbb.permission.moderatePosts, space.atbb.permission.banUsers, space.atbb.permission.lockTopics (or *). Use canViewModLog(auth) from session.ts.
Pagination design#
- 50 rows per page, read
?offsetfrom query string (default 0) - Previous link:
href="/admin/modlog?offset={offset - 50}"(hidden when offset === 0) - Next link:
href="/admin/modlog?offset={offset + 50}"(hidden when offset + limit >= total) - Page indicator:
Page {currentPage} of {totalPages}
Test pattern (copy from existing describe blocks)#
Each describe block in the test file:
beforeEachstubs globalfetch,APPVIEW_URL, and callsvi.resetModules()afterEachunstubs everythingsetupSession(permissions)mock sets up the two-call auth sequence (session + members/me)loadAdminRoutes()does dynamic import to get a fresh Hono app per test
Task 1: Add types and helpers to admin.tsx#
Files:
- Modify:
apps/web/src/routes/admin.tsx(add after theBoardEntryinterface, around line 48)
Step 1: Add ModLogEntry interface and ACTION_LABELS map
Add these after the BoardEntry interface (before the // ─── Helpers ─── section):
interface ModLogEntry {
id: string;
action: string;
moderatorDid: string;
moderatorHandle: string;
subjectDid: string | null;
subjectHandle: string | null;
subjectPostUri: string | null;
reason: string | null;
createdAt: string;
}
const ACTION_LABELS: Record<string, string> = {
"space.atbb.modAction.ban": "Ban",
"space.atbb.modAction.unban": "Unban",
"space.atbb.modAction.lock": "Lock",
"space.atbb.modAction.unlock": "Unlock",
"space.atbb.modAction.delete": "Hide",
"space.atbb.modAction.undelete": "Unhide",
};
Step 2: Add formatModLogDate helper
Add after formatJoinedDate in the // ─── Helpers ─── section:
function formatModLogDate(isoString: string): string {
const d = new Date(isoString);
if (isNaN(d.getTime())) return "—";
return d.toLocaleString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
Step 3: No test to run yet — verify TypeScript compiles
Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web build (or skip — build happens in Task 3 after route is added)
Step 4: No commit yet — combine with Task 2
Task 2: Add ModLogRow component to admin.tsx#
Files:
- Modify:
apps/web/src/routes/admin.tsx(add in the// ─── Components ───section, afterMemberRow)
Step 1: Add ModLogRow component
Add after the MemberRow component (around line 122):
function ModLogRow({ entry }: { entry: ModLogEntry }) {
const label = ACTION_LABELS[entry.action] ?? entry.action;
const subject = entry.subjectPostUri
? entry.subjectPostUri
: (entry.subjectHandle ?? entry.subjectDid ?? "—");
return (
<tr>
<td class="modlog-table__time">{formatModLogDate(entry.createdAt)}</td>
<td class="modlog-table__moderator">{entry.moderatorHandle}</td>
<td class="modlog-table__action">
<span class={`modlog-action-badge modlog-action-badge--${label.toLowerCase()}`}>
{label}
</span>
</td>
<td class="modlog-table__subject">{subject}</td>
<td class="modlog-table__reason">{entry.reason ?? "—"}</td>
</tr>
);
}
Step 2: No commit yet — combine with Task 3
Task 3: Add GET /admin/modlog route to admin.tsx#
Files:
- Modify:
apps/web/src/routes/admin.tsx(add beforereturn app;at the end ofcreateAdminRoutes)
Step 1: Write the failing test first (see Task 4, Step 1)
Skip ahead to Task 4 to write one smoke-test first, then come back here.
Step 2: Add the route
Add before return app; (around line 1249):
// ── GET /admin/modlog ─────────────────────────────────────────────────────
app.get("/admin/modlog", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) {
return c.redirect("/login");
}
if (!canViewModLog(auth)) {
return c.html(
<BaseLayout title="Access Denied — atBB Forum" auth={auth}>
<PageHeader title="Mod Action Log" />
<p>You don't have permission to view the mod action log.</p>
</BaseLayout>,
403
);
}
const rawOffset = c.req.query("offset");
const offset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
? parseInt(rawOffset, 10)
: 0;
const limit = 50;
const cookie = c.req.header("cookie") ?? "";
let modlogRes: Response;
try {
modlogRes = await fetch(
`${appviewUrl}/api/admin/modlog?limit=${limit}&offset=${offset}`,
{ headers: { Cookie: cookie } }
);
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error fetching mod action log", {
operation: "GET /admin/modlog",
error: error instanceof Error ? error.message : String(error),
});
return c.html(
<BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
<PageHeader title="Mod Action Log" />
<ErrorDisplay
message="Unable to load mod action log"
detail="The forum is temporarily unavailable. Please try again."
/>
</BaseLayout>,
503
);
}
if (!modlogRes.ok) {
if (modlogRes.status === 401) {
return c.redirect("/login");
}
logger.error("AppView returned error for mod action log", {
operation: "GET /admin/modlog",
status: modlogRes.status,
});
return c.html(
<BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
<PageHeader title="Mod Action Log" />
<ErrorDisplay
message="Something went wrong"
detail="Could not load mod action log. Please try again."
/>
</BaseLayout>,
500
);
}
const data = (await modlogRes.json()) as {
actions: ModLogEntry[];
total: number;
offset: number;
limit: number;
};
const { actions, total } = data;
const totalPages = total === 0 ? 1 : Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
const hasPrev = offset > 0;
const hasNext = offset + limit < total;
return c.html(
<BaseLayout title="Mod Action Log — atBB Forum" auth={auth}>
<PageHeader title="Mod Action Log" />
{actions.length === 0 ? (
<EmptyState message="No moderation actions yet" />
) : (
<div class="card">
<table class="modlog-table">
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Moderator</th>
<th scope="col">Action</th>
<th scope="col">Subject</th>
<th scope="col">Reason</th>
</tr>
</thead>
<tbody>
{actions.map((entry) => (
<ModLogRow entry={entry} />
))}
</tbody>
</table>
</div>
)}
<div class="modlog-pagination">
{hasPrev && (
<a href={`/admin/modlog?offset=${offset - limit}`} class="btn btn-secondary">
← Previous
</a>
)}
<span class="modlog-pagination__indicator">
Page {currentPage} of {totalPages}
</span>
{hasNext && (
<a href={`/admin/modlog?offset=${offset + limit}`} class="btn btn-secondary">
Next →
</a>
)}
</div>
</BaseLayout>
);
});
Step 3: Run tests (after writing them in Task 4)
Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test
Expected: All tests pass
Step 4: Commit
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "feat(web): admin mod action log page — /admin/modlog (ATB-48)"
Task 4: Add tests to admin.test.tsx#
Files:
- Modify:
apps/web/src/routes/__tests__/admin.test.tsx(add newdescribeblock at the end)
Step 1: Write the describe block with all required tests
Append to the end of admin.test.tsx:
describe("createAdminRoutes — GET /admin/modlog", () => {
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),
};
}
function setupSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
const SAMPLE_ACTIONS = [
{
id: "1",
action: "space.atbb.modAction.ban",
moderatorDid: "did:plc:alice",
moderatorHandle: "alice.bsky.social",
subjectDid: "did:plc:bob",
subjectHandle: "bob.bsky.social",
subjectPostUri: null,
reason: "Spam",
createdAt: "2026-02-26T12:01:00.000Z",
},
{
id: "2",
action: "space.atbb.modAction.delete",
moderatorDid: "did:plc:alice",
moderatorHandle: "alice.bsky.social",
subjectDid: null,
subjectHandle: null,
subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123",
reason: "Inappropriate",
createdAt: "2026-02-26T11:30:00.000Z",
},
];
// ── Auth & permission gates ──────────────────────────────────────────────
it("redirects unauthenticated users to /login", async () => {
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
it("returns 403 for user without any mod permission", async () => {
setupSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(403);
const html = await res.text();
expect(html).toContain("permission");
});
it("allows access for moderatePosts permission", async () => {
setupSession(["space.atbb.permission.moderatePosts"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
});
it("allows access for banUsers permission", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
});
it("allows access for lockTopics permission", async () => {
setupSession(["space.atbb.permission.lockTopics"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
});
// ── Table rendering ──────────────────────────────────────────────────────
it("renders table with moderator handle and action label", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("alice.bsky.social");
expect(html).toContain("Ban");
expect(html).toContain("bob.bsky.social");
expect(html).toContain("Spam");
});
it("maps space.atbb.modAction.delete to 'Hide' label", async () => {
setupSession(["space.atbb.permission.moderatePosts"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("Hide");
});
it("shows post URI in subject column for post-targeting actions", async () => {
setupSession(["space.atbb.permission.moderatePosts"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123");
});
it("shows handle in subject column for user-targeting actions", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("bob.bsky.social");
});
it("shows empty state when no actions", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: [], total: 0, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("No moderation actions");
});
// ── Pagination ───────────────────────────────────────────────────────────
it("renders 'Page 1 of 2' indicator for 51 total actions", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("Page 1 of 2");
});
it("shows Next link when more pages exist", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain('href="/admin/modlog?offset=50"');
expect(html).toContain("Next");
});
it("hides Next link on last page", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog?offset=50", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).not.toContain('href="/admin/modlog?offset=100"');
});
it("shows Previous link when not on first page", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog?offset=50", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain('href="/admin/modlog?offset=0"');
expect(html).toContain("Previous");
});
it("hides Previous link on first page", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).not.toContain('href="/admin/modlog?offset=-50"');
expect(html).not.toContain("Previous");
});
it("passes offset query param to AppView", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 })
);
const routes = await loadAdminRoutes();
await routes.request("/admin/modlog?offset=50", {
headers: { cookie: "atbb_session=token" },
});
// Third fetch call (index 2) is the modlog API call
const modlogCall = mockFetch.mock.calls[2];
expect(modlogCall[0]).toContain("offset=50");
expect(modlogCall[0]).toContain("limit=50");
});
it("ignores invalid offset and defaults to 0", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 })
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog?offset=notanumber", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const modlogCall = mockFetch.mock.calls[2];
expect(modlogCall[0]).toContain("offset=0");
});
// ── Error handling ───────────────────────────────────────────────────────
it("returns 503 on AppView network error", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
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", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(500);
const html = await res.text();
expect(html).toContain("error-display");
});
it("redirects to /login when AppView returns 401", async () => {
setupSession(["space.atbb.permission.banUsers"]);
mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/modlog", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
});
Step 2: Run the tests (all should fail — route not added yet)
Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test
Expected: New modlog tests FAIL ("Cannot read properties of undefined" or similar)
Step 3: Now go implement Tasks 1–3 (types, component, route)
Step 4: Run tests again
Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm --filter @atbb/web test
Expected: All 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): admin mod action log page — /admin/modlog (ATB-48)"
Task 5: Run full test suite#
Step 1: Run all tests
Run: PATH=/path/to/.devenv/profile/bin:/bin:/usr/bin pnpm test
Expected: All tests pass
Step 2: If tests fail — check whether it's a Turbo env var issue. See CLAUDE.md section "Environment Variables in Tests".
Task 6: Update documentation#
Step 1: Move plan doc to complete/
No — plan docs only move to docs/plans/complete/ after work is shipped (merged). Leave this plan in docs/plans/ until the PR is merged.
Step 2: Update docs/atproto-forum-plan.md
In docs/atproto-forum-plan.md, find the ATB-48 entry and mark it [x]. Add a note: ATB-48: /admin/modlog UI — see PR.
Step 3: Update Linear
- Change ATB-48 status from Backlog → In Progress → Done
- Add a comment: "Implemented
GET /admin/modloginapps/web/src/routes/admin.tsx. Permission gate usescanViewModLog(). Offset pagination with 50 rows per page. Action labels mapped from actualspace.atbb.modAction.*tokens."
Step 4: Commit doc update (after PR is approved)
git add docs/atproto-forum-plan.md
git commit -m "docs: mark ATB-48 complete"
Task 7: Open PR#
git push -u origin HEAD
gh pr create \
--title "feat(web): admin mod action log UI — /admin/modlog (ATB-48)" \
--body "$(cat <<'EOF'
## Summary
- Adds `GET /admin/modlog` to the web admin panel
- Permission gate: any of `moderatePosts`, `banUsers`, `lockTopics`
- Renders a table with timestamp, moderator, action (human-readable label), subject, and reason
- Offset-based pagination: 50 rows per page, Previous/Next links
- Action tokens mapped to labels: `space.atbb.modAction.delete` → Hide, `space.atbb.modAction.lock` → Lock, etc.
- Subject column shows handle for user-targeting actions, AT URI for post-targeting actions
- Error handling: 503 for network errors, 500 for server errors, redirect for 401
## Test plan
- [ ] All new tests in `admin.test.tsx` pass
- [ ] Full `pnpm test` passes
- [ ] Manual smoke: log in with a mod permission, navigate to `/admin/modlog`
- [ ] Manual: verify pagination Next/Previous links work with offset param
- [ ] Manual: verify the admin landing page mod log card links to `/admin/modlog`
EOF
)"