- 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>
248 lines
11 KiB
Markdown
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
|