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 ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ +
+
+ ); +} +``` +
+ + - 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 && ( +
+ {/* Admin link — only visible to admin users */} + {auth?.user?.isAdmin && ( + <> + setOpen(false)} + className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors" + > + + Admin + +
+ + )} + {/* Existing links below unchanged */} + setOpen(false)} + className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors" + > + ... + + ... +
+)} +``` + +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 +
+ + Items + Soon +
+``` + +Icons to use: +- Items → `"package"` (matches existing collection icon) +- Tags → `"tag"` + +### Admin Index Placeholder (`src/client/routes/admin/index.tsx`) + +Simple centered placeholder: +```tsx +
+ +

Admin Panel

+

Select a section from the sidebar

+
+``` + +### Admin Link in UserMenu + +Position: before Profile link (top of menu). +Only rendered when `auth?.user?.isAdmin === true`. + +```tsx +{auth?.user?.isAdmin && ( + <> + setOpen(false)} + className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors" + > + + Admin + +
+ +)} +``` + +--- + +## Palette (existing conventions) + +| Token | Value | Usage | +|-------|-------|-------| +| bg-white | #ffffff | Sidebar, TopNav | +| bg-gray-50 | #f9fafb | Page background, main content | +| border-gray-100 | #f3f4f6 | Sidebar border, dividers | +| text-gray-900 | #111827 | Active/primary text | +| text-gray-500 | #6b7280 | Secondary text | +| text-gray-300 | #d1d5db | Disabled items | +| text-gray-400 | #9ca3af | Icons, muted labels | + +--- + +## Responsive + +- Sidebar is always visible (no mobile collapse in this phase — admin is desktop-only usage) +- `hidden md:flex` wrapper if needed to keep mobile layout clean, but admin route is inherently desktop + +## UI-SPEC COMPLETE