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

248 lines
11 KiB
Markdown

# 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