# Task 1.7 — Routing skeleton (TanStack Router + role-aware guards) **Phase:** 1 — Foundation **Status:** ⬜ Not started **Depends on:** 1.5, 1.6 **Wiki refs:** `docs/wiki/entities/react-spa.md`; `.planning/ROADMAP.md` design rule 6 ## Goal Wire TanStack Router with file-based routes: a root layout, a public `/login` route, a protected route group (everything else), and a role-aware guard that gates routes by `user.role`. After this task, an unauthenticated user is redirected to `/login`; an authenticated user lands on a placeholder home page. The role-aware guard is in shape from day one even though everyone's effectively admin until [[directus]] Phase 4 — retrofitting "the SPA assumes everyone sees everything" later is painful. ## Deliverables - `vite.config.ts` updated to include `@tanstack/router-plugin/vite`: ```ts import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; // ... in plugins array, BEFORE react(): TanStackRouterVite({ target: 'react', autoCodeSplitting: true }), ``` - `src/routeTree.gen.ts` (auto-generated by the plugin; gitignored). - `src/routes/__root.tsx` — the root route: - Wraps everything in a layout (currently just an `` for the matched child). - Provides the TanStack Query `QueryClientProvider`. - In dev: `` + `` (lazy-loaded so they don't ship in prod). - Reads the auth store and redirects to `/login` if status is `'anonymous'` and the matched route is in the protected group (the `_authed` segment). - `src/routes/login.tsx` — the public login route. Renders `` from 1.6. - `src/routes/_authed/route.tsx` — the protected route group. The layout for any route under `_authed/`. Calls `useRequireAuth()` from 1.5; if status is anything other than `'authenticated'`, returns a loading placeholder; otherwise renders ``. - `src/routes/_authed/index.tsx` — placeholder home page (`/`). Just a header "TRM" + "Logged in as {user.first_name}" + a placeholder card "Live monitoring map will land in Phase 2." Logout button (Phase 1.8 wires the action; this task adds the button). - `src/App.tsx` — replaced. Now just renders the ``: ```tsx import { RouterProvider, createRouter } from '@tanstack/react-router'; import { routeTree } from './routeTree.gen'; import { queryClient } from './lib/query-client'; const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent', }); declare module '@tanstack/react-router' { interface Register { router: typeof router; } } export default function App() { return ; } ``` - `src/lib/query-client.ts` — module-level `QueryClient` singleton. - Role-aware guard: - `useRequireRole(allowedRoles: string[])` — used inside protected route loaders/components. Currently no route restricts by role (everyone's admin); the helper exists and is documented for Phase 2 onwards. - `package.json` `dependencies` already include the TanStack Router/Query packages from 1.2; ensure devtools are added: `pnpm add -D @tanstack/router-plugin @tanstack/router-devtools @tanstack/react-query-devtools`. ## Specification ### File-based routing layout TanStack Router's file-based routing maps `src/routes/` to URL paths. Naming conventions: | File | Path | Purpose | | -------------------- | ---------------------- | ----------------------------------------------------------------------------- | | `__root.tsx` | (root layout) | Wraps everything; provides QueryClient and runs the auth gate | | `login.tsx` | `/login` | Public login page | | `_authed/route.tsx` | (layout for `_authed`) | Protected layout; the `_` prefix is "pathless" — no URL segment for `_authed` | | `_authed/index.tsx` | `/` | Home page (matches the empty path under `_authed`) | | `_authed/events.tsx` | `/events` | Future event-list page (Phase 2-ish) | The pathless `_authed` group is the TanStack Router idiom for "share a layout (and a guard) without adding a URL segment." All authenticated routes live under it; all live-public routes are siblings (just `login.tsx` for now). ### Root route — auth gate ```tsx // src/routes/__root.tsx import { createRootRouteWithContext, Outlet, redirect } from '@tanstack/react-router'; import { useAuthStore } from '@/auth/store'; import type { QueryClient } from '@tanstack/react-query'; export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({ component: RootLayout, }); function RootLayout() { return ( <> {import.meta.env.DEV && } ); } ``` The actual auth gate lives in `_authed/route.tsx` so the public `/login` route is unaffected. ### Protected layout ```tsx // src/routes/_authed/route.tsx import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'; import { useAuthStore } from '@/auth/store'; export const Route = createFileRoute('/_authed')({ beforeLoad: ({ location }) => { const status = useAuthStore.getState().status; if (status === 'unknown' || status === 'authenticating') { // Still resolving; let the component render a loading state. return; } if (status === 'anonymous') { throw redirect({ to: '/login', search: { redirect: location.href } }); } }, component: AuthedLayout, }); function AuthedLayout() { const status = useAuthStore((s) => s.status); if (status !== 'authenticated') { return ; } return ; } ``` The `beforeLoad` redirect is the canonical TanStack Router idiom. `search.redirect = location.href` lets `/login` send the user back where they tried to go. ### Why also a runtime check inside the component `beforeLoad` runs once per navigation; if the auth state changes during the session (logout in another tab → status becomes `'anonymous'`), the existing route doesn't auto-navigate. The runtime check + the `useRequireAuth` hook from 1.5 catch that case. Belt-and-braces. ### Role-aware guard helper ```ts export function useRequireRole(allowedRoles: string[]) { const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); const navigate = useNavigate(); useEffect(() => { if (!user) return; // not authenticated — _authed layout already handles if (user.role && !allowedRoles.includes(user.role)) { navigate({ to: '/', replace: true }); // bounce to home } }, [user, allowedRoles, navigate]); } ``` Currently no Phase 1 route uses it. Document with an example in the file comment so future task implementers know to add `useRequireRole(['race-director'])` to admin-only pages when those land in Phase 2+. ### Home page placeholder ```tsx // src/routes/_authed/index.tsx import { createFileRoute } from '@tanstack/react-router'; import { useAuthStore } from '@/auth/store'; import { Card, CardContent, CardHeader, CardTitle } from '@/ui/primitives/card'; import { Button } from '@/ui/primitives/button'; export const Route = createFileRoute('/_authed/')({ component: HomePage, }); function HomePage() { const user = useAuthStore((s) => (s.status === 'authenticated' ? s.user : null)); if (!user) return null; // covered by layout return (

TRM

{user.first_name ?? user.email}
Live monitoring map

The live-position map lands in Phase 2 once the Processor's WS endpoint is shipped.

); } ``` ### What this task does NOT do - **Logout action.** Button is rendered but the click handler is wired in 1.8. - **Sub-routes for events/devices/etc.** Those land in Phase 2 alongside the live map. - **Full app shell with sidebar nav.** The header above is intentionally minimal — Phase 2 expands it when there are real navigation targets. ## Acceptance criteria - [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check` clean. - [ ] `pnpm dev` → navigating to `/` while logged out redirects to `/login?redirect=...`. - [ ] After successful login, the SPA navigates to the redirect target (or `/` if none). - [ ] Refreshing on `/` while authenticated stays on `/`; the auth store re-initialises against the persisted refresh cookie and `initialize()` resolves to `'authenticated'` before the route gate fires. - [ ] Refreshing on `/` while unauthenticated redirects to `/login`. - [ ] Browser back/forward buttons work between `/login` and `/` correctly (no infinite redirect loops). - [ ] Devtools render in dev mode (`import.meta.env.DEV === true`); they don't render in prod build (`pnpm build` then `pnpm preview`). - [ ] Type-checked navigation: `` is OK; `` fails type-check. ## Risks / open questions - **Race between `initialize()` and `beforeLoad`.** If `beforeLoad` runs before the auth store has finished `initialize()`, status is `'unknown'` and the gate falls through to the component (which shows the loading spinner). Verify the timing — TanStack Router's `defaultPendingComponent` may be a cleaner place for the loading state. - **TanStack Router file-based routes regenerate on save.** The `routeTree.gen.ts` file is auto-managed; gitignore it. CI will regenerate during `pnpm build`. Document in README. - **Search params type-safety.** TanStack Router's typed search params require a validation function. For `/login`, validate `redirect` as `z.string().optional()`. Add as a small `validateSearch` on the login route. - **Devtools bundle size.** Lazy-load via dynamic import inside the `import.meta.env.DEV` branch so the prod bundle doesn't ship them. ## Done (Filled in when the task lands.)