AI Implementation feature(842): First-Start Create Admin Modal 1.01 (#4)

This commit was merged in pull request #4.
This commit is contained in:
2026-07-21 15:32:51 +00:00
parent 960516a7bc
commit 6feaf08bdb
17 changed files with 834 additions and 217 deletions
+126
View File
@@ -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)