---
phase: 36
plan: 02
title: "Client /admin route, admin shell with sidebar, UserMenu admin link"
type: execute
wave: 2
depends_on:
- 36-01
files_modified:
- src/client/routes/admin.tsx
- src/client/routes/admin/index.tsx
- src/client/hooks/useAuth.ts
- src/client/components/UserMenu.tsx
- src/client/routes/__root.tsx
autonomous: true
requirements:
- ADMN-01
---
Create the client-side `/admin` route with a beforeLoad guard that redirects non-admin users to home, build the admin shell with a sidebar (Items + Tags nav items, both disabled/coming-soon), create the placeholder admin index view, update the AuthState type to include isAdmin, and add a conditional Admin link to the UserMenu.
**Threat:** A non-admin authenticated user navigates directly to /admin in the browser.
**Mitigation:** `beforeLoad` guard in the /admin route reads `isAdmin` from the auth query cache and throws a `redirect({ to: "/" })` if false — the component never renders. Belt-and-suspenders: server also returns 403 on /api/admin/* endpoints.
**Threat:** Admin link is shown to non-admin users due to a stale auth cache.
**Mitigation:** `useAuth()` has `staleTime: 5 * 60 * 1000` — a non-admin can only see the link if auth cache is stale AND isAdmin was previously true. Risk is negligible since server always enforces the 403 check.
executeUpdate AuthState interface in useAuth.ts to include isAdmin
src/client/hooks/useAuth.ts
- src/client/hooks/useAuth.ts — read the full file to see the AuthState interface and existing hook structure
In `src/client/hooks/useAuth.ts`, update the `AuthState` interface to include `isAdmin`:
Current:
```typescript
interface AuthState {
user: { id: string; email?: string; createdAt?: string } | null;
authenticated: boolean;
}
```
Updated:
```typescript
interface AuthState {
user: { id: string; email?: string; createdAt?: string; isAdmin?: boolean } | null;
authenticated: boolean;
}
```
No other changes needed. The `useAuth()` hook fetches from `/api/auth/me` which now returns `isAdmin` after plan 36-01.
- src/client/hooks/useAuth.ts AuthState interface includes `isAdmin?: boolean` in the user object type
executeCreate admin route directory and admin layout route (admin.tsx)
src/client/routes/admin.tsx
src/client/routes/admin/
- src/client/routes/__root.tsx — understand the existing route structure and TanStack Router patterns (createRootRoute, Outlet, beforeLoad pattern)
- src/client/routes/settings.tsx — read as an example of a simple protected route pattern
- src/client/lib/iconData.ts — verify LucideIcon import path
- src/client/hooks/useAuth.ts — verify useAuth import path
Create `src/client/routes/admin.tsx` — the admin layout route (shell with sidebar).
**Context:** The router in `src/client/main.tsx` is created with `context: {}` (empty) — the queryClient is NOT passed via router context. Use the component-level guard pattern (useEffect + navigate) rather than beforeLoad.
```typescript
import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData";
export const Route = createFileRoute("/admin")({
component: AdminLayout,
});
function AdminLayout() {
const navigate = useNavigate();
const { data: auth, isLoading } = useAuth();
useEffect(() => {
if (!isLoading && !auth?.user?.isAdmin) {
navigate({ to: "/" });
}
}, [auth, isLoading, navigate]);
// Don't render the shell until auth is confirmed
if (isLoading || !auth?.user?.isAdmin) return null;
return (
{/* Sidebar */}
{/* Main content */}
);
}
```
- src/client/routes/admin.tsx exists and exports a Route created with `createFileRoute("/admin")`
- The component renders a sidebar with "Admin" heading
- The sidebar contains two disabled nav items: one with icon "package" labeled "Items" and one with icon "tag" labeled "Tags"
- Both disabled items have a "Soon" badge
- The component renders `` in the main content area
- Non-admin users are redirected (either via beforeLoad redirect or useEffect navigate) to "/"
executeCreate admin/index.tsx placeholder content
src/client/routes/admin/index.tsx
- src/client/routes/admin.tsx — confirm the route structure so the index matches correctly
- src/client/lib/iconData.ts — verify LucideIcon import path
Create the `src/client/routes/admin/` directory and `src/client/routes/admin/index.tsx`:
```typescript
import { createFileRoute } from "@tanstack/react-router";
import { LucideIcon } from "../../lib/iconData";
export const Route = createFileRoute("/admin/")({
component: AdminIndex,
});
function AdminIndex() {
return (
Admin Panel
Select a section from the sidebar
);
}
```
- src/client/routes/admin/index.tsx exists
- It exports a Route with `createFileRoute("/admin/")`
- The component renders a centered placeholder with a "shield" icon, "Admin Panel" text, and a subtext
executeAdd conditional Admin link to UserMenu
src/client/components/UserMenu.tsx
- src/client/components/UserMenu.tsx — read the full file to understand the existing menu structure, Link usage, and auth data access
- src/client/hooks/useAuth.ts — confirm that auth.user.isAdmin is now typed
In `src/client/components/UserMenu.tsx`, add a conditional Admin link as the first item in the dropdown menu (before the Profile link).
The `auth` variable is already read via `const { data: auth } = useAuth();`.
Update the menu dropdown JSX to add the Admin link before the Profile link:
```tsx
{open && (
)}
```
Keep all existing menu items unchanged. Only add the Admin link + divider at the top, conditionally rendered.
- src/client/components/UserMenu.tsx renders an Admin link when `auth?.user?.isAdmin` is true
- The Admin link uses `to="/admin"` and renders a "shield" LucideIcon
- A `border-t border-gray-100` divider separates Admin from the Profile link
- When `auth?.user?.isAdmin` is false or undefined, the Admin link and its divider are not rendered
- All existing menu items (Profile, Settings, Sign out) remain unchanged
executeAdd /admin to public route allowlist in __root.tsx
src/client/routes/__root.tsx
- src/client/routes/__root.tsx — read the isPublicRoute logic and the auth guard that redirects to /login
In `src/client/routes/__root.tsx`, update the `isPublicRoute` check to include `/admin` so the root layout does NOT redirect admin users to `/login` before the admin route's own guard can run.
Current:
```typescript
const isPublicRoute =
location.pathname === "/" ||
location.pathname.startsWith("/users/") ||
...
location.pathname === "/login" || ...
```
The issue: If an admin navigates to `/admin`, the root layout runs `if (!isAuthenticated && !isPublicRoute) navigate({ to: "/login" })`. For admin users who ARE authenticated, this is not a problem. But to be safe and explicit, the `/admin` route should be treated as a **protected** route (not public). The root layout's auth guard redirects unauthenticated users to `/login`, which is correct behavior for `/admin`.
**Action:** No change needed to `isPublicRoute` — the current logic already handles authenticated users correctly (the guard only fires for unauthenticated users). The admin route's own guard handles the isAdmin check.
However, verify that `src/client/routes/__root.tsx` does NOT exclude `/admin` from the auth guard in a way that would allow unauthenticated access. Read the file and confirm no changes are needed. If the existing `isPublicRoute` logic would incorrectly allow `/admin` access without auth, add:
```typescript
// /admin is NOT a public route — root auth guard handles unauthenticated redirect
// admin.tsx beforeLoad handles non-admin redirect
```
as a comment to clarify intent. No code change if logic is already correct.
- src/client/routes/__root.tsx is unchanged OR has a clarifying comment
- The /admin route is NOT in the isPublicRoute list (it requires authentication)
- An unauthenticated user navigating to /admin is redirected to /login by the root guard
- An authenticated non-admin navigating to /admin is redirected to / by the admin route's guard
1. `bun run build` exits 0 — no TypeScript errors in new route files
2. The route tree is regenerated — `routeTree.gen.ts` includes `/admin` and `/admin/` routes
3. src/client/hooks/useAuth.ts AuthState interface includes `isAdmin?: boolean`
4. src/client/routes/admin.tsx exists with createFileRoute("/admin")
5. src/client/routes/admin/index.tsx exists with createFileRoute("/admin/")
6. src/client/components/UserMenu.tsx conditionally renders Admin link when isAdmin is true
7. Manual verification: admin user sees Admin link in UserMenu; non-admin does not
- /admin route exists and is guarded against non-admin users
- Admin shell renders sidebar with Items and Tags (disabled)
- Admin index placeholder renders inside the shell
- Admin link appears in UserMenu only when isAdmin is true
- TypeScript type for isAdmin propagated through AuthState
- [ ] src/client/routes/admin.tsx exists with createFileRoute("/admin") and guard logic
- [ ] src/client/routes/admin/index.tsx exists with placeholder UI
- [ ] Admin sidebar renders "Items" (package icon) and "Tags" (tag icon) both disabled with "Soon" badge
- [ ] Non-admin redirect is implemented (beforeLoad or useEffect)
- [ ] UserMenu shows Admin link when auth.user.isAdmin is true
- [ ] bun run build exits 0
- [ ] routeTree.gen.ts includes /admin route