Routing
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.
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 |
The next parameter
RequireAuth encodes the attempted destination so login can return the user to it:
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:
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.
