Files
GearBox/.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
Jean-Luc Makiola 94e2a8c019 plan(36): admin role & panel foundation — 2 plans ready
- 36-RESEARCH.md: schema migration, requireAdmin middleware, /api/auth/me
  surface, client routing patterns, grant script, wave breakdown
- 36-UI-SPEC.md: admin shell layout, sidebar disabled nav items, UserMenu
  admin link, palette and responsive notes
- 36-01-PLAN.md (wave 1): isAdmin schema column + Drizzle migration,
  requireAdmin middleware, /api/auth/me isAdmin field, /api/admin placeholder
  route, scripts/grant-admin.ts
- 36-02-PLAN.md (wave 2): AuthState isAdmin type, /admin client route with
  sidebar shell, admin/index.tsx placeholder, UserMenu admin link
- STATE.md: updated to Phase 36, ready to execute, 2 plans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 20:43:12 +02:00

11 KiB

Phase 36: Admin Role & Panel Foundation — Research

Phase: 36 — Admin Role & Panel Foundation Researched: 2026-04-19 Requirements: ROLE-01, ROLE-02, ADMN-01


Summary

This phase adds an isAdmin boolean to the users table, surfaces it in /api/auth/me, creates a requireAdmin middleware, and builds a protected /admin client route with a sidebar shell. All decisions are already locked in CONTEXT.md. The work is additive and low-risk — no existing logic is removed, only extended.


1. Database Schema Change

Current State

src/db/schema.tsusers table has: id, logtoSub, displayName, avatarUrl, bio, createdAt. No isAdmin column.

Required Change

Add isAdmin: boolean("is_admin").notNull().default(false) to the users pgTable definition.

Migration Mechanics

  • Drizzle ORM (PostgreSQL) — dialect is postgresql, config at drizzle.config.ts
  • Generate: bunx drizzle-kit generate → creates new SQL file in drizzle-pg/
  • The generated migration will be ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;
  • Apply: bunx drizzle-kit push (or bun run db:push)
  • This is non-destructive — DEFAULT false means all existing rows get false

Validation Architecture

  • After migration, SELECT is_admin FROM users LIMIT 1; returns a boolean value
  • Drizzle eq(users.isAdmin, true) works in queries after schema update

2. requireAdmin Middleware

Current Auth Flow

src/server/middleware/auth.ts exports requireAuth. It handles:

  1. API key (X-API-Key header) → verifyApiKey(db, key) → sets c.set("userId", result.userId)
  2. OAuth Bearer token → verifyAccessToken → sets c.set("userId", result.userId)
  3. OIDC session (browser) → getOrCreateUser → sets c.set("userId", user.id)

requireAuth sets userId on context but does NOT query isAdmin.

requireAdmin Pattern

requireAdmin must:

  1. Call requireAuth logic first (or call requireAuth and chain), OR
  2. Be a standalone middleware that verifies auth AND checks isAdmin

Recommended approach (avoids double-next issues): requireAdmin is a standalone middleware that:

  • Replicates the "is this user authenticated?" check from requireAuth
  • After setting userId, queries users table for isAdmin
  • Returns 403 if isAdmin is false or null

Alternative cleaner approach: Call requireAuth inline, then check isAdmin in a second middleware. Hono supports middleware chaining: app.get("/admin/*", requireAuth, requireAdmin, handler). The requireAdmin middleware reads c.get("userId") (set by requireAuth) and queries the db.

Decision for plan: Use composition — requireAdmin is a separate middleware that expects userId to already be set (by requireAuth), then queries users table for isAdmin flag. Register on routes as: requireAuth, requireAdmin.

// src/server/middleware/auth.ts (addition)
export async function requireAdmin(c: Context, next: Next) {
  const db = c.get("db");
  const userId = c.get("userId");
  if (!userId) return c.json({ error: "Authentication required" }, 401);
  const [user] = await db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId));
  if (!user?.isAdmin) return c.json({ error: "Forbidden" }, 403);
  return next();
}

3. Admin Grant Mechanism (D-03)

Per CONTEXT.md decision D-03: no CLI script needed. Developers use direct SQL:

UPDATE users SET is_admin = true WHERE logto_sub = '<sub>';

Or via Drizzle Studio (the interactive UI that ships with drizzle-kit).

Note: The ROADMAP success criterion says "a developer can grant or revoke admin status via a CLI script or seed mechanism". The CONTEXT.md overrides this with the decision that direct SQL is sufficient. Per the CONTEXT.md decision hierarchy, CONTEXT.md decisions take precedence — no CLI script needed. However, a simple admin-grant script (scripts/grant-admin.ts) would be minimal effort and satisfy the roadmap success criterion. Recommendation: Create a tiny scripts/grant-admin.ts that accepts a logto_sub argument and sets isAdmin = true. This is ~10 lines and satisfies the success criterion without UI.

// scripts/grant-admin.ts
import { eq } from "drizzle-orm";
import { db } from "../src/db/index.ts";
import { users } from "../src/db/schema.ts";

const sub = process.argv[2];
if (!sub) { console.error("Usage: bun scripts/grant-admin.ts <logto-sub>"); process.exit(1); }
const [user] = await db.update(users).set({ isAdmin: true }).where(eq(users.logtoSub, sub)).returning({ id: users.id, logtoSub: users.logtoSub });
if (!user) { console.error("User not found:", sub); process.exit(1); }
console.log(`Granted admin to user ${user.id} (${user.logtoSub})`);

4. /api/auth/me — isAdmin Surface

Current State

src/server/routes/auth.ts /me endpoint returns:

{ "user": { "id": ..., "email": ..., "createdAt": ... }, "authenticated": true }

It queries fullUser from users table but only returns id, email, createdAt.

Required Change

Add isAdmin: fullUser?.isAdmin ?? false to the returned user object.

Client Hook

src/client/hooks/useAuth.tsAuthState interface has user: { id: string; email?: string; createdAt?: string } | null. Add isAdmin?: boolean.


5. Client Routing — /admin Route

TanStack Router File-Based Routing

Routes are in src/client/routes/. File-based routing auto-generates the route tree to routeTree.gen.ts (never edit manually).

Creating /admin Route

  • Create src/client/routes/admin.tsx — the admin shell with layout + sidebar
  • Create src/client/routes/admin/ directory for future sub-routes (phases 37/38)
  • Create src/client/routes/admin/index.tsx — the default admin view (placeholder)

Alternative simpler structure: Just src/client/routes/admin.tsx with an <Outlet /> for sub-routes. TanStack Router will render the admin layout with <Outlet> for child routes.

Recommended: admin.tsx as the layout route (shell + sidebar) + admin/index.tsx as the placeholder content. This is the standard TanStack Router pattern for nested layouts.

beforeLoad Guard

export const Route = createFileRoute("/admin")({
  beforeLoad: async ({ context }) => {
    // context.auth from router context, or fetch from query
    const auth = await queryClient.fetchQuery({ queryKey: ["auth"], queryFn: ... });
    if (!auth?.user?.isAdmin) {
      throw redirect({ to: "/" });
    }
  },
  component: AdminLayout,
});

Pattern from codebase: The root route (__root.tsx) does auth checking inline in the component (if (!isAuthenticated && !isPublicRoute) navigate({ to: "/login" })). For /admin, use beforeLoad for cleaner protection — it prevents the component from rendering at all.


6. Admin Panel Shell UI

Design Constraints

  • Light/minimal aesthetic (white, gray palette, consistent with existing TopNav/UserMenu)
  • Sidebar with two nav items: "Items" (phase 37) and "Tags" (phase 38) — both disabled/coming-soon
  • The shell is persistent; phases 37/38 inject content via <Outlet />

Existing UI Patterns to Reuse

  • bg-white, border-gray-100/200, text-gray-900/500/700 — standard palette
  • LucideIcon from lib/iconData — use "package" for Items, "tag" for Tags
  • Sidebar structure: left sidebar (fixed width) + main content area (<Outlet />)
  • No dedicated sidebar component exists — build inline in admin layout

Layout Structure

┌────────────────────────────────────────────────────┐
│ TopNav (existing, always visible)                   │
├──────────┬─────────────────────────────────────────┤
│ Sidebar  │ Main content (Outlet)                    │
│          │                                          │
│ [Items]  │ (placeholder / child route content)      │
│ [Tags]   │                                          │
└──────────┴─────────────────────────────────────────┘

7. Server-Side /api/admin/* Route

Current State

No /api/admin/* routes exist. The server serves the SPA catch-all for /admin (client-side routing handles it).

Required for This Phase

  • Create src/server/routes/admin.ts — a placeholder admin router protected by requireAdmin
  • Register in src/server/index.ts as /api/admin
  • For now, only one endpoint is needed: GET /api/admin/ping or similar to confirm admin access works

Actually: The route doesn't need a /api/admin/ping endpoint for this phase — the guard can be verified via the middleware on the future routes (phases 37/38 will add actual endpoints). But having a placeholder makes testing the 403/200 behavior possible.

Decision for plan: Create src/server/routes/admin.ts with a single GET / (becomes /api/admin/) that returns { ok: true }. Protected by requireAuth, requireAdmin. Register in index.ts.


Current UserMenu

src/client/components/UserMenu.tsx renders: Profile link, Settings link, divider, Sign out button.

Required Change

Add "Admin" link above Profile, visible only when auth?.user?.isAdmin === true.

{auth?.user?.isAdmin && (
  <Link to="/admin" onClick={() => setOpen(false)} className="...">
    <LucideIcon name="shield" size={16} className="text-gray-400" />
    Admin
  </Link>
)}

9. Wave Planning

The work has clear dependencies:

  • Wave 1: Schema migration + requireAdmin middleware + /api/auth/me change + grant script
  • Wave 2: Client route + admin shell UI + UserMenu admin link

Wave 1 must complete before Wave 2 (client needs isAdmin in auth response).


Validation Architecture

Test Matrix

Scenario Expected Behavior
Unauthenticated → GET /api/admin/ 401
Authenticated non-admin → GET /api/admin/ 403
Authenticated admin → GET /api/admin/ 200 {ok: true}
Non-admin → navigate to /admin (client) Redirect to /
Admin → navigate to /admin (client) Admin shell renders
Admin link in UserMenu Visible only when isAdmin=true

Verification Commands

# Check schema migration applied
bunx drizzle-kit studio  # or psql query

# Check middleware compiles
bun run build

# Manual API tests (curl with session/API key)
curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: <non-admin-key>"  # → 403
curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: <admin-key>"      # → 200

RESEARCH COMPLETE