Replace the broken `GET /api/admin/auth/refresh` (no `credentials:'include'`) mount-time bootstrap with `POST /api/admin/auth/refresh` (with credentials) chained to `GET /api/admin/users/me`. Returning users with a valid HttpOnly refresh cookie no longer flash through `/login`. Closes Finding B3 / Vision P3. - Add module-scoped `bootstrapInflight` guard (StrictMode double-mount safety) + test-only reset hook exported via the `src/auth` barrel; `tests/setup.ts` resets it in `afterEach` to prevent pending-promise leakage between tests. - Defensive `hasPermission` against legacy `/users/me` payloads omitting `permissions`; default MSW handler now seeds `permissions` explicitly. - Add `endpoints.admin.usersMe()` builder (STC-ARCH-02 forbids the literal). - Bulk-swap 15 test files from `http.get` -> `http.post` for the refresh override so intentional bootstrap-fail tests still fail correctly. - Update auth component description; mark B3 closed. - Code review verdict PASS; static + fast suites green (231 / 13 skipped). Batch report: _docs/03_implementation/batch_13_cycle3_report.md Co-authored-by: Cursor <cursoragent@cursor.com>
4.9 KiB
02 — Auth
1. High-Level Overview
Purpose: Authentication state, login/logout/refresh plumbing, and the <ProtectedRoute> gate that wraps every non-public route.
Architectural Pattern: React Context provider + route-guard component.
Upstream dependencies: 00_foundation (types), 01_api-transport.
Downstream consumers: 04_login, 10_app-shell, every authenticated page (indirectly via the route gate).
2. Internal Interfaces
src/auth/AuthContext.tsx
| Export | Signature | Notes |
|---|---|---|
AuthProvider({ children }) |
React component | Wraps the app below BrowserRouter. Bootstraps via POST /api/admin/auth/refresh (with credentials: 'include') chained with GET /api/admin/users/me on mount — same wire shape as the 401-retry path in api/client.ts. |
useAuth(): AuthContextValue |
hook | Read-only access to { user, permissions, login, logout, refresh, loading }. |
AuthContextValue (output DTO):
user: User | null
permissions: Permission[] ← from server, used by route guards & UI
loading: boolean ← true during initial bootstrap and active refresh
login(c): Promise<User> ← POST /api/admin/auth/login
logout(): Promise<void> ← POST /api/admin/auth/logout
refresh(): Promise<void> ← POST /api/admin/auth/refresh
src/auth/ProtectedRoute.tsx
| Export | Signature | Notes |
|---|---|---|
ProtectedRoute({ children, requirePermission? }) |
React component | Renders children if authenticated; otherwise navigates to /login. Optional requirePermission is checked against useAuth().permissions and renders a 403 placeholder on miss. |
3. External API Specification
Consumes only — does not expose. Endpoint set (from _docs/02_document/modules/src__auth__AuthContext.md):
| Method | Path | Auth | When |
|---|---|---|---|
| POST | /api/admin/auth/login |
public | Login form submit |
| POST | /api/admin/auth/refresh |
cookie | Bootstrap + on 401 retry |
| POST | /api/admin/auth/logout |
cookie | Header → Logout |
| GET | /api/admin/auth/me |
cookie | (post-login profile fetch, if implemented) |
5. Implementation Details
State Management: Single React context. Token lives in an HTTP-only cookie (server-managed); the React state holds only the parsed user + permissions. No localStorage.
Bootstrap sequence (consolidated by AZ-510):
- Mount → set
loading: true. fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })to ask the server "do I have a valid session?". Directfetch(notapi.post) becauseapi.postdoes not threadcredentials: 'include'and widening it would change CORS posture for every authed callsite.- On 200 →
setToken(data.token), thenapi.get(endpoints.admin.usersMe())to fetch the user shape (the POST refresh response is{ token }only — no user payload). On/users/me200 →setUser(authUser),loading: false. On/users/mefailure →setToken(null),setUser(null),loading: false,console.errorcarries the diagnostic (refresh OK / user GET failed). - On refresh 4xx or network failure →
setUser(null),loading: false.ProtectedRoutethen redirects to/login. - StrictMode: a module-scoped in-flight promise deduplicates the bootstrap network round-trip across React 18+ StrictMode double-mounts so the backend cookie rotation does not race itself.
Bootstrap and the 401-retry path in api/client.ts:88 now share a single wire shape — POST /api/admin/auth/refresh with credentials. Finding B3 (bootstrap missing credentials: 'include') is closed.
Spinner UX: ProtectedRoute renders a centered spinner during loading. The spinner has no role="status" / no accessible label / no timeout. (Findings B4, joint with Step 4 client.ts timeout flag.)
7. Caveats & Edge Cases
Bootstrap missing— closed by AZ-510. Bootstrap now uses POST refresh + chainedcredentials: 'include'/users/mewith credentials, matching the 401-retry path.- Spinner accessibility — Step 4.
- Token-rotation interaction with SSE — see
01_api-transport. Auth refresh works for fetch but breaks every active EventSource. - No idle-timeout / inactivity logout — server-side concern; UI tolerates whatever the server enforces.
8. Dependency Graph
Must be implemented after: 00_foundation, 01_api-transport.
Can be implemented in parallel with: nothing inside this workspace's gate path.
Blocks: 03_shared-ui (Header reads useAuth), 04_login, 10_app-shell, indirectly every authenticated page.
Module Inventory
| Path | Module Doc |
|---|---|
src/auth/AuthContext.tsx |
_docs/02_document/modules/src__auth__AuthContext.md |
src/auth/ProtectedRoute.tsx |
_docs/02_document/modules/src__auth__ProtectedRoute.md |