---
title: Routing
description: All 17 SPA routes plus the catch-all, the two guard components wrapping them, the three-state session machine, and the sanitiser that closes the open-redirect hole.
---

Routes are declared in `src/App.tsx`. The `<BrowserRouter basename="/">` that wraps them lives one level up in `src/main.tsx`; Vite's `base` is `/` to match.

The table below lists **17 paths plus a `*` catch-all** — 18 `<Route>` elements in total. Counts elsewhere in these docs say "17 routes" and mean the 17 real paths.

## The session state machine

`AuthProvider` calls `GET /api/auth/user` on mount and exposes exactly three statuses. Guards branch on that status and nothing else.

| Status | Meaning | `RequireAuth` | `RedirectIfAuthenticated` |
| --- | --- | --- | --- |
| `loading` | the `GET /api/auth/user` call is in flight | full-screen loader | full-screen loader |
| `anonymous` | no session | redirect to `/login?next=…` | render children |
| `authenticated` | session confirmed | render children | redirect to `next` or `/home` |

Rendering a loader in **both** guards while loading is what stops the login page flashing before a valid session resolves.

:::warning[Any failure resolves to `anonymous`, including going offline]
`AuthProvider.reload()` wraps the whole call in one `try`/`catch` and sets `anonymous` on *any* throw — a 401, a malformed body, or a `TypeError` from an unreachable network. There is no `error` status.

The practical consequence: a signed-in user who loses connectivity is bounced to `/login` rather than shown an offline state, and their tokens are still in `localStorage`, so a refresh once they reconnect signs them straight back in. If that matters, `reload()` is the place to add a fourth status.

One shortcut worth knowing: with neither token present it returns `anonymous` without making a request at all.
:::

## Routes

| Route | Guard | Layout | Component |
| --- | --- | --- | --- |
| `/login` | `RedirectIfAuthenticated` | — | `LoginPage` |
| `/register` | `RedirectIfAuthenticated` | — | `SignUpPage` |
| `/forgot-password` | — | — | `ForgotPasswordPage` |
| `/reset-password` | — | — | `ResetPasswordPage` |
| `/verify-email` | — | — | `VerifyEmailPage` |
| `/loading` | — | — | `LoadingPage` — OAuth code exchange |
| `/home` | `RequireAuth` | `AuthenticatedLayout` | `HomePage` |
| `/library` | `RequireAuth` | `AuthenticatedLayout` | `LibraryPage` |
| `/timeline` | `RequireAuth` | `AuthenticatedLayout` | `TimelinePage` |
| `/teams` | `RequireAuth` | `AuthenticatedLayout` | `TeamsPage` |
| `/workspace` | `RequireAuth` | `AuthenticatedLayout` | `WorkspacePage` |
| `/workspace/:workspaceId` | `RequireAuth` | `AuthenticatedLayout` | `WorkspaceDetailPage` |
| `/profile` | `RequireAuth` | `AuthenticatedLayout` | `ProfilePage` |
| `/settings` | `RequireAuth` | `AuthenticatedLayout` | `SettingsPage` |
| `/billing` | `RequireAuth` | `AuthenticatedLayout` | `BillingPage` |
| `/invitations/:token` | `RequireAuth` | — | `InvitationPage` |
| `/profile/:username` | — | — | redirects to `/profile` |
| `*` | `RedirectIfAuthenticated` | — | `LoginPage` |

:::success[Route guards exist]
`CLAUDE.md` says every page renders regardless of auth state and anonymous visitors see a complete-looking app. That was true; it is not now. Every private route is wrapped.
:::

## The `next` parameter

`RequireAuth` encodes the attempted destination so login can return the user to it:

```tsx title="src/components/auth/AuthGuards.tsx"
if (status === "anonymous") {
  const next = encodeURIComponent(location.pathname + location.search);
  return <Navigate to={`/login?next=${next}`} replace />;
}
```

`RedirectIfAuthenticated` reads it back through a sanitiser before trusting it:

```tsx
function sanitizeNextPath(raw: string | null): string | null {
  if (!raw) return null;
  if (!raw.startsWith("/") || raw.startsWith("//")) return null;
  return raw;
}
```

Rejecting `//` matters: `//evil.example.com` is a protocol-relative URL that browsers resolve to a different origin. Without that check, `?next=//evil.example.com` would be an open redirect off the login page.

## `/timeline` means two different things

The SPA route `/timeline` renders a Gantt view of **tasks**, fed by `GET /api/tasks`.

The backend's `/api/timeline/*` routes serve the **HTTP audit log** and have no consumer at all.

They share a word and nothing else. Worth renaming one of them before someone wires the wrong pair together.

## Route-level gaps

`/invitations/:token` is wrapped in `RequireAuth`, so an invited user who is not signed in is bounced to login with the invitation path in `next` — they land back on the invitation after authenticating. That works, but it means an invitee must already have an account; there is no register-then-accept path.

`/settings` renders placeholder copy and calls no endpoints. `/profile` and `/workspace/:workspaceId` are thin wrappers, 10 and 18 lines respectively.
