ATB-42: Admin Panel Landing Page Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add GET /admin landing page with permission-aware navigation cards and all routing/permission infrastructure required by subsequent admin sub-pages (ATB-43, ATB-44, ATB-45).
Architecture: The web server gates /admin using WebSessionWithPermissions (already fetched via getSessionWithPermissions). A new hasAnyAdminPermission() helper in session.ts returns true if the session holds at least one of the five admin permissions. The page renders navigation cards filtered to the user's actual permissions; no API call is needed on load — data comes entirely from WebSession.permissions.
Tech Stack: Hono (web server + JSX), Vitest, WebSessionWithPermissions from apps/web/src/lib/session.ts, existing BaseLayout / Card / PageHeader components.
Task 1: Add hasAnyAdminPermission() helper to session.ts#
Files:
- Modify:
apps/web/src/lib/session.ts - Test: (added in existing session tests or inline in admin test — addressed in Task 3)
Step 1: Add the exported function at the end of apps/web/src/lib/session.ts
/** Permission strings that constitute "any admin access". */
const ADMIN_PERMISSIONS = [
"space.atbb.permission.manageMembers",
"space.atbb.permission.manageCategories",
"space.atbb.permission.moderatePosts",
"space.atbb.permission.banUsers",
"space.atbb.permission.lockTopics",
] as const;
/**
* Returns true if the session grants at least one admin or mod permission,
* or the wildcard "*". Used to gate the /admin landing page.
*/
export function hasAnyAdminPermission(
auth: WebSessionWithPermissions
): boolean {
if (!auth.authenticated) return false;
if (auth.permissions.has("*")) return true;
return ADMIN_PERMISSIONS.some((p) => auth.permissions.has(p));
}
Step 2: Confirm the build still type-checks
cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec tsc --noEmit
Expected: no errors.
Step 3: Commit
git add apps/web/src/lib/session.ts
git commit -m "feat(web): add hasAnyAdminPermission() helper to session.ts (ATB-42)"
Task 2: Create apps/web/src/routes/admin.tsx with GET /admin#
Files:
- Create:
apps/web/src/routes/admin.tsx
Step 1: Write the route file
import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import { PageHeader, Card } from "../components/index.js";
import { getSessionWithPermissions, hasAnyAdminPermission } from "../lib/session.js";
// ─── Permission helpers ───────────────────────────────────────────────────
/** Returns true if the session grants manageMembers. */
function canManageMembers(auth: { authenticated: boolean; permissions: Set<string> }): boolean {
return (
auth.authenticated &&
(auth.permissions.has("space.atbb.permission.manageMembers") ||
auth.permissions.has("*"))
);
}
/** Returns true if the session grants manageCategories. */
function canManageCategories(auth: { authenticated: boolean; permissions: Set<string> }): boolean {
return (
auth.authenticated &&
(auth.permissions.has("space.atbb.permission.manageCategories") ||
auth.permissions.has("*"))
);
}
/** Returns true if the session grants any moderation permission. */
function canViewModLog(auth: { authenticated: boolean; permissions: Set<string> }): boolean {
return (
auth.authenticated &&
(auth.permissions.has("space.atbb.permission.moderatePosts") ||
auth.permissions.has("space.atbb.permission.banUsers") ||
auth.permissions.has("space.atbb.permission.lockTopics") ||
auth.permissions.has("*"))
);
}
// ─── Route ────────────────────────────────────────────────────────────────
export function createAdminRoutes(appviewUrl: string) {
return new Hono().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>
);
});
}
Step 2: Register the route in apps/web/src/routes/index.ts
Add the import and .route() call before createNotFoundRoute (which must remain last):
import { createAdminRoutes } from "./admin.js";
// ...
export const webRoutes = new Hono()
.route("/", createHomeRoutes(config.appviewUrl))
.route("/", createBoardsRoutes(config.appviewUrl))
.route("/", createTopicsRoutes(config.appviewUrl))
.route("/", createLoginRoutes(config.appviewUrl))
.route("/", createNewTopicRoutes(config.appviewUrl))
.route("/", createAuthRoutes(config.appviewUrl))
.route("/", createModActionRoute(config.appviewUrl))
.route("/", createAdminRoutes(config.appviewUrl)) // ← add this line
.route("/", createNotFoundRoute(config.appviewUrl));
Step 3: Confirm the build type-checks
cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec tsc --noEmit
Expected: no errors.
Step 4: Commit
git add apps/web/src/routes/admin.tsx apps/web/src/routes/index.ts
git commit -m "feat(web): add GET /admin landing page with permission-gated nav cards (ATB-42)"
Task 3: Write tests in apps/web/src/routes/__tests__/admin.test.tsx#
This covers all ATB-42 acceptance criteria. Subsequent ATB issues will add tests for
/admin/members,/admin/structure, and/admin/modlogin the same file.
Files:
- Create:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests first
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockFetch = vi.fn();
// ─── Helpers ──────────────────────────────────────────────────────────────
/** Build a mock fetch response */
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok,
status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
/**
* Sets up the fetch mock for a session with specific permissions.
*
* Fetch call order for an authenticated user:
* 1. GET /api/auth/session → { authenticated: true, did, handle }
* 2. GET /api/admin/members/me → { permissions: [...] }
*/
function setupAuthenticatedSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
/** Sets up fetch mock for an unauthenticated session. */
function setupUnauthenticated() {
// getSession short-circuits if no atbb_session cookie, so no fetch needed.
// Tests that pass no cookie header exercise this path without mock setup.
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
// ─── Suite ────────────────────────────────────────────────────────────────
describe("createAdminRoutes — GET /admin", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
// ── Unauthenticated ─────────────────────────────────────────────────────
it("redirects unauthenticated users to /login", async () => {
// No cookie → getSession returns unauthenticated without a fetch
const routes = await loadAdminRoutes();
const res = await routes.request("/admin");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
// ── No admin permissions ─────────────────────────────────────────────────
it("returns 403 for authenticated user with no admin permissions", async () => {
setupAuthenticatedSession([]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(403);
const html = await res.text();
expect(html).toContain("Access Denied");
});
it("returns 403 for authenticated user with only an unrelated permission", async () => {
setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(403);
});
// ── Wildcard permission ──────────────────────────────────────────────────
it("grants access and shows all cards for wildcard (*) permission", async () => {
setupAuthenticatedSession(["*"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Members");
expect(html).toContain("Structure");
expect(html).toContain("Mod Log");
expect(html).toContain('href="/admin/members"');
expect(html).toContain('href="/admin/structure"');
expect(html).toContain('href="/admin/modlog"');
});
// ── Single permission — only that card shown ─────────────────────────────
it("shows only the Mod Log card for a user with only moderatePosts", async () => {
setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Mod Log");
expect(html).not.toContain('href="/admin/members"');
expect(html).not.toContain('href="/admin/structure"');
expect(html).toContain('href="/admin/modlog"');
});
it("shows only the Mod Log card for a user with only banUsers", async () => {
setupAuthenticatedSession(["space.atbb.permission.banUsers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).not.toContain('href="/admin/members"');
expect(html).toContain('href="/admin/modlog"');
});
it("shows only the Mod Log card for a user with only lockTopics", async () => {
setupAuthenticatedSession(["space.atbb.permission.lockTopics"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).not.toContain('href="/admin/members"');
expect(html).toContain('href="/admin/modlog"');
});
it("shows only the Members card for a user with only manageMembers", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('href="/admin/members"');
expect(html).not.toContain('href="/admin/structure"');
expect(html).not.toContain('href="/admin/modlog"');
});
it("shows only the Structure card for a user with only manageCategories", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageCategories"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).not.toContain('href="/admin/members"');
expect(html).toContain('href="/admin/structure"');
expect(html).not.toContain('href="/admin/modlog"');
});
// ── Multi-permission combos ──────────────────────────────────────────────
it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => {
setupAuthenticatedSession([
"space.atbb.permission.manageMembers",
"space.atbb.permission.moderatePosts",
]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('href="/admin/members"');
expect(html).not.toContain('href="/admin/structure"');
expect(html).toContain('href="/admin/modlog"');
});
// ── Page structure ───────────────────────────────────────────────────────
it("renders page title 'Admin Panel'", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("Admin Panel");
});
it("renders admin-nav-grid container", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("admin-nav-grid");
});
});
Step 2: Run the tests and confirm they fail (no implementation yet in this step — if running Task 3 before Task 2, skip to Step 3)
cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec vitest run src/routes/__tests__/admin.test.tsx
Expected: FAIL — Cannot find module '../admin.js' (or similar).
Once Task 2 is done, all tests should pass.
Step 3: Run the full test suite
PATH=/path/to/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: all pass, including new admin tests.
Step 4: Commit
git add apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "test(web): add admin landing page route tests (ATB-42)"
Task 4: Add CSS for admin nav grid in theme.css#
Files:
- Modify:
apps/web/public/static/css/theme.css
Step 1: Append the admin panel styles at the end of the file
/* ─── Admin Panel ───────────────────────────────────────────────────────── */
.admin-nav-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-md);
margin-top: var(--space-lg);
}
.admin-nav-card {
text-decoration: none;
color: inherit;
display: block;
}
.admin-nav-card:hover .card {
border-color: var(--color-primary);
}
.admin-nav-card__icon {
font-size: var(--font-size-xl, 2rem);
margin-bottom: var(--space-sm);
}
.admin-nav-card__title {
font-family: var(--font-heading);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
margin-bottom: var(--space-xs);
}
.admin-nav-card__description {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
Step 2: Commit
git add apps/web/public/static/css/theme.css
git commit -m "style(web): add admin nav grid CSS (ATB-42)"
Task 5: Final verification#
Step 1: Run the full web package test suite
PATH=/path/to/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test
Expected: all tests pass.
Step 2: Type-check the web package
cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec tsc --noEmit
Expected: no errors.
Step 3: Update Linear
Set ATB-42 status → In Progress at start, Done when complete. Add a comment:
Implemented GET /admin with hasAnyAdminPermission() helper, permission-gated nav cards, CSS, and full test coverage.
Notes#
- Permission strings — all use full namespace:
space.atbb.permission.<name>. Do NOT use short names like"manageMembers". See existing helpers insession.ts(e.g.canLockTopics) for the pattern. - Wildcard —
"*"grants all permissions; every helper must check for it alongside named permissions. WebSessionWithPermissionsimport — already exported fromapps/web/src/lib/session.ts; no new types needed.BaseLayoutauth prop — acceptsWebSession, whichWebSessionWithPermissionssatisfies (it extends it withpermissions).- NotFoundRoute must remain last in
routes/index.ts— it catches all unmatched paths. - No API call on admin landing — the design explicitly states that card visibility comes from
WebSession.permissions, not a fresh API fetch.