diff --git a/.planning/STATE.md b/.planning/STATE.md
index 07e0192..263d578 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -2,16 +2,16 @@
gsd_state_version: 1.0
milestone: v2.4
milestone_name: Admin Foundation
-status: executing
-stopped_at: Phase 36 context gathered
-last_updated: "2026-04-19T18:30:00.000Z"
+status: ready_to_execute
+stopped_at: Phase 36 planned — 2 plans ready
+last_updated: "2026-04-19T19:00:00.000Z"
last_activity: 2026-04-19
progress:
total_phases: 20
completed_phases: 8
- total_plans: 32
+ total_plans: 34
completed_plans: 32
- percent: 100
+ percent: 94
---
# Project State
@@ -21,16 +21,16 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-19)
**Core value:** Help people make better gear decisions — discover what others use, compare real-world data, and see how a potential buy affects your setup before committing.
-**Current focus:** Milestone v2.4 — Admin Foundation | Phase 35: Bug Fixes
+**Current focus:** Milestone v2.4 — Admin Foundation | Phase 36: Admin Role & Panel Foundation
## Current Position
-Phase: 35 — Bug Fixes
-Plan: 3 of 3 complete
+Phase: 36 — Admin Role & Panel Foundation
+Plan: 0 of 2 complete
Status: Ready to execute
Last activity: 2026-04-19
-Progress: [██████████] 97%
+Progress: [████████░░] 94%
## Performance Metrics
diff --git a/.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md b/.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
new file mode 100644
index 0000000..ea3614e
--- /dev/null
+++ b/.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
@@ -0,0 +1,376 @@
+---
+phase: 36
+plan: 01
+title: "isAdmin schema, requireAdmin middleware, /api/auth/me surface, grant script"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - src/db/schema.ts
+ - src/server/middleware/auth.ts
+ - src/server/routes/auth.ts
+ - src/server/routes/admin.ts
+ - src/server/index.ts
+ - scripts/grant-admin.ts
+ - drizzle-pg/ (generated migration)
+autonomous: true
+requirements:
+ - ROLE-01
+ - ROLE-02
+ - ADMN-01
+---
+
+
+Add `isAdmin` boolean to the `users` table, create `requireAdmin` middleware, surface `isAdmin` in `/api/auth/me`, create a placeholder `/api/admin/` route, and provide a `scripts/grant-admin.ts` for granting admin status. This is the server-side foundation for Phase 36.
+
+
+
+**[BLOCKING] Schema Push Required**
+
+This plan modifies `src/db/schema.ts` (Drizzle ORM). After all schema file changes are complete and BEFORE verification, run:
+
+- Generate migration: `bunx drizzle-kit generate`
+- Apply migration: `bun run db:push`
+
+If the database is not running, flag for manual intervention (`autonomous: false` for that task).
+
+This task is mandatory — the phase CANNOT pass verification without it.
+
+
+
+**Threat:** An unauthenticated or non-admin user calls `/api/admin/*` endpoints directly.
+**Mitigation:** `requireAuth` + `requireAdmin` middleware chain returns 401/403 before handler executes. Both middleware layers are applied to all `/api/admin/*` routes.
+
+**Threat:** `isAdmin` defaults to `true` for new users.
+**Mitigation:** Column is `NOT NULL DEFAULT false` — new users are never admins by default.
+
+**Threat:** Direct SQL grant bypasses application validation.
+**Mitigation:** The grant script is a developer-only tool; no public endpoint exposes admin promotion. The only mutation path is authenticated developer access to the database.
+
+
+
+
+
+execute
+Add isAdmin column to users table in schema.ts
+
+ src/db/schema.ts
+
+
+ - src/db/schema.ts — read the full users table definition to see the exact structure before modifying
+
+
+In `src/db/schema.ts`, add `isAdmin: boolean("is_admin").notNull().default(false)` to the `users` pgTable definition.
+
+The updated users table should look like:
+```typescript
+export const users = pgTable("users", {
+ id: serial("id").primaryKey(),
+ logtoSub: text("logto_sub").notNull().unique(),
+ displayName: text("display_name"),
+ avatarUrl: text("avatar_url"),
+ bio: text("bio"),
+ isAdmin: boolean("is_admin").notNull().default(false),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+});
+```
+
+The `boolean` import is already present in the file (used by `manufacturers.active`).
+
+
+ - src/db/schema.ts contains `isAdmin: boolean("is_admin").notNull().default(false)` in the users pgTable
+ - The `boolean` import from `drizzle-orm/pg-core` is present (already there — verify it's not removed)
+
+
+
+
+execute
+[BLOCKING] Generate and apply Drizzle migration for isAdmin column
+
+ drizzle-pg/
+
+
+ - drizzle.config.ts — verify the out directory is drizzle-pg/ and dialect is postgresql
+ - drizzle-pg/ — list existing migration files to understand numbering
+
+
+Run the following commands in sequence:
+
+1. Generate migration:
+ ```bash
+ bunx drizzle-kit generate
+ ```
+ This creates a new SQL file in `drizzle-pg/` with:
+ ```sql
+ ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;
+ ```
+
+2. Apply migration:
+ ```bash
+ bun run db:push
+ ```
+ OR `bunx drizzle-kit push` if `bun run db:push` isn't available.
+
+If the database is not reachable, mark as requiring manual intervention and continue with remaining tasks that don't need the live DB.
+
+
+ - A new SQL file exists in drizzle-pg/ containing `ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false`
+ - `bun run db:push` (or equivalent) exits with code 0
+
+
+
+
+execute
+Add requireAdmin middleware to auth.ts
+
+ src/server/middleware/auth.ts
+
+
+ - src/server/middleware/auth.ts — read the full file to understand the existing requireAuth pattern, imports, and Context type
+ - src/db/schema.ts — verify the users table export name and isAdmin field name after task T1
+
+
+Add a `requireAdmin` middleware function to `src/server/middleware/auth.ts`.
+
+Add the following imports at the top (if not already present):
+```typescript
+import { eq } from "drizzle-orm";
+import { users } from "../../db/schema.ts";
+```
+
+Add the `requireAdmin` function after `requireAuth`:
+```typescript
+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();
+}
+```
+
+`requireAdmin` is designed to be called AFTER `requireAuth` has already set `c.get("userId")`. It reads `userId` from context, queries the users table, and returns 403 if the user is not an admin.
+
+
+ - src/server/middleware/auth.ts exports `requireAdmin` function
+ - The function signature is `async function requireAdmin(c: Context, next: Next)`
+ - The function returns 401 if userId is not set on context
+ - The function returns 403 if `user.isAdmin` is falsy
+ - The function calls `next()` if `user.isAdmin` is true
+ - `eq` is imported from `drizzle-orm`
+ - `users` is imported from `../../db/schema.ts`
+
+
+
+
+execute
+Add isAdmin to /api/auth/me response
+
+ src/server/routes/auth.ts
+
+
+ - src/server/routes/auth.ts — read the full /me handler to understand what fullUser contains and what is returned
+ - src/db/schema.ts — verify that users.isAdmin is now a valid field
+
+
+In `src/server/routes/auth.ts`, update the `app.get("/me", ...)` handler to include `isAdmin` in the returned user object.
+
+Current return:
+```typescript
+return c.json({
+ user: {
+ id: user.id,
+ email: auth.email,
+ createdAt: fullUser?.createdAt?.toISOString() ?? null,
+ },
+ authenticated: true,
+});
+```
+
+Updated return:
+```typescript
+return c.json({
+ user: {
+ id: user.id,
+ email: auth.email,
+ createdAt: fullUser?.createdAt?.toISOString() ?? null,
+ isAdmin: fullUser?.isAdmin ?? false,
+ },
+ authenticated: true,
+});
+```
+
+The `fullUser` variable already queries the full row from `users` table (`db.select().from(users).where(eq(users.id, user.id))`), so `fullUser.isAdmin` is available after the schema change.
+
+
+ - src/server/routes/auth.ts /me handler includes `isAdmin: fullUser?.isAdmin ?? false` in the returned user object
+ - No other changes to the /me handler logic
+
+
+
+
+execute
+Create /api/admin placeholder route
+
+ src/server/routes/admin.ts
+
+
+ - src/server/routes/tags.ts — use as the minimal route template (same Hono app pattern)
+ - src/server/middleware/auth.ts — verify requireAuth and requireAdmin exports are available
+
+
+Create `src/server/routes/admin.ts`:
+
+```typescript
+import { Hono } from "hono";
+import { requireAdmin, requireAuth } from "../middleware/auth.ts";
+
+type Env = { Variables: { db?: any; userId?: number } };
+
+const app = new Hono();
+
+// All /api/admin/* routes require authentication + admin role
+app.use("/*", requireAuth, requireAdmin);
+
+// Health check / ping for admin access verification
+app.get("/", async (c) => {
+ return c.json({ ok: true });
+});
+
+export { app as adminRoutes };
+```
+
+
+ - src/server/routes/admin.ts exists and exports `adminRoutes`
+ - The file applies `requireAuth` and `requireAdmin` as middleware on `/*`
+ - `GET /` returns `{ ok: true }`
+
+
+
+
+execute
+Register adminRoutes in server index.ts
+
+ src/server/index.ts
+
+
+ - src/server/index.ts — read the route registration section to find where to insert the new import and route
+
+
+In `src/server/index.ts`:
+
+1. Add import (alphabetically with other route imports):
+ ```typescript
+ import { adminRoutes } from "./routes/admin.ts";
+ ```
+
+2. Register the route after the existing route registrations (look for the block where `app.route("/api/...")` calls are grouped):
+ ```typescript
+ app.route("/api/admin", adminRoutes);
+ ```
+
+The db injection middleware `app.use("/api/*", ...)` already covers `/api/admin/*`, so no additional db setup is needed.
+
+
+ - src/server/index.ts imports `adminRoutes` from "./routes/admin.ts"
+ - src/server/index.ts registers `app.route("/api/admin", adminRoutes)`
+
+
+
+
+execute
+Create scripts/grant-admin.ts for admin status management
+
+ scripts/grant-admin.ts
+
+
+ - src/db/index.ts — read how the db instance is exported to use the correct import path
+ - src/db/schema.ts — verify the users table and isAdmin/logtoSub field names
+
+
+Create `scripts/grant-admin.ts`:
+
+```typescript
+/**
+ * Grant or revoke admin status for a GearBox user.
+ *
+ * Usage:
+ * bun scripts/grant-admin.ts # grant admin
+ * bun scripts/grant-admin.ts --revoke # revoke admin
+ */
+
+import { eq } from "drizzle-orm";
+import { db } from "../src/db/index.ts";
+import { users } from "../src/db/schema.ts";
+
+const sub = process.argv[2];
+const revoke = process.argv.includes("--revoke");
+
+if (!sub) {
+ console.error("Usage: bun scripts/grant-admin.ts [--revoke]");
+ process.exit(1);
+}
+
+const [user] = await db
+ .update(users)
+ .set({ isAdmin: !revoke })
+ .where(eq(users.logtoSub, sub))
+ .returning({ id: users.id, logtoSub: users.logtoSub, isAdmin: users.isAdmin });
+
+if (!user) {
+ console.error(`User not found with logto_sub: ${sub}`);
+ process.exit(1);
+}
+
+const action = revoke ? "Revoked admin from" : "Granted admin to";
+console.log(`${action} user ${user.id} (${user.logtoSub}) — isAdmin: ${user.isAdmin}`);
+```
+
+
+ - scripts/grant-admin.ts exists
+ - The script accepts a logto-sub argument as `process.argv[2]`
+ - The script accepts an optional `--revoke` flag
+ - The script updates `users.isAdmin` to `true` (grant) or `false` (revoke)
+ - The script exits with code 1 if no sub is provided
+ - The script exits with code 1 if the user is not found
+
+
+
+
+
+
+1. `bun run build` exits 0 — TypeScript compiles without errors
+2. `src/db/schema.ts` contains `isAdmin: boolean("is_admin").notNull().default(false)` in the users table
+3. `drizzle-pg/` contains a new migration file with `ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false`
+4. `src/server/middleware/auth.ts` exports `requireAdmin`
+5. `src/server/routes/auth.ts` `/me` response includes `isAdmin` field
+6. `src/server/routes/admin.ts` exists and exports `adminRoutes`
+7. `src/server/index.ts` registers `app.route("/api/admin", adminRoutes)`
+8. `scripts/grant-admin.ts` exists
+
+
+
+- isAdmin boolean column exists in users table schema
+- requireAdmin middleware exported from auth.ts middleware file
+- isAdmin returned in /api/auth/me response
+- /api/admin route exists and is protected by requireAuth + requireAdmin
+- grant-admin script exists and handles grant + revoke
+
+
+
+- [ ] users table schema has isAdmin column with NOT NULL DEFAULT false
+- [ ] Drizzle migration generated and applied successfully
+- [ ] requireAdmin middleware returns 403 for non-admin users
+- [ ] /api/auth/me includes isAdmin in user object
+- [ ] GET /api/admin/ returns 403 for non-admin, 200 for admin
+- [ ] scripts/grant-admin.ts can set isAdmin=true for a user by logto_sub
+- [ ] bun run build exits 0
+
diff --git a/.planning/phases/36-admin-role-panel-foundation/36-02-PLAN.md b/.planning/phases/36-admin-role-panel-foundation/36-02-PLAN.md
new file mode 100644
index 0000000..d9d7822
--- /dev/null
+++ b/.planning/phases/36-admin-role-panel-foundation/36-02-PLAN.md
@@ -0,0 +1,329 @@
+---
+phase: 36
+plan: 02
+title: "Client /admin route, admin shell with sidebar, UserMenu admin link"
+type: execute
+wave: 2
+depends_on:
+ - 36-01
+files_modified:
+ - src/client/routes/admin.tsx
+ - src/client/routes/admin/index.tsx
+ - src/client/hooks/useAuth.ts
+ - src/client/components/UserMenu.tsx
+ - src/client/routes/__root.tsx
+autonomous: true
+requirements:
+ - ADMN-01
+---
+
+
+Create the client-side `/admin` route with a beforeLoad guard that redirects non-admin users to home, build the admin shell with a sidebar (Items + Tags nav items, both disabled/coming-soon), create the placeholder admin index view, update the AuthState type to include isAdmin, and add a conditional Admin link to the UserMenu.
+
+
+
+**Threat:** A non-admin authenticated user navigates directly to /admin in the browser.
+**Mitigation:** `beforeLoad` guard in the /admin route reads `isAdmin` from the auth query cache and throws a `redirect({ to: "/" })` if false — the component never renders. Belt-and-suspenders: server also returns 403 on /api/admin/* endpoints.
+
+**Threat:** Admin link is shown to non-admin users due to a stale auth cache.
+**Mitigation:** `useAuth()` has `staleTime: 5 * 60 * 1000` — a non-admin can only see the link if auth cache is stale AND isAdmin was previously true. Risk is negligible since server always enforces the 403 check.
+
+
+
+
+
+execute
+Update AuthState interface in useAuth.ts to include isAdmin
+
+ src/client/hooks/useAuth.ts
+
+
+ - src/client/hooks/useAuth.ts — read the full file to see the AuthState interface and existing hook structure
+
+
+In `src/client/hooks/useAuth.ts`, update the `AuthState` interface to include `isAdmin`:
+
+Current:
+```typescript
+interface AuthState {
+ user: { id: string; email?: string; createdAt?: string } | null;
+ authenticated: boolean;
+}
+```
+
+Updated:
+```typescript
+interface AuthState {
+ user: { id: string; email?: string; createdAt?: string; isAdmin?: boolean } | null;
+ authenticated: boolean;
+}
+```
+
+No other changes needed. The `useAuth()` hook fetches from `/api/auth/me` which now returns `isAdmin` after plan 36-01.
+
+
+ - src/client/hooks/useAuth.ts AuthState interface includes `isAdmin?: boolean` in the user object type
+
+
+
+
+execute
+Create admin route directory and admin layout route (admin.tsx)
+
+ src/client/routes/admin.tsx
+ src/client/routes/admin/
+
+
+ - src/client/routes/__root.tsx — understand the existing route structure and TanStack Router patterns (createRootRoute, Outlet, beforeLoad pattern)
+ - src/client/routes/settings.tsx — read as an example of a simple protected route pattern
+ - src/client/lib/iconData.ts — verify LucideIcon import path
+ - src/client/hooks/useAuth.ts — verify useAuth import path
+
+
+Create `src/client/routes/admin.tsx` — the admin layout route (shell with sidebar).
+
+**Context:** The router in `src/client/main.tsx` is created with `context: {}` (empty) — the queryClient is NOT passed via router context. Use the component-level guard pattern (useEffect + navigate) rather than beforeLoad.
+
+```typescript
+import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
+import { useEffect } from "react";
+import { useAuth } from "../hooks/useAuth";
+import { LucideIcon } from "../lib/iconData";
+
+export const Route = createFileRoute("/admin")({
+ component: AdminLayout,
+});
+
+function AdminLayout() {
+ const navigate = useNavigate();
+ const { data: auth, isLoading } = useAuth();
+
+ useEffect(() => {
+ if (!isLoading && !auth?.user?.isAdmin) {
+ navigate({ to: "/" });
+ }
+ }, [auth, isLoading, navigate]);
+
+ // Don't render the shell until auth is confirmed
+ if (isLoading || !auth?.user?.isAdmin) return null;
+
+ return (
+
+ );
+}
+```
+
+
+ - src/client/routes/admin.tsx exists and exports a Route created with `createFileRoute("/admin")`
+ - The component renders a sidebar with "Admin" heading
+ - The sidebar contains two disabled nav items: one with icon "package" labeled "Items" and one with icon "tag" labeled "Tags"
+ - Both disabled items have a "Soon" badge
+ - The component renders `` in the main content area
+ - Non-admin users are redirected (either via beforeLoad redirect or useEffect navigate) to "/"
+
+
+
+
+execute
+Create admin/index.tsx placeholder content
+
+ src/client/routes/admin/index.tsx
+
+
+ - src/client/routes/admin.tsx — confirm the route structure so the index matches correctly
+ - src/client/lib/iconData.ts — verify LucideIcon import path
+
+
+Create the `src/client/routes/admin/` directory and `src/client/routes/admin/index.tsx`:
+
+```typescript
+import { createFileRoute } from "@tanstack/react-router";
+import { LucideIcon } from "../../lib/iconData";
+
+export const Route = createFileRoute("/admin/")({
+ component: AdminIndex,
+});
+
+function AdminIndex() {
+ return (
+
+
+
Admin Panel
+
+ Select a section from the sidebar
+
+
+ );
+}
+```
+
+
+ - src/client/routes/admin/index.tsx exists
+ - It exports a Route with `createFileRoute("/admin/")`
+ - The component renders a centered placeholder with a "shield" icon, "Admin Panel" text, and a subtext
+
+
+
+
+execute
+Add conditional Admin link to UserMenu
+
+ src/client/components/UserMenu.tsx
+
+
+ - src/client/components/UserMenu.tsx — read the full file to understand the existing menu structure, Link usage, and auth data access
+ - src/client/hooks/useAuth.ts — confirm that auth.user.isAdmin is now typed
+
+
+In `src/client/components/UserMenu.tsx`, add a conditional Admin link as the first item in the dropdown menu (before the Profile link).
+
+The `auth` variable is already read via `const { data: auth } = useAuth();`.
+
+Update the menu dropdown JSX to add the Admin link before the Profile link:
+
+```tsx
+{open && (
+
+)}
+```
+
+Keep all existing menu items unchanged. Only add the Admin link + divider at the top, conditionally rendered.
+
+
+ - src/client/components/UserMenu.tsx renders an Admin link when `auth?.user?.isAdmin` is true
+ - The Admin link uses `to="/admin"` and renders a "shield" LucideIcon
+ - A `border-t border-gray-100` divider separates Admin from the Profile link
+ - When `auth?.user?.isAdmin` is false or undefined, the Admin link and its divider are not rendered
+ - All existing menu items (Profile, Settings, Sign out) remain unchanged
+
+
+
+
+execute
+Add /admin to public route allowlist in __root.tsx
+
+ src/client/routes/__root.tsx
+
+
+ - src/client/routes/__root.tsx — read the isPublicRoute logic and the auth guard that redirects to /login
+
+
+In `src/client/routes/__root.tsx`, update the `isPublicRoute` check to include `/admin` so the root layout does NOT redirect admin users to `/login` before the admin route's own guard can run.
+
+Current:
+```typescript
+const isPublicRoute =
+ location.pathname === "/" ||
+ location.pathname.startsWith("/users/") ||
+ ...
+ location.pathname === "/login" || ...
+```
+
+The issue: If an admin navigates to `/admin`, the root layout runs `if (!isAuthenticated && !isPublicRoute) navigate({ to: "/login" })`. For admin users who ARE authenticated, this is not a problem. But to be safe and explicit, the `/admin` route should be treated as a **protected** route (not public). The root layout's auth guard redirects unauthenticated users to `/login`, which is correct behavior for `/admin`.
+
+**Action:** No change needed to `isPublicRoute` — the current logic already handles authenticated users correctly (the guard only fires for unauthenticated users). The admin route's own guard handles the isAdmin check.
+
+However, verify that `src/client/routes/__root.tsx` does NOT exclude `/admin` from the auth guard in a way that would allow unauthenticated access. Read the file and confirm no changes are needed. If the existing `isPublicRoute` logic would incorrectly allow `/admin` access without auth, add:
+```typescript
+// /admin is NOT a public route — root auth guard handles unauthenticated redirect
+// admin.tsx beforeLoad handles non-admin redirect
+```
+as a comment to clarify intent. No code change if logic is already correct.
+
+
+ - src/client/routes/__root.tsx is unchanged OR has a clarifying comment
+ - The /admin route is NOT in the isPublicRoute list (it requires authentication)
+ - An unauthenticated user navigating to /admin is redirected to /login by the root guard
+ - An authenticated non-admin navigating to /admin is redirected to / by the admin route's guard
+
+
+
+
+
+
+1. `bun run build` exits 0 — no TypeScript errors in new route files
+2. The route tree is regenerated — `routeTree.gen.ts` includes `/admin` and `/admin/` routes
+3. src/client/hooks/useAuth.ts AuthState interface includes `isAdmin?: boolean`
+4. src/client/routes/admin.tsx exists with createFileRoute("/admin")
+5. src/client/routes/admin/index.tsx exists with createFileRoute("/admin/")
+6. src/client/components/UserMenu.tsx conditionally renders Admin link when isAdmin is true
+7. Manual verification: admin user sees Admin link in UserMenu; non-admin does not
+
+
+
+- /admin route exists and is guarded against non-admin users
+- Admin shell renders sidebar with Items and Tags (disabled)
+- Admin index placeholder renders inside the shell
+- Admin link appears in UserMenu only when isAdmin is true
+- TypeScript type for isAdmin propagated through AuthState
+
+
+
+- [ ] src/client/routes/admin.tsx exists with createFileRoute("/admin") and guard logic
+- [ ] src/client/routes/admin/index.tsx exists with placeholder UI
+- [ ] Admin sidebar renders "Items" (package icon) and "Tags" (tag icon) both disabled with "Soon" badge
+- [ ] Non-admin redirect is implemented (beforeLoad or useEffect)
+- [ ] UserMenu shows Admin link when auth.user.isAdmin is true
+- [ ] bun run build exits 0
+- [ ] routeTree.gen.ts includes /admin route
+
diff --git a/.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md b/.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
new file mode 100644
index 0000000..121bf0a
--- /dev/null
+++ b/.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
@@ -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 = '';
+```
+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 "); 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 `` for sub-routes. TanStack Router will render the admin layout with `` 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 ``
+
+### 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 (``)
+- 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 && (
+ setOpen(false)} className="...">
+
+ Admin
+
+)}
+```
+
+---
+
+## 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: " # → 403
+curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: " # → 200
+```
+
+---
+
+## RESEARCH COMPLETE
diff --git a/.planning/phases/36-admin-role-panel-foundation/36-UI-SPEC.md b/.planning/phases/36-admin-role-panel-foundation/36-UI-SPEC.md
new file mode 100644
index 0000000..3501073
--- /dev/null
+++ b/.planning/phases/36-admin-role-panel-foundation/36-UI-SPEC.md
@@ -0,0 +1,126 @@
+# Phase 36: Admin Role & Panel Foundation — UI Design Contract
+
+**Phase:** 36 — Admin Role & Panel Foundation
+**Created:** 2026-04-19
+**Status:** Ready for planning
+
+---
+
+## Design Intent
+
+The admin panel is a protected, minimal shell consistent with the app's existing light/airy aesthetic. It is not a distinct visual world — it reuses the same white background, gray borders, and sans-serif type as the rest of GearBox. The only indicator of admin context is the sidebar and a subtle "Admin" badge or heading.
+
+---
+
+## Layout
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ TopNav (existing — unchanged) │
+├──────────────┬──────────────────────────────────────────┤
+│ Sidebar │ Main content area │
+│ w-56 │ flex-1, min-h │
+│ border-r │ │
+│ │ (placeholder for now) │
+│ Admin │ │
+│ ────────── │ │
+│ □ Items │ │
+│ □ Tags │ │
+│ │ │
+└──────────────┴──────────────────────────────────────────┘
+```
+
+---
+
+## Component Specs
+
+### Admin Shell (`src/client/routes/admin.tsx`)
+
+**Outer wrapper:** `flex min-h-[calc(100vh-3.5rem)]` (full height minus TopNav 3.5rem/14)
+
+**Sidebar:**
+- `w-56 border-r border-gray-100 bg-white p-4 flex flex-col gap-1`
+- Header: `text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3` — "Admin"
+- Nav items: `flex items-center gap-2 px-3 py-2 rounded-lg text-sm` (disabled state below)
+
+**Main content:**
+- `flex-1 p-6 bg-gray-50`
+- Contains ``
+
+### Sidebar Nav Items (Disabled / Coming Soon)
+
+Both "Items" and "Tags" are disabled in this phase.
+
+**Disabled item style:**
+```
+flex items-center gap-2 px-3 py-2 rounded-lg text-sm
+text-gray-300 cursor-not-allowed
+```
+
+**Icon + label + badge:**
+```tsx
+