- 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>
13 KiB
phase, plan, title, type, wave, depends_on, files_modified, autonomous, requirements
| phase | plan | title | type | wave | depends_on | files_modified | autonomous | requirements | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 36 | 02 | Client /admin route, admin shell with sidebar, UserMenu admin link | execute | 2 |
|
|
true |
|
<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>
Current:
interface AuthState {
user: { id: string; email?: string; createdAt?: string } | null;
authenticated: boolean;
}
Updated:
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.
<acceptance_criteria>
- src/client/hooks/useAuth.ts AuthState interface includes
isAdmin?: booleanin the user object type </acceptance_criteria>
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.
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>
);
}
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>
);
}
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:
{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. <acceptance_criteria>
- src/client/components/UserMenu.tsx renders an Admin link when
auth?.user?.isAdminis true - The Admin link uses
to="/admin"and renders a "shield" LucideIcon - A
border-t border-gray-100divider separates Admin from the Profile link - When
auth?.user?.isAdminis false or undefined, the Admin link and its divider are not rendered - All existing menu items (Profile, Settings, Sign out) remain unchanged </acceptance_criteria>
Current:
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:
// /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. <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>
<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>