7.0 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||||
|---|---|---|---|---|---|---|---|---|---|---|
| guide | Session Restoration on Reload | 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. |
|
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:
- Writes the access token + current user to
sessionStorageon everysetSession()/clear(). - On every app boot, attempts to silently refresh the session by
POST /api/v1/auth/refresh(thertHttpOnly cookie is sent automatically). - 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/bootstrapor/loginwhile 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:
await this.bootstrap.load();
await this.auth.restoreSession();
AuthService.restoreSession():
- Rehydrates the in-memory signals from
sessionStorage(restoreFromStorage()) — the UI can render immediately. POST /api/v1/auth/refreshwith{ withCredentials: true }so thertcookie is sent.- On success: replaces the stored session with the fresh token + user,
flips
hydrated.set(true), resolves anywaitUntilHydrated()callers. - On failure (network or 401): calls
clear()(which also wipessessionStorage) and still flipshydrated.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:
await bootstrap.ready(); // deduplicated in-flight promise
await auth.waitUntilHydrated(); // resolves once restoreSession() finishes
BootstrapService.load()andBootstrapService.ready()(frontend/src/app/core/services/bootstrap.service.ts) share a singleloadPromiseso concurrent callers don't double-fetch/api/v1/bootstrap.AuthService.waitUntilHydrated()returns a promise that resolves whenhydrated()flips totrue(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 existingdecideAdminGuardpattern. Inputs:{ initialized, isAuthenticated }. Outputs:trueor a routerUrlTree.- The
CanActivateFnauthGuardnow 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). |