# 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 = '<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 <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:
```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 `<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
```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 `<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.

---

## 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 && (
  <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
```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: <non-admin-key>"  # → 403
curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: <admin-key>"      # → 200
```

---

## RESEARCH COMPLETE
