Files
Jean-Luc Makiola 94e2a8c019 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>
2026-04-19 20:43:12 +02:00

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
36-01
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
true
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_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>

execute 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:

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?: boolean in the user object type </acceptance_criteria>
execute 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.

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>
  );
}
- 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 "/" execute 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`:
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>
  );
}
- 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 execute 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:

{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?.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>
execute 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:

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>
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

<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>