# 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`.