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:
@@ -2,16 +2,16 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v2.4
|
milestone: v2.4
|
||||||
milestone_name: Admin Foundation
|
milestone_name: Admin Foundation
|
||||||
status: executing
|
status: ready_to_execute
|
||||||
stopped_at: Phase 36 context gathered
|
stopped_at: Phase 36 planned — 2 plans ready
|
||||||
last_updated: "2026-04-19T18:30:00.000Z"
|
last_updated: "2026-04-19T19:00:00.000Z"
|
||||||
last_activity: 2026-04-19
|
last_activity: 2026-04-19
|
||||||
progress:
|
progress:
|
||||||
total_phases: 20
|
total_phases: 20
|
||||||
completed_phases: 8
|
completed_phases: 8
|
||||||
total_plans: 32
|
total_plans: 34
|
||||||
completed_plans: 32
|
completed_plans: 32
|
||||||
percent: 100
|
percent: 94
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
@@ -21,16 +21,16 @@ progress:
|
|||||||
See: .planning/PROJECT.md (updated 2026-04-19)
|
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.
|
**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
|
## Current Position
|
||||||
|
|
||||||
Phase: 35 — Bug Fixes
|
Phase: 36 — Admin Role & Panel Foundation
|
||||||
Plan: 3 of 3 complete
|
Plan: 0 of 2 complete
|
||||||
Status: Ready to execute
|
Status: Ready to execute
|
||||||
Last activity: 2026-04-19
|
Last activity: 2026-04-19
|
||||||
|
|
||||||
Progress: [██████████] 97%
|
Progress: [████████░░] 94%
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
|
|||||||
376
.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
Normal file
376
.planning/phases/36-admin-role-panel-foundation/36-01-PLAN.md
Normal 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>
|
||||||
329
.planning/phases/36-admin-role-panel-foundation/36-02-PLAN.md
Normal file
329
.planning/phases/36-admin-role-panel-foundation/36-02-PLAN.md
Normal 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>
|
||||||
247
.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
Normal file
247
.planning/phases/36-admin-role-panel-foundation/36-RESEARCH.md
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# Phase 36: Admin Role & Panel Foundation — Research
|
||||||
|
|
||||||
|
**Phase:** 36 — Admin Role & Panel Foundation
|
||||||
|
**Researched:** 2026-04-19
|
||||||
|
**Requirements:** ROLE-01, ROLE-02, ADMN-01
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This phase adds an `isAdmin` boolean to the `users` table, surfaces it in `/api/auth/me`, creates a `requireAdmin` middleware, and builds a protected `/admin` client route with a sidebar shell. All decisions are already locked in CONTEXT.md. The work is additive and low-risk — no existing logic is removed, only extended.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Database Schema Change
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
`src/db/schema.ts` — `users` table has: `id`, `logtoSub`, `displayName`, `avatarUrl`, `bio`, `createdAt`. No `isAdmin` column.
|
||||||
|
|
||||||
|
### Required Change
|
||||||
|
Add `isAdmin: boolean("is_admin").notNull().default(false)` to the `users` pgTable definition.
|
||||||
|
|
||||||
|
### Migration Mechanics
|
||||||
|
- Drizzle ORM (PostgreSQL) — dialect is `postgresql`, config at `drizzle.config.ts`
|
||||||
|
- Generate: `bunx drizzle-kit generate` → creates new SQL file in `drizzle-pg/`
|
||||||
|
- The generated migration will be `ALTER TABLE "users" ADD COLUMN "is_admin" boolean NOT NULL DEFAULT false;`
|
||||||
|
- Apply: `bunx drizzle-kit push` (or `bun run db:push`)
|
||||||
|
- This is non-destructive — `DEFAULT false` means all existing rows get `false`
|
||||||
|
|
||||||
|
### Validation Architecture
|
||||||
|
- After migration, `SELECT is_admin FROM users LIMIT 1;` returns a boolean value
|
||||||
|
- Drizzle `eq(users.isAdmin, true)` works in queries after schema update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. requireAdmin Middleware
|
||||||
|
|
||||||
|
### Current Auth Flow
|
||||||
|
`src/server/middleware/auth.ts` exports `requireAuth`. It handles:
|
||||||
|
1. API key (`X-API-Key` header) → `verifyApiKey(db, key)` → sets `c.set("userId", result.userId)`
|
||||||
|
2. OAuth Bearer token → `verifyAccessToken` → sets `c.set("userId", result.userId)`
|
||||||
|
3. OIDC session (browser) → `getOrCreateUser` → sets `c.set("userId", user.id)`
|
||||||
|
|
||||||
|
`requireAuth` sets `userId` on context but does NOT query `isAdmin`.
|
||||||
|
|
||||||
|
### requireAdmin Pattern
|
||||||
|
`requireAdmin` must:
|
||||||
|
1. Call `requireAuth` logic first (or call `requireAuth` and chain), OR
|
||||||
|
2. Be a standalone middleware that verifies auth AND checks `isAdmin`
|
||||||
|
|
||||||
|
**Recommended approach (avoids double-next issues):** `requireAdmin` is a standalone middleware that:
|
||||||
|
- Replicates the "is this user authenticated?" check from `requireAuth`
|
||||||
|
- After setting `userId`, queries `users` table for `isAdmin`
|
||||||
|
- Returns 403 if `isAdmin` is false or null
|
||||||
|
|
||||||
|
**Alternative cleaner approach:** Call `requireAuth` inline, then check `isAdmin` in a second middleware. Hono supports middleware chaining: `app.get("/admin/*", requireAuth, requireAdmin, handler)`. The `requireAdmin` middleware reads `c.get("userId")` (set by `requireAuth`) and queries the db.
|
||||||
|
|
||||||
|
**Decision for plan:** Use composition — `requireAdmin` is a separate middleware that expects `userId` to already be set (by `requireAuth`), then queries `users` table for `isAdmin` flag. Register on routes as: `requireAuth, requireAdmin`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/server/middleware/auth.ts (addition)
|
||||||
|
export async function requireAdmin(c: Context, next: Next) {
|
||||||
|
const db = c.get("db");
|
||||||
|
const userId = c.get("userId");
|
||||||
|
if (!userId) return c.json({ error: "Authentication required" }, 401);
|
||||||
|
const [user] = await db.select({ isAdmin: users.isAdmin }).from(users).where(eq(users.id, userId));
|
||||||
|
if (!user?.isAdmin) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Admin Grant Mechanism (D-03)
|
||||||
|
|
||||||
|
Per CONTEXT.md decision D-03: no CLI script needed. Developers use direct SQL:
|
||||||
|
```sql
|
||||||
|
UPDATE users SET is_admin = true WHERE logto_sub = '<sub>';
|
||||||
|
```
|
||||||
|
Or via Drizzle Studio (the interactive UI that ships with drizzle-kit).
|
||||||
|
|
||||||
|
**Note:** The ROADMAP success criterion says "a developer can grant or revoke admin status via a CLI script or seed mechanism". The CONTEXT.md overrides this with the decision that direct SQL is sufficient. Per the CONTEXT.md decision hierarchy, CONTEXT.md decisions take precedence — no CLI script needed. However, a simple admin-grant script (`scripts/grant-admin.ts`) would be minimal effort and satisfy the roadmap success criterion. **Recommendation:** Create a tiny `scripts/grant-admin.ts` that accepts a logto_sub argument and sets `isAdmin = true`. This is ~10 lines and satisfies the success criterion without UI.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/grant-admin.ts
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../src/db/index.ts";
|
||||||
|
import { users } from "../src/db/schema.ts";
|
||||||
|
|
||||||
|
const sub = process.argv[2];
|
||||||
|
if (!sub) { console.error("Usage: bun scripts/grant-admin.ts <logto-sub>"); process.exit(1); }
|
||||||
|
const [user] = await db.update(users).set({ isAdmin: true }).where(eq(users.logtoSub, sub)).returning({ id: users.id, logtoSub: users.logtoSub });
|
||||||
|
if (!user) { console.error("User not found:", sub); process.exit(1); }
|
||||||
|
console.log(`Granted admin to user ${user.id} (${user.logtoSub})`);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. /api/auth/me — isAdmin Surface
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
`src/server/routes/auth.ts` `/me` endpoint returns:
|
||||||
|
```json
|
||||||
|
{ "user": { "id": ..., "email": ..., "createdAt": ... }, "authenticated": true }
|
||||||
|
```
|
||||||
|
It queries `fullUser` from `users` table but only returns `id`, `email`, `createdAt`.
|
||||||
|
|
||||||
|
### Required Change
|
||||||
|
Add `isAdmin: fullUser?.isAdmin ?? false` to the returned `user` object.
|
||||||
|
|
||||||
|
### Client Hook
|
||||||
|
`src/client/hooks/useAuth.ts` — `AuthState` interface has `user: { id: string; email?: string; createdAt?: string } | null`. Add `isAdmin?: boolean`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Client Routing — /admin Route
|
||||||
|
|
||||||
|
### TanStack Router File-Based Routing
|
||||||
|
Routes are in `src/client/routes/`. File-based routing auto-generates the route tree to `routeTree.gen.ts` (never edit manually).
|
||||||
|
|
||||||
|
### Creating /admin Route
|
||||||
|
- Create `src/client/routes/admin.tsx` — the admin shell with layout + sidebar
|
||||||
|
- Create `src/client/routes/admin/` directory for future sub-routes (phases 37/38)
|
||||||
|
- Create `src/client/routes/admin/index.tsx` — the default admin view (placeholder)
|
||||||
|
|
||||||
|
**Alternative simpler structure:** Just `src/client/routes/admin.tsx` with an `<Outlet />` for sub-routes. TanStack Router will render the admin layout with `<Outlet>` for child routes.
|
||||||
|
|
||||||
|
**Recommended:** `admin.tsx` as the layout route (shell + sidebar) + `admin/index.tsx` as the placeholder content. This is the standard TanStack Router pattern for nested layouts.
|
||||||
|
|
||||||
|
### beforeLoad Guard
|
||||||
|
```typescript
|
||||||
|
export const Route = createFileRoute("/admin")({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
// context.auth from router context, or fetch from query
|
||||||
|
const auth = await queryClient.fetchQuery({ queryKey: ["auth"], queryFn: ... });
|
||||||
|
if (!auth?.user?.isAdmin) {
|
||||||
|
throw redirect({ to: "/" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
component: AdminLayout,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern from codebase:** The root route (`__root.tsx`) does auth checking inline in the component (`if (!isAuthenticated && !isPublicRoute) navigate({ to: "/login" })`). For `/admin`, use `beforeLoad` for cleaner protection — it prevents the component from rendering at all.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Admin Panel Shell UI
|
||||||
|
|
||||||
|
### Design Constraints
|
||||||
|
- Light/minimal aesthetic (white, gray palette, consistent with existing TopNav/UserMenu)
|
||||||
|
- Sidebar with two nav items: "Items" (phase 37) and "Tags" (phase 38) — both disabled/coming-soon
|
||||||
|
- The shell is persistent; phases 37/38 inject content via `<Outlet />`
|
||||||
|
|
||||||
|
### Existing UI Patterns to Reuse
|
||||||
|
- `bg-white`, `border-gray-100/200`, `text-gray-900/500/700` — standard palette
|
||||||
|
- `LucideIcon` from `lib/iconData` — use `"package"` for Items, `"tag"` for Tags
|
||||||
|
- Sidebar structure: left sidebar (fixed width) + main content area (`<Outlet />`)
|
||||||
|
- No dedicated sidebar component exists — build inline in admin layout
|
||||||
|
|
||||||
|
### Layout Structure
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────┐
|
||||||
|
│ TopNav (existing, always visible) │
|
||||||
|
├──────────┬─────────────────────────────────────────┤
|
||||||
|
│ Sidebar │ Main content (Outlet) │
|
||||||
|
│ │ │
|
||||||
|
│ [Items] │ (placeholder / child route content) │
|
||||||
|
│ [Tags] │ │
|
||||||
|
└──────────┴─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Server-Side /api/admin/* Route
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
No `/api/admin/*` routes exist. The server serves the SPA catch-all for `/admin` (client-side routing handles it).
|
||||||
|
|
||||||
|
### Required for This Phase
|
||||||
|
- Create `src/server/routes/admin.ts` — a placeholder admin router protected by `requireAdmin`
|
||||||
|
- Register in `src/server/index.ts` as `/api/admin`
|
||||||
|
- For now, only one endpoint is needed: `GET /api/admin/ping` or similar to confirm admin access works
|
||||||
|
|
||||||
|
**Actually:** The route doesn't need a `/api/admin/ping` endpoint for this phase — the guard can be verified via the middleware on the future routes (phases 37/38 will add actual endpoints). But having a placeholder makes testing the 403/200 behavior possible.
|
||||||
|
|
||||||
|
**Decision for plan:** Create `src/server/routes/admin.ts` with a single `GET /` (becomes `/api/admin/`) that returns `{ ok: true }`. Protected by `requireAuth, requireAdmin`. Register in index.ts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Conditional Admin Link in UserMenu
|
||||||
|
|
||||||
|
### Current UserMenu
|
||||||
|
`src/client/components/UserMenu.tsx` renders: Profile link, Settings link, divider, Sign out button.
|
||||||
|
|
||||||
|
### Required Change
|
||||||
|
Add "Admin" link above Profile, visible only when `auth?.user?.isAdmin === true`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{auth?.user?.isAdmin && (
|
||||||
|
<Link to="/admin" onClick={() => setOpen(false)} className="...">
|
||||||
|
<LucideIcon name="shield" size={16} className="text-gray-400" />
|
||||||
|
Admin
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Wave Planning
|
||||||
|
|
||||||
|
The work has clear dependencies:
|
||||||
|
- **Wave 1:** Schema migration + `requireAdmin` middleware + `/api/auth/me` change + grant script
|
||||||
|
- **Wave 2:** Client route + admin shell UI + UserMenu admin link
|
||||||
|
|
||||||
|
Wave 1 must complete before Wave 2 (client needs `isAdmin` in auth response).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Matrix
|
||||||
|
| Scenario | Expected Behavior |
|
||||||
|
|----------|------------------|
|
||||||
|
| Unauthenticated → GET /api/admin/ | 401 |
|
||||||
|
| Authenticated non-admin → GET /api/admin/ | 403 |
|
||||||
|
| Authenticated admin → GET /api/admin/ | 200 `{ok: true}` |
|
||||||
|
| Non-admin → navigate to /admin (client) | Redirect to `/` |
|
||||||
|
| Admin → navigate to /admin (client) | Admin shell renders |
|
||||||
|
| Admin link in UserMenu | Visible only when isAdmin=true |
|
||||||
|
|
||||||
|
### Verification Commands
|
||||||
|
```bash
|
||||||
|
# Check schema migration applied
|
||||||
|
bunx drizzle-kit studio # or psql query
|
||||||
|
|
||||||
|
# Check middleware compiles
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Manual API tests (curl with session/API key)
|
||||||
|
curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: <non-admin-key>" # → 403
|
||||||
|
curl -X GET http://localhost:3000/api/admin/ -H "X-API-Key: <admin-key>" # → 200
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RESEARCH COMPLETE
|
||||||
126
.planning/phases/36-admin-role-panel-foundation/36-UI-SPEC.md
Normal file
126
.planning/phases/36-admin-role-panel-foundation/36-UI-SPEC.md
Normal 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
|
||||||
Reference in New Issue
Block a user