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

feat(web): admin panel landing page and routing infrastructure (ATB-42) (#72)

* docs: ATB-42 admin panel landing page implementation plan

* feat(web): add hasAnyAdminPermission() helper to session.ts (ATB-42)

* test(web): add hasAnyAdminPermission tests + tighten JSDoc (ATB-42)

* feat(web): add GET /admin landing page with permission-gated nav cards (ATB-42)

* refactor(web): move canManageMembers/canManageCategories/canViewModLog to session.ts (ATB-42)

* test(web): add admin landing page route tests (ATB-42)

* test(web): add missing structure-absent assertions for banUsers/lockTopics (ATB-42)

* style(web): add admin nav grid CSS (ATB-42)

* docs: add admin panel UI preview screenshot (ATB-42)

* fix(web): address minor code review feedback on ATB-42 admin panel

- Use var(--font-size-xl, 2rem) for admin card icon (CSS token consistency)
- Add banUsers and lockTopics test cases for canViewModLog helper
- Move plan doc to docs/plans/complete/

authored by

Malpercio and committed by
GitHub
93acac75 e7f57337

+969 -1
+36
apps/web/public/static/css/theme.css
··· 910 910 display: block; 911 911 } 912 912 } 913 + 914 + /* ─── Admin Panel ───────────────────────────────────────────────────────── */ 915 + 916 + .admin-nav-grid { 917 + display: grid; 918 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 919 + gap: var(--space-md); 920 + margin-top: var(--space-lg); 921 + } 922 + 923 + .admin-nav-card { 924 + text-decoration: none; 925 + color: inherit; 926 + display: block; 927 + } 928 + 929 + .admin-nav-card:hover .card { 930 + border-color: var(--color-primary); 931 + } 932 + 933 + .admin-nav-card__icon { 934 + font-size: var(--font-size-xl, 2rem); 935 + margin-bottom: var(--space-sm); 936 + } 937 + 938 + .admin-nav-card__title { 939 + font-family: var(--font-heading); 940 + font-weight: var(--font-weight-bold); 941 + font-size: var(--font-size-lg); 942 + margin-bottom: var(--space-xs); 943 + } 944 + 945 + .admin-nav-card__description { 946 + color: var(--color-text-muted); 947 + font-size: var(--font-size-sm); 948 + }
+78 -1
apps/web/src/lib/__tests__/session.test.ts
··· 1 1 import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 - import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers } from "../session.js"; 2 + import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog } from "../session.js"; 3 3 import { logger } from "../logger.js"; 4 4 5 5 vi.mock("../logger.js", () => ({ ··· 324 324 expect(canModeratePosts(ownerSession)).toBe(true)); 325 325 it("canBanUsers returns true for owner with wildcard permission", () => 326 326 expect(canBanUsers(ownerSession)).toBe(true)); 327 + 328 + const makeSinglePermSessionHelper = (permission: string) => ({ 329 + authenticated: true as const, 330 + did: "did:plc:user", 331 + handle: "user.bsky.social", 332 + permissions: new Set([permission]), 333 + }); 334 + 335 + it("canManageMembers returns true for user with manageMembers", () => 336 + expect(canManageMembers(makeSinglePermSessionHelper("space.atbb.permission.manageMembers"))).toBe(true)); 337 + it("canManageMembers returns false for member with no permissions", () => 338 + expect(canManageMembers(memberSession)).toBe(false)); 339 + it("canManageMembers returns true for owner with wildcard", () => 340 + expect(canManageMembers(ownerSession)).toBe(true)); 341 + 342 + it("canManageCategories returns true for user with manageCategories", () => 343 + expect(canManageCategories(makeSinglePermSessionHelper("space.atbb.permission.manageCategories"))).toBe(true)); 344 + it("canManageCategories returns false for member with no permissions", () => 345 + expect(canManageCategories(memberSession)).toBe(false)); 346 + it("canManageCategories returns true for owner with wildcard", () => 347 + expect(canManageCategories(ownerSession)).toBe(true)); 348 + 349 + it("canViewModLog returns true for user with moderatePosts", () => 350 + expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.moderatePosts"))).toBe(true)); 351 + it("canViewModLog returns true for user with banUsers", () => 352 + expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.banUsers"))).toBe(true)); 353 + it("canViewModLog returns true for user with lockTopics", () => 354 + expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.lockTopics"))).toBe(true)); 355 + it("canViewModLog returns false for member with no permissions", () => 356 + expect(canViewModLog(memberSession)).toBe(false)); 357 + it("canViewModLog returns true for owner with wildcard", () => 358 + expect(canViewModLog(ownerSession)).toBe(true)); 359 + }); 360 + 361 + describe("hasAnyAdminPermission", () => { 362 + const unauthSession = { authenticated: false as const, permissions: new Set<string>() }; 363 + 364 + const noPermSession = { 365 + authenticated: true as const, 366 + did: "did:plc:member", 367 + handle: "member.bsky.social", 368 + permissions: new Set<string>(), 369 + }; 370 + 371 + const makeSinglePermSession = (permission: string) => ({ 372 + authenticated: true as const, 373 + did: "did:plc:user", 374 + handle: "user.bsky.social", 375 + permissions: new Set([permission]), 376 + }); 377 + 378 + it("returns false for unauthenticated session", () => 379 + expect(hasAnyAdminPermission(unauthSession)).toBe(false)); 380 + 381 + it("returns false for authenticated user with no permissions", () => 382 + expect(hasAnyAdminPermission(noPermSession)).toBe(false)); 383 + 384 + it("returns true for user with manageMembers permission", () => 385 + expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageMembers"))).toBe(true)); 386 + 387 + it("returns true for user with manageCategories permission", () => 388 + expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageCategories"))).toBe(true)); 389 + 390 + it("returns true for user with moderatePosts permission", () => 391 + expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.moderatePosts"))).toBe(true)); 392 + 393 + it("returns true for user with banUsers permission", () => 394 + expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.banUsers"))).toBe(true)); 395 + 396 + it("returns true for user with lockTopics permission", () => 397 + expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.lockTopics"))).toBe(true)); 398 + 399 + it("returns true for user with wildcard permission", () => 400 + expect(hasAnyAdminPermission(makeSinglePermSession("*"))).toBe(true)); 401 + 402 + it("returns false for user with only an unrelated permission", () => 403 + expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.someOtherThing"))).toBe(false)); 327 404 });
+50
apps/web/src/lib/session.ts
··· 147 147 auth.permissions.has("*")) 148 148 ); 149 149 } 150 + 151 + /** Permission strings that constitute "any admin access". */ 152 + const ADMIN_PERMISSIONS = [ 153 + "space.atbb.permission.manageMembers", 154 + "space.atbb.permission.manageCategories", 155 + "space.atbb.permission.moderatePosts", 156 + "space.atbb.permission.banUsers", 157 + "space.atbb.permission.lockTopics", 158 + ] as const; 159 + 160 + /** 161 + * Returns true if the session grants at least one of the admin panel permissions 162 + * listed in ADMIN_PERMISSIONS, or the wildcard "*". Used to gate the /admin landing page. 163 + */ 164 + export function hasAnyAdminPermission( 165 + auth: WebSessionWithPermissions 166 + ): boolean { 167 + if (!auth.authenticated) return false; 168 + if (auth.permissions.has("*")) return true; 169 + return ADMIN_PERMISSIONS.some((p) => auth.permissions.has(p)); 170 + } 171 + 172 + /** Returns true if the session grants permission to manage forum members. */ 173 + export function canManageMembers(auth: WebSessionWithPermissions): boolean { 174 + return ( 175 + auth.authenticated && 176 + (auth.permissions.has("space.atbb.permission.manageMembers") || 177 + auth.permissions.has("*")) 178 + ); 179 + } 180 + 181 + /** Returns true if the session grants permission to manage forum categories and boards. */ 182 + export function canManageCategories(auth: WebSessionWithPermissions): boolean { 183 + return ( 184 + auth.authenticated && 185 + (auth.permissions.has("space.atbb.permission.manageCategories") || 186 + auth.permissions.has("*")) 187 + ); 188 + } 189 + 190 + /** Returns true if the session grants any moderation permission (view mod log). */ 191 + export function canViewModLog(auth: WebSessionWithPermissions): boolean { 192 + return ( 193 + auth.authenticated && 194 + (auth.permissions.has("space.atbb.permission.moderatePosts") || 195 + auth.permissions.has("space.atbb.permission.banUsers") || 196 + auth.permissions.has("space.atbb.permission.lockTopics") || 197 + auth.permissions.has("*")) 198 + ); 199 + }
+197
apps/web/src/routes/__tests__/admin.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createAdminRoutes — GET /admin", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + }); 11 + 12 + afterEach(() => { 13 + vi.unstubAllGlobals(); 14 + vi.unstubAllEnvs(); 15 + mockFetch.mockReset(); 16 + }); 17 + 18 + function mockResponse(body: unknown, ok = true, status = 200) { 19 + return { 20 + ok, 21 + status, 22 + statusText: ok ? "OK" : "Error", 23 + json: () => Promise.resolve(body), 24 + }; 25 + } 26 + 27 + /** 28 + * Sets up the two-fetch mock sequence for an authenticated session. 29 + * Call 1: GET /api/auth/session 30 + * Call 2: GET /api/admin/members/me 31 + */ 32 + function setupAuthenticatedSession(permissions: string[]) { 33 + mockFetch.mockResolvedValueOnce( 34 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 35 + ); 36 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 37 + } 38 + 39 + async function loadAdminRoutes() { 40 + const { createAdminRoutes } = await import("../admin.js"); 41 + return createAdminRoutes("http://localhost:3000"); 42 + } 43 + 44 + // ── Unauthenticated ───────────────────────────────────────────────────── 45 + 46 + it("redirects unauthenticated users to /login", async () => { 47 + // No atbb_session cookie → zero fetch calls 48 + const routes = await loadAdminRoutes(); 49 + const res = await routes.request("/admin"); 50 + expect(res.status).toBe(302); 51 + expect(res.headers.get("location")).toBe("/login"); 52 + }); 53 + 54 + // ── No admin permissions → 403 ────────────────────────────────────────── 55 + 56 + it("returns 403 for authenticated user with no permissions", async () => { 57 + setupAuthenticatedSession([]); 58 + const routes = await loadAdminRoutes(); 59 + const res = await routes.request("/admin", { 60 + headers: { cookie: "atbb_session=token" }, 61 + }); 62 + expect(res.status).toBe(403); 63 + const html = await res.text(); 64 + expect(html).toContain("Access Denied"); 65 + }); 66 + 67 + it("returns 403 for authenticated user with only an unrelated permission", async () => { 68 + setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]); 69 + const routes = await loadAdminRoutes(); 70 + const res = await routes.request("/admin", { 71 + headers: { cookie: "atbb_session=token" }, 72 + }); 73 + expect(res.status).toBe(403); 74 + }); 75 + 76 + // ── Wildcard → all cards ───────────────────────────────────────────────── 77 + 78 + it("grants access and shows all cards for wildcard (*) permission", async () => { 79 + setupAuthenticatedSession(["*"]); 80 + const routes = await loadAdminRoutes(); 81 + const res = await routes.request("/admin", { 82 + headers: { cookie: "atbb_session=token" }, 83 + }); 84 + expect(res.status).toBe(200); 85 + const html = await res.text(); 86 + expect(html).toContain('href="/admin/members"'); 87 + expect(html).toContain('href="/admin/structure"'); 88 + expect(html).toContain('href="/admin/modlog"'); 89 + }); 90 + 91 + // ── Single permission → only that card ────────────────────────────────── 92 + 93 + it("shows only Members card for user with only manageMembers", async () => { 94 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 95 + const routes = await loadAdminRoutes(); 96 + const res = await routes.request("/admin", { 97 + headers: { cookie: "atbb_session=token" }, 98 + }); 99 + expect(res.status).toBe(200); 100 + const html = await res.text(); 101 + expect(html).toContain('href="/admin/members"'); 102 + expect(html).not.toContain('href="/admin/structure"'); 103 + expect(html).not.toContain('href="/admin/modlog"'); 104 + }); 105 + 106 + it("shows only Structure card for user with only manageCategories", async () => { 107 + setupAuthenticatedSession(["space.atbb.permission.manageCategories"]); 108 + const routes = await loadAdminRoutes(); 109 + const res = await routes.request("/admin", { 110 + headers: { cookie: "atbb_session=token" }, 111 + }); 112 + expect(res.status).toBe(200); 113 + const html = await res.text(); 114 + expect(html).not.toContain('href="/admin/members"'); 115 + expect(html).toContain('href="/admin/structure"'); 116 + expect(html).not.toContain('href="/admin/modlog"'); 117 + }); 118 + 119 + it("shows only Mod Log card for user with only moderatePosts", async () => { 120 + setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]); 121 + const routes = await loadAdminRoutes(); 122 + const res = await routes.request("/admin", { 123 + headers: { cookie: "atbb_session=token" }, 124 + }); 125 + expect(res.status).toBe(200); 126 + const html = await res.text(); 127 + expect(html).not.toContain('href="/admin/members"'); 128 + expect(html).not.toContain('href="/admin/structure"'); 129 + expect(html).toContain('href="/admin/modlog"'); 130 + }); 131 + 132 + it("shows only Mod Log card for user with only banUsers", async () => { 133 + setupAuthenticatedSession(["space.atbb.permission.banUsers"]); 134 + const routes = await loadAdminRoutes(); 135 + const res = await routes.request("/admin", { 136 + headers: { cookie: "atbb_session=token" }, 137 + }); 138 + expect(res.status).toBe(200); 139 + const html = await res.text(); 140 + expect(html).not.toContain('href="/admin/members"'); 141 + expect(html).not.toContain('href="/admin/structure"'); 142 + expect(html).toContain('href="/admin/modlog"'); 143 + }); 144 + 145 + it("shows only Mod Log card for user with only lockTopics", async () => { 146 + setupAuthenticatedSession(["space.atbb.permission.lockTopics"]); 147 + const routes = await loadAdminRoutes(); 148 + const res = await routes.request("/admin", { 149 + headers: { cookie: "atbb_session=token" }, 150 + }); 151 + expect(res.status).toBe(200); 152 + const html = await res.text(); 153 + expect(html).not.toContain('href="/admin/members"'); 154 + expect(html).not.toContain('href="/admin/structure"'); 155 + expect(html).toContain('href="/admin/modlog"'); 156 + }); 157 + 158 + // ── Multi-permission combos ────────────────────────────────────────────── 159 + 160 + it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => { 161 + setupAuthenticatedSession([ 162 + "space.atbb.permission.manageMembers", 163 + "space.atbb.permission.moderatePosts", 164 + ]); 165 + const routes = await loadAdminRoutes(); 166 + const res = await routes.request("/admin", { 167 + headers: { cookie: "atbb_session=token" }, 168 + }); 169 + expect(res.status).toBe(200); 170 + const html = await res.text(); 171 + expect(html).toContain('href="/admin/members"'); 172 + expect(html).not.toContain('href="/admin/structure"'); 173 + expect(html).toContain('href="/admin/modlog"'); 174 + }); 175 + 176 + // ── Page structure ─────────────────────────────────────────────────────── 177 + 178 + it("renders 'Admin Panel' page title", async () => { 179 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 180 + const routes = await loadAdminRoutes(); 181 + const res = await routes.request("/admin", { 182 + headers: { cookie: "atbb_session=token" }, 183 + }); 184 + const html = await res.text(); 185 + expect(html).toContain("Admin Panel"); 186 + }); 187 + 188 + it("renders admin-nav-grid container", async () => { 189 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 190 + const routes = await loadAdminRoutes(); 191 + const res = await routes.request("/admin", { 192 + headers: { cookie: "atbb_session=token" }, 193 + }); 194 + const html = await res.text(); 195 + expect(html).toContain("admin-nav-grid"); 196 + }); 197 + });
+65
apps/web/src/routes/admin.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader, Card } from "../components/index.js"; 4 + import { getSessionWithPermissions, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog } from "../lib/session.js"; 5 + 6 + // ─── Route ──────────────────────────────────────────────────────────────── 7 + 8 + export function createAdminRoutes(appviewUrl: string) { 9 + return new Hono().get("/admin", async (c) => { 10 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 11 + 12 + if (!auth.authenticated) { 13 + return c.redirect("/login"); 14 + } 15 + 16 + if (!hasAnyAdminPermission(auth)) { 17 + return c.html( 18 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 19 + <PageHeader title="Access Denied" /> 20 + <p>You don&apos;t have permission to access the admin panel.</p> 21 + </BaseLayout>, 22 + 403 23 + ); 24 + } 25 + 26 + const showMembers = canManageMembers(auth); 27 + const showStructure = canManageCategories(auth); 28 + const showModLog = canViewModLog(auth); 29 + 30 + return c.html( 31 + <BaseLayout title="Admin Panel — atBB Forum" auth={auth}> 32 + <PageHeader title="Admin Panel" /> 33 + <div class="admin-nav-grid"> 34 + {showMembers && ( 35 + <a href="/admin/members" class="admin-nav-card"> 36 + <Card> 37 + <p class="admin-nav-card__icon" aria-hidden="true">👥</p> 38 + <p class="admin-nav-card__title">Members</p> 39 + <p class="admin-nav-card__description">View and assign member roles</p> 40 + </Card> 41 + </a> 42 + )} 43 + {showStructure && ( 44 + <a href="/admin/structure" class="admin-nav-card"> 45 + <Card> 46 + <p class="admin-nav-card__icon" aria-hidden="true">📁</p> 47 + <p class="admin-nav-card__title">Structure</p> 48 + <p class="admin-nav-card__description">Manage categories and boards</p> 49 + </Card> 50 + </a> 51 + )} 52 + {showModLog && ( 53 + <a href="/admin/modlog" class="admin-nav-card"> 54 + <Card> 55 + <p class="admin-nav-card__icon" aria-hidden="true">📋</p> 56 + <p class="admin-nav-card__title">Mod Log</p> 57 + <p class="admin-nav-card__description">Audit trail of moderation actions</p> 58 + </Card> 59 + </a> 60 + )} 61 + </div> 62 + </BaseLayout> 63 + ); 64 + }); 65 + }
+2
apps/web/src/routes/index.ts
··· 7 7 import { createNewTopicRoutes } from "./new-topic.js"; 8 8 import { createAuthRoutes } from "./auth.js"; 9 9 import { createModActionRoute } from "./mod.js"; 10 + import { createAdminRoutes } from "./admin.js"; 10 11 import { createNotFoundRoute } from "./not-found.js"; 11 12 12 13 const config = loadConfig(); ··· 19 20 .route("/", createNewTopicRoutes(config.appviewUrl)) 20 21 .route("/", createAuthRoutes(config.appviewUrl)) 21 22 .route("/", createModActionRoute(config.appviewUrl)) 23 + .route("/", createAdminRoutes(config.appviewUrl)) 22 24 .route("/", createNotFoundRoute(config.appviewUrl));
+541
docs/plans/complete/2026-02-27-atb-42-admin-landing.md
··· 1 + # ATB-42: Admin Panel Landing Page Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **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). 6 + 7 + **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`. 8 + 9 + **Tech Stack:** Hono (web server + JSX), Vitest, `WebSessionWithPermissions` from `apps/web/src/lib/session.ts`, existing `BaseLayout` / `Card` / `PageHeader` components. 10 + 11 + --- 12 + 13 + ### Task 1: Add `hasAnyAdminPermission()` helper to `session.ts` 14 + 15 + **Files:** 16 + - Modify: `apps/web/src/lib/session.ts` 17 + - Test: (added in existing session tests or inline in admin test — addressed in Task 3) 18 + 19 + **Step 1: Add the exported function at the end of `apps/web/src/lib/session.ts`** 20 + 21 + ```typescript 22 + /** Permission strings that constitute "any admin access". */ 23 + const ADMIN_PERMISSIONS = [ 24 + "space.atbb.permission.manageMembers", 25 + "space.atbb.permission.manageCategories", 26 + "space.atbb.permission.moderatePosts", 27 + "space.atbb.permission.banUsers", 28 + "space.atbb.permission.lockTopics", 29 + ] as const; 30 + 31 + /** 32 + * Returns true if the session grants at least one admin or mod permission, 33 + * or the wildcard "*". Used to gate the /admin landing page. 34 + */ 35 + export function hasAnyAdminPermission( 36 + auth: WebSessionWithPermissions 37 + ): boolean { 38 + if (!auth.authenticated) return false; 39 + if (auth.permissions.has("*")) return true; 40 + return ADMIN_PERMISSIONS.some((p) => auth.permissions.has(p)); 41 + } 42 + ``` 43 + 44 + **Step 2: Confirm the build still type-checks** 45 + 46 + ```sh 47 + cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec tsc --noEmit 48 + ``` 49 + 50 + Expected: no errors. 51 + 52 + **Step 3: Commit** 53 + 54 + ```sh 55 + git add apps/web/src/lib/session.ts 56 + git commit -m "feat(web): add hasAnyAdminPermission() helper to session.ts (ATB-42)" 57 + ``` 58 + 59 + --- 60 + 61 + ### Task 2: Create `apps/web/src/routes/admin.tsx` with `GET /admin` 62 + 63 + **Files:** 64 + - Create: `apps/web/src/routes/admin.tsx` 65 + 66 + **Step 1: Write the route file** 67 + 68 + ```tsx 69 + import { Hono } from "hono"; 70 + import { BaseLayout } from "../layouts/base.js"; 71 + import { PageHeader, Card } from "../components/index.js"; 72 + import { getSessionWithPermissions, hasAnyAdminPermission } from "../lib/session.js"; 73 + 74 + // ─── Permission helpers ─────────────────────────────────────────────────── 75 + 76 + /** Returns true if the session grants manageMembers. */ 77 + function canManageMembers(auth: { authenticated: boolean; permissions: Set<string> }): boolean { 78 + return ( 79 + auth.authenticated && 80 + (auth.permissions.has("space.atbb.permission.manageMembers") || 81 + auth.permissions.has("*")) 82 + ); 83 + } 84 + 85 + /** Returns true if the session grants manageCategories. */ 86 + function canManageCategories(auth: { authenticated: boolean; permissions: Set<string> }): boolean { 87 + return ( 88 + auth.authenticated && 89 + (auth.permissions.has("space.atbb.permission.manageCategories") || 90 + auth.permissions.has("*")) 91 + ); 92 + } 93 + 94 + /** Returns true if the session grants any moderation permission. */ 95 + function canViewModLog(auth: { authenticated: boolean; permissions: Set<string> }): boolean { 96 + return ( 97 + auth.authenticated && 98 + (auth.permissions.has("space.atbb.permission.moderatePosts") || 99 + auth.permissions.has("space.atbb.permission.banUsers") || 100 + auth.permissions.has("space.atbb.permission.lockTopics") || 101 + auth.permissions.has("*")) 102 + ); 103 + } 104 + 105 + // ─── Route ──────────────────────────────────────────────────────────────── 106 + 107 + export function createAdminRoutes(appviewUrl: string) { 108 + return new Hono().get("/admin", async (c) => { 109 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 110 + 111 + if (!auth.authenticated) { 112 + return c.redirect("/login"); 113 + } 114 + 115 + if (!hasAnyAdminPermission(auth)) { 116 + return c.html( 117 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 118 + <PageHeader title="Access Denied" /> 119 + <p>You don't have permission to access the admin panel.</p> 120 + </BaseLayout>, 121 + 403 122 + ); 123 + } 124 + 125 + const showMembers = canManageMembers(auth); 126 + const showStructure = canManageCategories(auth); 127 + const showModLog = canViewModLog(auth); 128 + 129 + return c.html( 130 + <BaseLayout title="Admin Panel — atBB Forum" auth={auth}> 131 + <PageHeader title="Admin Panel" /> 132 + <div class="admin-nav-grid"> 133 + {showMembers && ( 134 + <a href="/admin/members" class="admin-nav-card"> 135 + <Card> 136 + <p class="admin-nav-card__icon" aria-hidden="true">👥</p> 137 + <p class="admin-nav-card__title">Members</p> 138 + <p class="admin-nav-card__description">View and assign member roles</p> 139 + </Card> 140 + </a> 141 + )} 142 + {showStructure && ( 143 + <a href="/admin/structure" class="admin-nav-card"> 144 + <Card> 145 + <p class="admin-nav-card__icon" aria-hidden="true">📁</p> 146 + <p class="admin-nav-card__title">Structure</p> 147 + <p class="admin-nav-card__description">Manage categories and boards</p> 148 + </Card> 149 + </a> 150 + )} 151 + {showModLog && ( 152 + <a href="/admin/modlog" class="admin-nav-card"> 153 + <Card> 154 + <p class="admin-nav-card__icon" aria-hidden="true">📋</p> 155 + <p class="admin-nav-card__title">Mod Log</p> 156 + <p class="admin-nav-card__description">Audit trail of moderation actions</p> 157 + </Card> 158 + </a> 159 + )} 160 + </div> 161 + </BaseLayout> 162 + ); 163 + }); 164 + } 165 + ``` 166 + 167 + **Step 2: Register the route in `apps/web/src/routes/index.ts`** 168 + 169 + Add the import and `.route()` call **before** `createNotFoundRoute` (which must remain last): 170 + 171 + ```typescript 172 + import { createAdminRoutes } from "./admin.js"; 173 + 174 + // ... 175 + export const webRoutes = new Hono() 176 + .route("/", createHomeRoutes(config.appviewUrl)) 177 + .route("/", createBoardsRoutes(config.appviewUrl)) 178 + .route("/", createTopicsRoutes(config.appviewUrl)) 179 + .route("/", createLoginRoutes(config.appviewUrl)) 180 + .route("/", createNewTopicRoutes(config.appviewUrl)) 181 + .route("/", createAuthRoutes(config.appviewUrl)) 182 + .route("/", createModActionRoute(config.appviewUrl)) 183 + .route("/", createAdminRoutes(config.appviewUrl)) // ← add this line 184 + .route("/", createNotFoundRoute(config.appviewUrl)); 185 + ``` 186 + 187 + **Step 3: Confirm the build type-checks** 188 + 189 + ```sh 190 + cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec tsc --noEmit 191 + ``` 192 + 193 + Expected: no errors. 194 + 195 + **Step 4: Commit** 196 + 197 + ```sh 198 + git add apps/web/src/routes/admin.tsx apps/web/src/routes/index.ts 199 + git commit -m "feat(web): add GET /admin landing page with permission-gated nav cards (ATB-42)" 200 + ``` 201 + 202 + --- 203 + 204 + ### Task 3: Write tests in `apps/web/src/routes/__tests__/admin.test.tsx` 205 + 206 + > This covers all ATB-42 acceptance criteria. Subsequent ATB issues will add tests for `/admin/members`, `/admin/structure`, and `/admin/modlog` in the same file. 207 + 208 + **Files:** 209 + - Create: `apps/web/src/routes/__tests__/admin.test.tsx` 210 + 211 + **Step 1: Write the failing tests first** 212 + 213 + ```tsx 214 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 215 + 216 + const mockFetch = vi.fn(); 217 + 218 + // ─── Helpers ────────────────────────────────────────────────────────────── 219 + 220 + /** Build a mock fetch response */ 221 + function mockResponse(body: unknown, ok = true, status = 200) { 222 + return { 223 + ok, 224 + status, 225 + statusText: ok ? "OK" : "Error", 226 + json: () => Promise.resolve(body), 227 + }; 228 + } 229 + 230 + /** 231 + * Sets up the fetch mock for a session with specific permissions. 232 + * 233 + * Fetch call order for an authenticated user: 234 + * 1. GET /api/auth/session → { authenticated: true, did, handle } 235 + * 2. GET /api/admin/members/me → { permissions: [...] } 236 + */ 237 + function setupAuthenticatedSession(permissions: string[]) { 238 + mockFetch.mockResolvedValueOnce( 239 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 240 + ); 241 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 242 + } 243 + 244 + /** Sets up fetch mock for an unauthenticated session. */ 245 + function setupUnauthenticated() { 246 + // getSession short-circuits if no atbb_session cookie, so no fetch needed. 247 + // Tests that pass no cookie header exercise this path without mock setup. 248 + } 249 + 250 + async function loadAdminRoutes() { 251 + const { createAdminRoutes } = await import("../admin.js"); 252 + return createAdminRoutes("http://localhost:3000"); 253 + } 254 + 255 + // ─── Suite ──────────────────────────────────────────────────────────────── 256 + 257 + describe("createAdminRoutes — GET /admin", () => { 258 + beforeEach(() => { 259 + vi.stubGlobal("fetch", mockFetch); 260 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 261 + vi.resetModules(); 262 + }); 263 + 264 + afterEach(() => { 265 + vi.unstubAllGlobals(); 266 + vi.unstubAllEnvs(); 267 + mockFetch.mockReset(); 268 + }); 269 + 270 + // ── Unauthenticated ───────────────────────────────────────────────────── 271 + 272 + it("redirects unauthenticated users to /login", async () => { 273 + // No cookie → getSession returns unauthenticated without a fetch 274 + const routes = await loadAdminRoutes(); 275 + const res = await routes.request("/admin"); 276 + expect(res.status).toBe(302); 277 + expect(res.headers.get("location")).toBe("/login"); 278 + }); 279 + 280 + // ── No admin permissions ───────────────────────────────────────────────── 281 + 282 + it("returns 403 for authenticated user with no admin permissions", async () => { 283 + setupAuthenticatedSession([]); 284 + const routes = await loadAdminRoutes(); 285 + const res = await routes.request("/admin", { 286 + headers: { cookie: "atbb_session=token" }, 287 + }); 288 + expect(res.status).toBe(403); 289 + const html = await res.text(); 290 + expect(html).toContain("Access Denied"); 291 + }); 292 + 293 + it("returns 403 for authenticated user with only an unrelated permission", async () => { 294 + setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]); 295 + const routes = await loadAdminRoutes(); 296 + const res = await routes.request("/admin", { 297 + headers: { cookie: "atbb_session=token" }, 298 + }); 299 + expect(res.status).toBe(403); 300 + }); 301 + 302 + // ── Wildcard permission ────────────────────────────────────────────────── 303 + 304 + it("grants access and shows all cards for wildcard (*) permission", async () => { 305 + setupAuthenticatedSession(["*"]); 306 + const routes = await loadAdminRoutes(); 307 + const res = await routes.request("/admin", { 308 + headers: { cookie: "atbb_session=token" }, 309 + }); 310 + expect(res.status).toBe(200); 311 + const html = await res.text(); 312 + expect(html).toContain("Members"); 313 + expect(html).toContain("Structure"); 314 + expect(html).toContain("Mod Log"); 315 + expect(html).toContain('href="/admin/members"'); 316 + expect(html).toContain('href="/admin/structure"'); 317 + expect(html).toContain('href="/admin/modlog"'); 318 + }); 319 + 320 + // ── Single permission — only that card shown ───────────────────────────── 321 + 322 + it("shows only the Mod Log card for a user with only moderatePosts", async () => { 323 + setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]); 324 + const routes = await loadAdminRoutes(); 325 + const res = await routes.request("/admin", { 326 + headers: { cookie: "atbb_session=token" }, 327 + }); 328 + expect(res.status).toBe(200); 329 + const html = await res.text(); 330 + expect(html).toContain("Mod Log"); 331 + expect(html).not.toContain('href="/admin/members"'); 332 + expect(html).not.toContain('href="/admin/structure"'); 333 + expect(html).toContain('href="/admin/modlog"'); 334 + }); 335 + 336 + it("shows only the Mod Log card for a user with only banUsers", async () => { 337 + setupAuthenticatedSession(["space.atbb.permission.banUsers"]); 338 + const routes = await loadAdminRoutes(); 339 + const res = await routes.request("/admin", { 340 + headers: { cookie: "atbb_session=token" }, 341 + }); 342 + expect(res.status).toBe(200); 343 + const html = await res.text(); 344 + expect(html).not.toContain('href="/admin/members"'); 345 + expect(html).toContain('href="/admin/modlog"'); 346 + }); 347 + 348 + it("shows only the Mod Log card for a user with only lockTopics", async () => { 349 + setupAuthenticatedSession(["space.atbb.permission.lockTopics"]); 350 + const routes = await loadAdminRoutes(); 351 + const res = await routes.request("/admin", { 352 + headers: { cookie: "atbb_session=token" }, 353 + }); 354 + expect(res.status).toBe(200); 355 + const html = await res.text(); 356 + expect(html).not.toContain('href="/admin/members"'); 357 + expect(html).toContain('href="/admin/modlog"'); 358 + }); 359 + 360 + it("shows only the Members card for a user with only manageMembers", async () => { 361 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 362 + const routes = await loadAdminRoutes(); 363 + const res = await routes.request("/admin", { 364 + headers: { cookie: "atbb_session=token" }, 365 + }); 366 + expect(res.status).toBe(200); 367 + const html = await res.text(); 368 + expect(html).toContain('href="/admin/members"'); 369 + expect(html).not.toContain('href="/admin/structure"'); 370 + expect(html).not.toContain('href="/admin/modlog"'); 371 + }); 372 + 373 + it("shows only the Structure card for a user with only manageCategories", async () => { 374 + setupAuthenticatedSession(["space.atbb.permission.manageCategories"]); 375 + const routes = await loadAdminRoutes(); 376 + const res = await routes.request("/admin", { 377 + headers: { cookie: "atbb_session=token" }, 378 + }); 379 + expect(res.status).toBe(200); 380 + const html = await res.text(); 381 + expect(html).not.toContain('href="/admin/members"'); 382 + expect(html).toContain('href="/admin/structure"'); 383 + expect(html).not.toContain('href="/admin/modlog"'); 384 + }); 385 + 386 + // ── Multi-permission combos ────────────────────────────────────────────── 387 + 388 + it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => { 389 + setupAuthenticatedSession([ 390 + "space.atbb.permission.manageMembers", 391 + "space.atbb.permission.moderatePosts", 392 + ]); 393 + const routes = await loadAdminRoutes(); 394 + const res = await routes.request("/admin", { 395 + headers: { cookie: "atbb_session=token" }, 396 + }); 397 + expect(res.status).toBe(200); 398 + const html = await res.text(); 399 + expect(html).toContain('href="/admin/members"'); 400 + expect(html).not.toContain('href="/admin/structure"'); 401 + expect(html).toContain('href="/admin/modlog"'); 402 + }); 403 + 404 + // ── Page structure ─────────────────────────────────────────────────────── 405 + 406 + it("renders page title 'Admin Panel'", async () => { 407 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 408 + const routes = await loadAdminRoutes(); 409 + const res = await routes.request("/admin", { 410 + headers: { cookie: "atbb_session=token" }, 411 + }); 412 + const html = await res.text(); 413 + expect(html).toContain("Admin Panel"); 414 + }); 415 + 416 + it("renders admin-nav-grid container", async () => { 417 + setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 418 + const routes = await loadAdminRoutes(); 419 + const res = await routes.request("/admin", { 420 + headers: { cookie: "atbb_session=token" }, 421 + }); 422 + const html = await res.text(); 423 + expect(html).toContain("admin-nav-grid"); 424 + }); 425 + }); 426 + ``` 427 + 428 + **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)** 429 + 430 + ```sh 431 + cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec vitest run src/routes/__tests__/admin.test.tsx 432 + ``` 433 + 434 + Expected: FAIL — `Cannot find module '../admin.js'` (or similar). 435 + 436 + Once Task 2 is done, all tests should pass. 437 + 438 + **Step 3: Run the full test suite** 439 + 440 + ```sh 441 + PATH=/path/to/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 442 + ``` 443 + 444 + Expected: all pass, including new admin tests. 445 + 446 + **Step 4: Commit** 447 + 448 + ```sh 449 + git add apps/web/src/routes/__tests__/admin.test.tsx 450 + git commit -m "test(web): add admin landing page route tests (ATB-42)" 451 + ``` 452 + 453 + --- 454 + 455 + ### Task 4: Add CSS for admin nav grid in `theme.css` 456 + 457 + **Files:** 458 + - Modify: `apps/web/public/static/css/theme.css` 459 + 460 + **Step 1: Append the admin panel styles at the end of the file** 461 + 462 + ```css 463 + /* ─── Admin Panel ───────────────────────────────────────────────────────── */ 464 + 465 + .admin-nav-grid { 466 + display: grid; 467 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 468 + gap: var(--space-md); 469 + margin-top: var(--space-lg); 470 + } 471 + 472 + .admin-nav-card { 473 + text-decoration: none; 474 + color: inherit; 475 + display: block; 476 + } 477 + 478 + .admin-nav-card:hover .card { 479 + border-color: var(--color-primary); 480 + } 481 + 482 + .admin-nav-card__icon { 483 + font-size: var(--font-size-xl, 2rem); 484 + margin-bottom: var(--space-sm); 485 + } 486 + 487 + .admin-nav-card__title { 488 + font-family: var(--font-heading); 489 + font-weight: var(--font-weight-bold); 490 + font-size: var(--font-size-lg); 491 + margin-bottom: var(--space-xs); 492 + } 493 + 494 + .admin-nav-card__description { 495 + color: var(--color-text-muted); 496 + font-size: var(--font-size-sm); 497 + } 498 + ``` 499 + 500 + **Step 2: Commit** 501 + 502 + ```sh 503 + git add apps/web/public/static/css/theme.css 504 + git commit -m "style(web): add admin nav grid CSS (ATB-42)" 505 + ``` 506 + 507 + --- 508 + 509 + ### Task 5: Final verification 510 + 511 + **Step 1: Run the full web package test suite** 512 + 513 + ```sh 514 + PATH=/path/to/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 515 + ``` 516 + 517 + Expected: all tests pass. 518 + 519 + **Step 2: Type-check the web package** 520 + 521 + ```sh 522 + cd apps/web && PATH=/path/to/.devenv/profile/bin:$PATH pnpm exec tsc --noEmit 523 + ``` 524 + 525 + Expected: no errors. 526 + 527 + **Step 3: Update Linear** 528 + 529 + Set ATB-42 status → **In Progress** at start, **Done** when complete. Add a comment: 530 + > Implemented GET /admin with hasAnyAdminPermission() helper, permission-gated nav cards, CSS, and full test coverage. 531 + 532 + --- 533 + 534 + ## Notes 535 + 536 + - **Permission strings** — all use full namespace: `space.atbb.permission.<name>`. Do NOT use short names like `"manageMembers"`. See existing helpers in `session.ts` (e.g. `canLockTopics`) for the pattern. 537 + - **Wildcard** — `"*"` grants all permissions; every helper must check for it alongside named permissions. 538 + - **`WebSessionWithPermissions` import** — already exported from `apps/web/src/lib/session.ts`; no new types needed. 539 + - **`BaseLayout` auth prop** — accepts `WebSession`, which `WebSessionWithPermissions` satisfies (it extends it with `permissions`). 540 + - **NotFoundRoute must remain last** in `routes/index.ts` — it catches all unmatched paths. 541 + - **No API call on admin landing** — the design explicitly states that card visibility comes from `WebSession.permissions`, not a fresh API fetch.
docs/screenshots/atb-42-admin-panel.png

This is a binary file and will not be displayed.