Files
spa/.planning/phase-1-foundation/07-routing-skeleton.md
T
julian 26e059fc20 feat: planning structure + task 1.2 stack rounding-out
Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
2026-05-02 18:41:54 +02:00

223 lines
10 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
(Filled in when the task lands.)