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

Configure Feed

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

docs: ATB-42 admin panel landing page implementation plan

Malpercio 4b0cc5b2 e7f57337

+541
+541
docs/plans/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.