26e059fc20
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).
223 lines
10 KiB
Markdown
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.)
|