- 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>
11 KiB
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 atdrizzle.config.ts - Generate:
bunx drizzle-kit generate→ creates new SQL file indrizzle-pg/ - The generated migration will be
ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false; - Apply:
bunx drizzle-kit push(orbun run db:push) - This is non-destructive —
DEFAULT falsemeans all existing rows getfalse
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:
- API key (
X-API-Keyheader) →verifyApiKey(db, key)→ setsc.set("userId", result.userId) - OAuth Bearer token →
verifyAccessToken→ setsc.set("userId", result.userId) - OIDC session (browser) →
getOrCreateUser→ setsc.set("userId", user.id)
requireAuth sets userId on context but does NOT query isAdmin.
requireAdmin Pattern
requireAdmin must:
- Call
requireAuthlogic first (or callrequireAuthand chain), OR - 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, queriesuserstable forisAdmin - Returns 403 if
isAdminis 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.
// 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:
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.
// 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:
{ "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
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 paletteLucideIconfromlib/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 byrequireAdmin - Register in
src/server/index.tsas/api/admin - For now, only one endpoint is needed:
GET /api/admin/pingor 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.
{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 +
requireAdminmiddleware +/api/auth/mechange + 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
# 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