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

Configure Feed

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

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/modlog in 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 in session.ts (e.g. canLockTopics) for the pattern.
  • Wildcard"*" grants all permissions; every helper must check for it alongside named permissions.
  • WebSessionWithPermissions import — already exported from apps/web/src/lib/session.ts; no new types needed.
  • BaseLayout auth prop — accepts WebSession, which WebSessionWithPermissions satisfies (it extends it with permissions).
  • 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.