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>
This commit is contained in:
247
.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
Normal file
247
.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user