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:
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>
|
||||
Reference in New Issue
Block a user