Files
spa/.planning/phase-1-foundation/07-routing-skeleton.md
T
julian dc4e73f73a feat: task 1.7 routing skeleton
TanStack Router file-based routes:
- vite.config.ts: TanStackRouterVite plugin (target: react,
  autoCodeSplitting: true) before the React plugin.
- .gitignore: src/routeTree.gen.ts (auto-generated).
- src/lib/query-client.ts: module-level QueryClient singleton.
- src/routes/__root.tsx: root route, QueryClientProvider, Suspense-
  wrapped router + query devtools (dev-only via import.meta.env.DEV).
- src/routes/login.tsx: public route. validateSearch schema accepts
  optional redirect. LoginRoute wraps <LoginPage> with onAuthenticated
  navigating to redirect ?? '/'.
- src/routes/_authed/route.tsx: pathless protected layout. beforeLoad
  redirects to /login on 'anonymous'; falls through on 'unknown' /
  'authenticating' so hard reload doesn't flash the login page.
- src/routes/_authed/index.tsx: placeholder home with sign-out button.
  Cross-tab logout effect bounces to /login on store transition.
- src/App.tsx: replaced — now creates the router and renders
  <RouterProvider />. The interim status-branching logic from 1.6 is
  gone (the route tree handles it).
- eslint.config.js: override for src/routes/** disabling
  react-refresh/only-export-components — TanStack Router's file-based
  pattern intentionally co-exports Route alongside components.

Code-splitting working: login chunk 37KB, home + auth 17KB, card
primitive 31KB, all loaded lazily; main bundle 353KB.

Deviations:
1. No top-level useRequireAuth hook — per-page useEffect on store
   transitions is just as effective and only used in one place.
2. ESLint override for src/routes/** matches the existing pattern for
   src/ui/primitives/**.
3. Login page's auto-navigate effect from 1.6 is removed — the route
   wrapper owns navigation via onAuthenticated.
2026-05-02 18:45:55 +02:00

246 lines
14 KiB
Markdown

# 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 `<Outlet />` for the matched child).
- Provides the TanStack Query `QueryClientProvider`.
- In dev: `<TanStackRouterDevtools />` + `<ReactQueryDevtools />` (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 `<LoginPage />` 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 `<Outlet />`.
- `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 `<RouterProvider />`:
```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 <RouterProvider router={router} />;
}
```
- `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 (
<>
<Outlet />
{import.meta.env.DEV && <Devtools />}
</>
);
}
```
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 <CenteredSpinner label="Loading…" />;
}
return <Outlet />;
}
```
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 (
<div className="container mx-auto p-6 space-y-4">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">TRM</h1>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{user.first_name ?? user.email}</span>
<Button variant="outline" onClick={/* wired in 1.8 */ () => {}}>
Sign out
</Button>
</div>
</header>
<Card>
<CardHeader>
<CardTitle>Live monitoring map</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
The live-position map lands in Phase 2 once the Processor's WS endpoint is shipped.
</p>
</CardContent>
</Card>
</div>
);
}
```
### 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: `<Link to="/" />` is OK; `<Link to="/missing" />` 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
TanStack Router wired with file-based routes:
- **`vite.config.ts`** — `TanStackRouterVite({ target: 'react', autoCodeSplitting: true })` added before the React plugin (router plugin must run first so `routeTree.gen.ts` exists when React's transform runs). Path-alias and proxy config from 1.3 untouched.
- **`.gitignore`** — added `src/routeTree.gen.ts`.
- **`src/lib/query-client.ts`** — module-level `QueryClient` singleton with sensible defaults (refetch on focus, 0 stale-time default with per-query overrides expected, retry: 1).
- **`src/routes/__root.tsx`** — `createRootRouteWithContext<{ queryClient }>()`. Wraps `<Outlet />` in `<QueryClientProvider>`. In dev only, `<Suspense>`-wrapped `<TanStackRouterDevtools position="bottom-right" />` + `<ReactQueryDevtools buttonPosition="bottom-left" />` lazy-loaded via `React.lazy` gated on `import.meta.env.DEV` so the prod bundle doesn't ship them.
- **`src/routes/login.tsx`** — public login route. `validateSearch` schema accepts an optional `redirect` string. `LoginRoute` wraps `<LoginPage>` (from 1.6) and passes `onAuthenticated → navigate({ to: redirect ?? '/', replace: true })`.
- **`src/routes/_authed/route.tsx`** — pathless protected layout. `beforeLoad` reads `useAuthStore.getState().status`; on `'anonymous'` throws `redirect({ to: '/login', search: { redirect: location.href } })`. The `'unknown'` and `'authenticating'` statuses fall through to the component, which renders a "Loading…" placeholder until the boot probe resolves (without that, hard reloads flash the login page on every refresh — bad UX).
- **`src/routes/_authed/index.tsx`** — placeholder home page (`/`). Header with TRM brand + signed-in display name + sign-out button (calls `useAuthStore.getState().logout()` then the component's status effect navigates to `/login`). Card placeholder for the live monitoring map (Phase 2). Uses `useNavigate` to handle the cross-tab logout case where the store transitions to `'anonymous'` mid-session.
- **`src/App.tsx`** — replaced. Now creates the router with `createRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent' })` and renders `<RouterProvider router={router} />`. The interim `App.tsx` from 1.6 (which branched on auth status directly) is gone — that branching now lives in the route tree.
**Deviations from spec:**
1. Spec sketched a `useRequireAuth()` hook + a runtime check inside the layout component as belt-and-braces. The runtime check landed in `_authed/index.tsx` (and any future protected page) as a `useEffect` that responds to store transitions. Didn't add a top-level `useRequireAuth()` hook — the per-page effect is just as effective and avoids adding a hook that's only used in one place.
2. Added an ESLint override for `src/routes/**` to disable `react-refresh/only-export-components`. TanStack Router's file-based pattern intentionally co-exports `Route` (the route definition object) alongside the route's component — the rule doesn't apply usefully there. Same shape as the existing override for `src/ui/primitives/**`.
3. Removed the `LoginPage` auto-navigate effect from 1.6 — the route wrapper (`LoginRoute`) now owns the navigation via `onAuthenticated`, so the page itself stays pure.
**Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. Bundle: 353KB main + per-route chunks (login 37KB, home + auth 17KB, card primitive 31KB lazy). Code-splitting working — login page only loads its chunk on the login route.
Browser smoke pending: against a running stage Directus, expected behaviour is `/` → 302 to `/login?redirect=%2F` → submit credentials → land on `/`. Hard refresh on `/` while authenticated stays on `/`. Already-authenticated user navigating to `/login` is auto-redirected away.
Landed in `PENDING_SHA`.