Files
spa/.planning/phase-1-foundation/07-routing-skeleton.md
T

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.ts updated 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 /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 />:

    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

// 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: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.tsTanStackRouterVite({ 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.tsxcreateRootRouteWithContext<{ 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 f4a5e5b.