AI Implementation feature(842): First-Start Create Admin Modal 1.01 #4
@@ -25,7 +25,7 @@ All components are standalone (no NgModules). Each component imports
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
|
||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. |
|
||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
||||
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
||||
@@ -35,16 +35,18 @@ All components are standalone (no NgModules). Each component imports
|
||||
|
||||
| Service | Path | Purpose |
|
||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user. |
|
||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS. |
|
||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage` and exposes `restoreSession()` + `waitUntilHydrated()`. |
|
||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
||||
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
||||
| `AdminService` | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (typed `AdminUser[]`). |
|
||||
|
||||
# Guards and interceptors
|
||||
|
||||
| Symbol | Path | Purpose |
|
||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Redirects to `/bootstrap` when uninitialized, `/login` when not authenticated. |
|
||||
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
||||
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits `BootstrapService.ready()` and `AuthService.waitUntilHydrated()`, then delegates to `decideAuthRedirect`. |
|
||||
| `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
|
||||
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
||||
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
||||
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
||||
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
||||
@@ -55,7 +57,10 @@ Both interceptors are wired in `frontend/src/main.ts` via
|
||||
|
||||
# Bootstrap flow
|
||||
|
||||
1. `AppComponent.ngOnInit()` → `BootstrapService.load()`.
|
||||
1. `AppComponent.ngOnInit()` → `BootstrapService.load()` then
|
||||
`AuthService.restoreSession()`. Both are deduplicated so concurrent
|
||||
callers share a single in-flight promise (`loadPromise` /
|
||||
`hydrateWaiters`).
|
||||
2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with
|
||||
`credentials: 'include'`).
|
||||
3. The payload sets `initialized` (true iff any admin user exists),
|
||||
@@ -64,8 +69,16 @@ Both interceptors are wired in `frontend/src/main.ts` via
|
||||
4. Theme tokens are written to CSS custom properties on
|
||||
`document.documentElement` (`--color-primary`, `--font-family`,
|
||||
`--radius-*`, etc.) consumed by `frontend/src/styles.css`.
|
||||
5. The `authGuard` reads `BootstrapService.initialized()` and either
|
||||
allows navigation or redirects to `/bootstrap`.
|
||||
5. `AuthService.restoreSession()` rehydrates the in-memory signals from
|
||||
`sessionStorage` (key `hipctf.auth.v1`) and POSTs
|
||||
`/api/v1/auth/refresh` with `withCredentials: true` to mint a fresh
|
||||
access token. On success it persists the new token; on failure it
|
||||
clears storage. Either way it flips `hydrated()` so the guards can
|
||||
unblock.
|
||||
6. The `authGuard` (and `adminGuard`) `await` `BootstrapService.ready()`
|
||||
and `AuthService.waitUntilHydrated()` before reading `initialized()`
|
||||
/ `isAuthenticated()`, which prevents a flash of `/bootstrap` or
|
||||
`/login` on a hard refresh of a deep link.
|
||||
|
||||
# Home shell flow
|
||||
|
||||
|
||||
@@ -106,13 +106,15 @@ timestamp: 2026-07-21T15:05:00Z
|
||||
| `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. |
|
||||
| `frontend/src/index.html` | Root HTML shell. |
|
||||
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
||||
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap on init. |
|
||||
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. |
|
||||
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
|
||||
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user. |
|
||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap` and applies theme tokens to CSS. |
|
||||
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()` and `waitUntilHydrated()`. |
|
||||
| `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. |
|
||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; `load()` / `ready()` deduplicate the in-flight fetch. |
|
||||
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
|
||||
| `frontend/src/app/core/guards/auth.guard.ts` | `CanActivateFn` checking init + auth. |
|
||||
| `frontend/src/app/core/guards/admin.guard.ts` | `CanActivateFn` for admin-only routes; delegates to `decideAdminGuard`. |
|
||||
| `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
|
||||
| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function for the auth guard (returns `true` or a redirect `UrlTree`). |
|
||||
| `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn` for admin-only routes; awaits bootstrap + auth hydration, delegates to `decideAdminGuard`. |
|
||||
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
|
||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
||||
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
|
||||
@@ -132,6 +134,8 @@ timestamp: 2026-07-21T15:05:00Z
|
||||
| `tests/frontend/theme.spec.ts` | Jest test for theme token application. |
|
||||
| `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. |
|
||||
| `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. |
|
||||
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
|
||||
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
|
||||
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
||||
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
||||
|
||||
|
||||
@@ -64,16 +64,23 @@ Browser ──HTTPS──▶ NestJS process (PORT, default 3000)
|
||||
attaches the CSRF header on unsafe methods and the Bearer access
|
||||
token on every request.
|
||||
2. `provideRouter(APP_ROUTES, withComponentInputBinding())`.
|
||||
3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` which
|
||||
fetches `GET /api/v1/bootstrap` and applies the returned theme tokens
|
||||
to CSS custom properties on `document.documentElement`.
|
||||
3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` then
|
||||
`AuthService.restoreSession()`. Bootstrap fetches `GET /api/v1/bootstrap`
|
||||
and applies the returned theme tokens to CSS custom properties on
|
||||
`document.documentElement`. Session restore rehydrates the in-memory
|
||||
auth signals from `sessionStorage` and posts `POST /api/v1/auth/refresh`
|
||||
with `withCredentials: true` so a hard refresh does not log the user out.
|
||||
4. Guards (`authGuard`, `adminGuard`) are async and `await`
|
||||
`BootstrapService.ready()` + `AuthService.waitUntilHydrated()` before
|
||||
reading state, so refreshing on a deep link never flashes `/bootstrap`
|
||||
or `/login`.
|
||||
|
||||
# Cross-cutting concepts
|
||||
|
||||
| Concern | Backend | Frontend |
|
||||
|----------------|------------------------------------------------------|---------------------------------------------|
|
||||
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor` |
|
||||
| Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` for `/`, `adminGuard` for `/admin` (delegates to pure `decideAdminGuard`) |
|
||||
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor`; session is mirrored to `sessionStorage` and rehydrated on boot via `/api/v1/auth/refresh` |
|
||||
| Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` for `/`, `adminGuard` for `/admin` (both async, both delegate to pure decision functions: `decideAuthRedirect` / `decideAdminGuard`) |
|
||||
| CSRF | `CsrfMiddleware` (skips `/api/v1/auth/login` and `/api/v1/auth/register-first-admin`) | `csrfInterceptor` reads `csrf` cookie |
|
||||
| Errors | `ApiError` → `GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` |
|
||||
| Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) |
|
||||
|
||||
@@ -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)
|
||||
+5
-1
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
||||
controls.
|
||||
|
||||
The docs below are organized by purpose so agents can pull just the slice
|
||||
they need.
|
||||
they need. Last regenerated 2026-07-21.
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -54,6 +54,10 @@ they need.
|
||||
initialized by the very first admin via the non-dismissible modal.
|
||||
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins
|
||||
navigate the post-login shell and reach the user-management area.
|
||||
* [Session Restoration on Reload](/guides/session-restoration.md) - How
|
||||
the SPA keeps a user signed in across hard refreshes and how the
|
||||
guards wait for bootstrap + auth hydration before deciding where to
|
||||
route.
|
||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||
countdown work.
|
||||
|
||||
Reference in New Issue
Block a user