dc4e73f73a
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.
246 lines
14 KiB
Markdown
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`.
|