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:
2026-04-19 20:43:12 +02:00
parent e8cdeafba2
commit 94e2a8c019
5 changed files with 1087 additions and 9 deletions

View File

@@ -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: 35Bug Fixes
Plan: 3 of 3 complete
Phase: 36Admin Role & Panel Foundation
Plan: 0 of 2 complete
Status: Ready to execute
Last activity: 2026-04-19
Progress: [██████████] 97%
Progress: [████████░░] 94%
## Performance Metrics

View File

@@ -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
---
<objective>
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.
</objective>
<schema_push_requirement>
**[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.
</schema_push_requirement>
<threat_model>
**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.
</threat_model>
<tasks>
<task id="36-01-T1">
<type>execute</type>
<title>Add isAdmin column to users table in schema.ts</title>
<files>
src/db/schema.ts
</files>
<read_first>
- src/db/schema.ts — read the full users table definition to see the exact structure before modifying
</read_first>
<action>
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`).
</action>
<acceptance_criteria>
- 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)
</acceptance_criteria>
</task>
<task id="36-01-T2">
<type>execute</type>
<title>[BLOCKING] Generate and apply Drizzle migration for isAdmin column</title>
<files>
drizzle-pg/
</files>
<read_first>
- drizzle.config.ts — verify the out directory is drizzle-pg/ and dialect is postgresql
- drizzle-pg/ — list existing migration files to understand numbering
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
<task id="36-01-T3">
<type>execute</type>
<title>Add requireAdmin middleware to auth.ts</title>
<files>
src/server/middleware/auth.ts
</files>
<read_first>
- 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
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- 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`
</acceptance_criteria>
</task>
<task id="36-01-T4">
<type>execute</type>
<title>Add isAdmin to /api/auth/me response</title>
<files>
src/server/routes/auth.ts
</files>
<read_first>
- 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
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
<task id="36-01-T5">
<type>execute</type>
<title>Create /api/admin placeholder route</title>
<files>
src/server/routes/admin.ts
</files>
<read_first>
- 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
</read_first>
<action>
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<Env>();
// 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 };
```
</action>
<acceptance_criteria>
- src/server/routes/admin.ts exists and exports `adminRoutes`
- The file applies `requireAuth` and `requireAdmin` as middleware on `/*`
- `GET /` returns `{ ok: true }`
</acceptance_criteria>
</task>
<task id="36-01-T6">
<type>execute</type>
<title>Register adminRoutes in server index.ts</title>
<files>
src/server/index.ts
</files>
<read_first>
- src/server/index.ts — read the route registration section to find where to insert the new import and route
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- src/server/index.ts imports `adminRoutes` from "./routes/admin.ts"
- src/server/index.ts registers `app.route("/api/admin", adminRoutes)`
</acceptance_criteria>
</task>
<task id="36-01-T7">
<type>execute</type>
<title>Create scripts/grant-admin.ts for admin status management</title>
<files>
scripts/grant-admin.ts
</files>
<read_first>
- 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
</read_first>
<action>
Create `scripts/grant-admin.ts`:
```typescript
/**
* Grant or revoke admin status for a GearBox user.
*
* Usage:
* bun scripts/grant-admin.ts <logto-sub> # grant admin
* bun scripts/grant-admin.ts <logto-sub> --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 <logto-sub> [--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}`);
```
</action>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
</tasks>
<verification>
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
</verification>
<must_haves>
- 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
</must_haves>
<success_criteria>
- [ ] 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
</success_criteria>

View File

@@ -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
---
<objective>
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.
</objective>
<threat_model>
**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.
</threat_model>
<tasks>
<task id="36-02-T1">
<type>execute</type>
<title>Update AuthState interface in useAuth.ts to include isAdmin</title>
<files>
src/client/hooks/useAuth.ts
</files>
<read_first>
- src/client/hooks/useAuth.ts — read the full file to see the AuthState interface and existing hook structure
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- src/client/hooks/useAuth.ts AuthState interface includes `isAdmin?: boolean` in the user object type
</acceptance_criteria>
</task>
<task id="36-02-T2">
<type>execute</type>
<title>Create admin route directory and admin layout route (admin.tsx)</title>
<files>
src/client/routes/admin.tsx
src/client/routes/admin/
</files>
<read_first>
- 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
</read_first>
<action>
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 (
<div className="flex min-h-[calc(100vh-3.5rem)]">
{/* Sidebar */}
<aside className="w-56 border-r border-gray-100 bg-white p-4 flex flex-col gap-1 shrink-0">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">
Admin
</p>
{/* Items — disabled (phase 37) */}
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed"
title="Coming in a future release"
>
<LucideIcon name="package" size={16} />
<span>Items</span>
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</div>
{/* Tags — disabled (phase 38) */}
<div
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed"
title="Coming in a future release"
>
<LucideIcon name="tag" size={16} />
<span>Tags</span>
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">
Soon
</span>
</div>
</aside>
{/* Main content */}
<main className="flex-1 p-6 bg-gray-50">
<Outlet />
</main>
</div>
);
}
```
</action>
<acceptance_criteria>
- 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 `<Outlet />` in the main content area
- Non-admin users are redirected (either via beforeLoad redirect or useEffect navigate) to "/"
</acceptance_criteria>
</task>
<task id="36-02-T3">
<type>execute</type>
<title>Create admin/index.tsx placeholder content</title>
<files>
src/client/routes/admin/index.tsx
</files>
<read_first>
- src/client/routes/admin.tsx — confirm the route structure so the index matches correctly
- src/client/lib/iconData.ts — verify LucideIcon import path
</read_first>
<action>
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 (
<div className="flex flex-col items-center justify-center h-64 text-center">
<LucideIcon name="shield" size={32} className="text-gray-300 mb-3" />
<p className="text-sm text-gray-500">Admin Panel</p>
<p className="text-xs text-gray-400 mt-1">
Select a section from the sidebar
</p>
</div>
);
}
```
</action>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
<task id="36-02-T4">
<type>execute</type>
<title>Add conditional Admin link to UserMenu</title>
<files>
src/client/components/UserMenu.tsx
</files>
<read_first>
- 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
</read_first>
<action>
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 && (
<div className="absolute right-0 mt-1 w-40 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
{/* Admin link — only visible to admin users */}
{auth?.user?.isAdmin && (
<>
<Link
to="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="shield" size={16} className="text-gray-400" />
Admin
</Link>
<div className="border-t border-gray-100 my-1" />
</>
)}
{/* Existing links below unchanged */}
<Link
to="/profile"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
...
</Link>
...
</div>
)}
```
Keep all existing menu items unchanged. Only add the Admin link + divider at the top, conditionally rendered.
</action>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
<task id="36-02-T5">
<type>execute</type>
<title>Add /admin to public route allowlist in __root.tsx</title>
<files>
src/client/routes/__root.tsx
</files>
<read_first>
- src/client/routes/__root.tsx — read the isPublicRoute logic and the auth guard that redirects to /login
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- 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
</acceptance_criteria>
</task>
</tasks>
<verification>
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
</verification>
<must_haves>
- /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
</must_haves>
<success_criteria>
- [ ] 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
</success_criteria>

View 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

View File

@@ -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 │ │
│ │ <Outlet /> (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 `<Outlet />`
### 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
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-gray-300 cursor-not-allowed">
<LucideIcon name="package" size={16} />
<span>Items</span>
<span className="ml-auto text-xs bg-gray-100 text-gray-400 px-1.5 py-0.5 rounded">Soon</span>
</div>
```
Icons to use:
- Items → `"package"` (matches existing collection icon)
- Tags → `"tag"`
### Admin Index Placeholder (`src/client/routes/admin/index.tsx`)
Simple centered placeholder:
```tsx
<div className="flex flex-col items-center justify-center h-64 text-center">
<LucideIcon name="shield" size={32} className="text-gray-300 mb-3" />
<p className="text-sm text-gray-500">Admin Panel</p>
<p className="text-xs text-gray-400 mt-1">Select a section from the sidebar</p>
</div>
```
### Admin Link in UserMenu
Position: before Profile link (top of menu).
Only rendered when `auth?.user?.isAdmin === true`.
```tsx
{auth?.user?.isAdmin && (
<>
<Link
to="/admin"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<LucideIcon name="shield" size={16} className="text-gray-400" />
Admin
</Link>
<div className="border-t border-gray-100 my-1" />
</>
)}
```
---
## 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