docs: update documentation to OKF v0.1 format
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: guide
|
||||
title: Session Restoration on Reload
|
||||
description: How the SPA keeps a user signed in across page reloads and how the guards wait for bootstrap + auth hydration before deciding where to route.
|
||||
tags: [guide, auth, session, restoration, refresh, tester]
|
||||
timestamp: 2026-07-21T15:31:00Z
|
||||
---
|
||||
|
||||
# What this feature does
|
||||
|
||||
Before this change, refreshing the tab while signed in logged the user out
|
||||
(because the access JWT was only held in memory). Now the SPA:
|
||||
|
||||
1. Writes the access token + current user to **`sessionStorage`** on every
|
||||
`setSession()` / `clear()`.
|
||||
2. On every app boot, attempts to **silently refresh** the session by
|
||||
`POST /api/v1/auth/refresh` (the `rt` HttpOnly cookie is sent
|
||||
automatically).
|
||||
3. Guards (`authGuard`, `adminGuard`) **wait** until both the bootstrap
|
||||
payload and the auth hydration step finish before deciding where to
|
||||
redirect — so refreshing on a deep link no longer sends the user
|
||||
to `/bootstrap` or `/login` while data is still in flight.
|
||||
|
||||
# Where the user sees it (tester perspective)
|
||||
|
||||
| Scenario | Expected outcome |
|
||||
|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Reload the tab while signed in as an admin | The home shell (`/`) re-renders, the admin nav link is visible, no redirect through `/login` or `/bootstrap`. |
|
||||
| Reload while signed in as a player | The home shell (`/`) re-renders, no admin nav link. |
|
||||
| Reload while on `/admin` as an admin | The admin user list renders without redirecting away. |
|
||||
| Reload while signed in but the refresh cookie expired | The stored session is cleared, the user is redirected to `/login`. |
|
||||
| Reload while on a deep link (e.g. `/admin`) | The app waits for bootstrap + auth hydration, then navigates directly. No flash of `/login` or `/bootstrap`. |
|
||||
| Open `/bootstrap` in the URL once an admin exists | The setup component detects `initialized === true` and redirects away (to `/` when authenticated, otherwise `/login`). |
|
||||
|
||||
# Visual / interactive elements
|
||||
|
||||
There are **no new UI controls**. The existing flows (login, home shell,
|
||||
admin nav) simply stop flickering through `/bootstrap` or `/login` when
|
||||
the session is still valid.
|
||||
|
||||
Key selectors that should still work (regression targets):
|
||||
|
||||
| Element | Selector | Notes |
|
||||
|--------------------|-------------------------------------|----------------------------------------------------|
|
||||
| Admin nav link | `[data-testid="nav-admin"]` | Re-renders after hydration; not visible on `/login` or `/bootstrap`. |
|
||||
| Login form | `[data-testid="login-*"]` | Reachable only when `initialized && !isAuthenticated`. |
|
||||
| Setup modal | `[data-testid="setup-submit"]` etc. | Reachable only when `!initialized`. |
|
||||
|
||||
# How it works (developer map)
|
||||
|
||||
## On every successful auth (`setSession`)
|
||||
|
||||
`AuthService.setSession()` (`frontend/src/app/core/services/auth.service.ts:42`)
|
||||
now also writes `{ token, user }` to `sessionStorage` under the key
|
||||
`hipctf.auth.v1` via `writeStoredSession()` from
|
||||
`frontend/src/app/core/services/auth.session-storage.ts`. `clear()`
|
||||
removes the key via `clearStoredSession()`.
|
||||
|
||||
## On app boot (`AppComponent.ngOnInit`)
|
||||
|
||||
`AppComponent.ngOnInit()` (`frontend/src/app/app.component.ts:13`)
|
||||
performs:
|
||||
|
||||
```ts
|
||||
await this.bootstrap.load();
|
||||
await this.auth.restoreSession();
|
||||
```
|
||||
|
||||
`AuthService.restoreSession()`:
|
||||
|
||||
1. Rehydrates the in-memory signals from `sessionStorage`
|
||||
(`restoreFromStorage()`) — the UI can render immediately.
|
||||
2. `POST /api/v1/auth/refresh` with `{ withCredentials: true }` so the
|
||||
`rt` cookie is sent.
|
||||
3. On success: replaces the stored session with the fresh token + user,
|
||||
flips `hydrated.set(true)`, resolves any `waitUntilHydrated()` callers.
|
||||
4. On failure (network or 401): calls `clear()` (which also wipes
|
||||
`sessionStorage`) and still flips `hydrated.set(true)` so guards can
|
||||
unblock and route the user to `/login`.
|
||||
|
||||
## Guard synchronization (race fix)
|
||||
|
||||
Both `authGuard` (`frontend/src/app/core/guards/auth.guard.ts`) and
|
||||
`adminGuard` (`frontend/src/app/core/guards/admin.guard.ts`) are now
|
||||
`async` and **await**:
|
||||
|
||||
```ts
|
||||
await bootstrap.ready(); // deduplicated in-flight promise
|
||||
await auth.waitUntilHydrated(); // resolves once restoreSession() finishes
|
||||
```
|
||||
|
||||
* `BootstrapService.load()` and `BootstrapService.ready()`
|
||||
(`frontend/src/app/core/services/bootstrap.service.ts`) share a single
|
||||
`loadPromise` so concurrent callers don't double-fetch `/api/v1/bootstrap`.
|
||||
* `AuthService.waitUntilHydrated()` returns a promise that resolves when
|
||||
`hydrated()` flips to `true` (or immediately if already hydrated).
|
||||
|
||||
## Pure decision functions
|
||||
|
||||
* `decideAuthRedirect` (`frontend/src/app/core/guards/auth.guard.decision.ts`)
|
||||
is the pure unit-testable predicate matching the existing
|
||||
`decideAdminGuard` pattern. Inputs: `{ initialized, isAuthenticated }`.
|
||||
Outputs: `true` or a router `UrlTree`.
|
||||
* The `CanActivateFn` `authGuard` now just calls the pure function.
|
||||
|
||||
## Setup component redirect
|
||||
|
||||
`SetupCreateAdminComponent` (`frontend/src/app/features/setup/setup-create-admin.component.ts:36`)
|
||||
runs an Angular `effect()` watching `bootstrap.initialized()`. If a user
|
||||
navigates directly to `/bootstrap` on an already-initialized instance,
|
||||
the effect pushes them to `/` (when authenticated) or `/login`
|
||||
(otherwise).
|
||||
|
||||
# Tests
|
||||
|
||||
| File | What it pins down |
|
||||
|-------------------------------------------------|-----------------------------------------------------------------------------------------|
|
||||
| `tests/frontend/auth-restore.spec.ts` | `auth.session-storage` round-trips; refresh-success writes new token; refresh-failure clears storage. |
|
||||
| `tests/frontend/guard-bootstrap-race.spec.ts` | `decideAuthRedirect` redirects to `/bootstrap` while uninitialized, to `/login` when init'd but unauth'd, and **allows** when both init + auth are true (no `/bootstrap` redirect). |
|
||||
|
||||
# See also
|
||||
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||
- [Admin Shell & User Management](/guides/admin-shell.md)
|
||||
Reference in New Issue
Block a user