# Module: `src/auth/ProtectedRoute.tsx` > **Source**: `src/auth/ProtectedRoute.tsx` (19 lines) > **Topo batch**: B4 (depends on B3: `auth/AuthContext`) ## Purpose A tiny route guard that gates its children behind an authenticated session. While `AuthContext` is bootstrapping (`loading === true`) it shows a spinner; when the bootstrap finishes with no user it redirects to `/login`; otherwise it renders its children. Mounted exactly once in `App.tsx` between `AuthProvider` and `FlightProvider` so every authenticated page benefits from it. WPF parallel: the implicit "no LoginWindow open ⇒ MainWindow" gate (`_docs/legacy/wpf-era.md` §4). ## Public interface ```ts interface Props { children: ReactNode } export default function ProtectedRoute(props: Props): JSX.Element ``` Always returns a `JSX.Element` — never `null`. The three rendered shapes are: 1. Spinner (centered `
` with the orange ring) while `loading`. 2. `` when `loading === false && user == null`. 3. `<>{children}` otherwise. `replace` is intentional: it rewrites the history entry so the back button does not return to a protected route the user was bounced off. ## Internal logic - Single hook call: `const { user, loading } = useAuth()`. No local state. - Branch order matters — `loading` is checked before `user` so a freshly reloaded tab never renders the login redirect during the in-flight refresh attempt. (See the open `AuthContext` bootstrap-vs-refresh divergence flagged in `src__auth__AuthContext.md`; if that bug is fixed the spinner duration becomes accurate.) ## Dependencies - **Internal**: `./AuthContext` — `useAuth`. - **External**: `react-router-dom` (`Navigate`), `react` (`ReactNode` type only). ## Consumers (intra-repo) From the §7a dependency graph: - `src/App.tsx` — wraps the entire authenticated route tree: `AuthProvider → ProtectedRoute → FlightProvider → Header + nested Routes`. Not used anywhere else; the SPA has a single protected zone. ## Data models None. ## Configuration The `/login` redirect target is hardcoded. Matches the public route declared in `App.tsx`. If the public route is ever renamed, update both sites. The spinner uses Tailwind tokens `bg-az-bg`, `border-az-orange` (defined in `src/index.css`). ## External integrations None directly. Indirectly relies on whatever `AuthContext` calls during bootstrap — currently `GET /api/admin/auth/refresh`. ## Security - **No token check is duplicated here** — relies entirely on `AuthContext`. The component cannot be confused into rendering protected children before the bootstrap resolves because `loading` defaults to `true` in `AuthProvider`. - Backend authority is unchanged — this is a UI affordance only. Every request the children make MUST also be enforced server-side. - The redirect uses `Navigate`, so query params on the original URL are lost. Acceptable today (no protected route relies on them); flag if a future "deep-link after login" UX appears. ## Tests None. ## Notes / open questions - The spinner has no `role="status"` or accessible label — screen readers hear nothing while the bootstrap runs. Cosmetic; flag for Step 4 verification against `_docs/ui_design/README.md` accessibility notes. - No timeout on the `loading` state — if `AuthContext`'s bootstrap somehow never resolves (e.g., the refresh endpoint hangs), the user sees an infinite spinner. `client.ts` does not currently set a request timeout either; flag jointly with Step 4. - The `<>{children}` Fragment wrap is intentional: returning `children` directly would make the type `ReactNode` rather than `JSX.Element` and would not satisfy the route element slot in `react-router-dom@7`.