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.
14 KiB
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.tsupdated to include@tanstack/router-plugin/vite: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
/loginif status is'anonymous'and the matched route is in the protected group (the_authedsegment).
- Wraps everything in a layout (currently just an
-
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/. CallsuseRequireAuth()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 />: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-levelQueryClientsingleton. -
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.jsondependenciesalready 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
// 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
// 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
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
// 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:checkclean.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 andinitialize()resolves to'authenticated'before the route gate fires. - Refreshing on
/while unauthenticated redirects to/login. - Browser back/forward buttons work between
/loginand/correctly (no infinite redirect loops). - Devtools render in dev mode (
import.meta.env.DEV === true); they don't render in prod build (pnpm buildthenpnpm preview). - Type-checked navigation:
<Link to="/" />is OK;<Link to="/missing" />fails type-check.
Risks / open questions
- Race between
initialize()andbeforeLoad. IfbeforeLoadruns before the auth store has finishedinitialize(), status is'unknown'and the gate falls through to the component (which shows the loading spinner). Verify the timing — TanStack Router'sdefaultPendingComponentmay be a cleaner place for the loading state. - TanStack Router file-based routes regenerate on save. The
routeTree.gen.tsfile is auto-managed; gitignore it. CI will regenerate duringpnpm build. Document in README. - Search params type-safety. TanStack Router's typed search params require a validation function. For
/login, validateredirectasz.string().optional(). Add as a smallvalidateSearchon the login route. - Devtools bundle size. Lazy-load via dynamic import inside the
import.meta.env.DEVbranch 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 sorouteTree.gen.tsexists when React's transform runs). Path-alias and proxy config from 1.3 untouched..gitignore— addedsrc/routeTree.gen.ts.src/lib/query-client.ts— module-levelQueryClientsingleton 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 viaReact.lazygated onimport.meta.env.DEVso the prod bundle doesn't ship them.src/routes/login.tsx— public login route.validateSearchschema accepts an optionalredirectstring.LoginRoutewraps<LoginPage>(from 1.6) and passesonAuthenticated → navigate({ to: redirect ?? '/', replace: true }).src/routes/_authed/route.tsx— pathless protected layout.beforeLoadreadsuseAuthStore.getState().status; on'anonymous'throwsredirect({ 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 (callsuseAuthStore.getState().logout()then the component's status effect navigates to/login). Card placeholder for the live monitoring map (Phase 2). UsesuseNavigateto handle the cross-tab logout case where the store transitions to'anonymous'mid-session.src/App.tsx— replaced. Now creates the router withcreateRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent' })and renders<RouterProvider router={router} />. The interimApp.tsxfrom 1.6 (which branched on auth status directly) is gone — that branching now lives in the route tree.
Deviations from spec:
-
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 auseEffectthat responds to store transitions. Didn't add a top-leveluseRequireAuth()hook — the per-page effect is just as effective and avoids adding a hook that's only used in one place. -
Added an ESLint override for
src/routes/**to disablereact-refresh/only-export-components. TanStack Router's file-based pattern intentionally co-exportsRoute(the route definition object) alongside the route's component — the rule doesn't apply usefully there. Same shape as the existing override forsrc/ui/primitives/**. -
Removed the
LoginPageauto-navigate effect from 1.6 — the route wrapper (LoginRoute) now owns the navigation viaonAuthenticated, 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.