# 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.ts` — `users` 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`. ```typescript // 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: ```sql UPDATE users SET is_admin = true WHERE logto_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. ```typescript // 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 "); 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: ```json { "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.ts` — `AuthState` 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 `` for sub-routes. TanStack Router will render the admin layout with `` 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 ```typescript 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 `` ### 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 (``) - 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. --- ## 8. Conditional Admin Link in UserMenu ### 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`. ```tsx {auth?.user?.isAdmin && ( setOpen(false)} className="..."> Admin )} ``` --- ## 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 ```bash # 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: " # → 403 curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: " # → 200 ``` --- ## RESEARCH COMPLETE