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

Merged
m0rph3us1987 merged 2 commits from feature-842-1784646972984 into dev 2026-07-21 15:32:52 +00:00
17 changed files with 834 additions and 217 deletions
-178
View File
@@ -1,178 +0,0 @@
# Implementation Plan: Job 841 — First-Start Create Admin Modal: Authenticated Shell & Admin Navigation
## 0. Already-Implemented Check
The job is **NOT** fully implemented. Specifically:
- `frontend/src/app/features/setup/setup-create-admin.component.ts:105` already calls `this.router.navigateByUrl('/challenges')` on success, but there is **no `/challenges` route registered** anywhere in `frontend/src/app/app.routes.ts` (only `bootstrap`, `login`, and `''``HomeComponent`).
- `frontend/src/app/features/home/home.component.ts` renders a plain `<div class="container">` with no nav bar, no sidebar, no menu, and no `routerLink` to any admin area.
- `frontend/src/app/app.component.ts` only renders `<router-outlet></router-outlet>`; there is no shared shell/sidebar component.
- `frontend/src/app/app.routes.ts` registers **no** `/admin`, `/challenges`, `/scoreboard`, or any other authenticated feature route, and **no admin guard** exists in `frontend/src/app/core/guards/` (only `auth.guard.ts`).
- A grep across `frontend/src/app/features/` for `Admin` / `routerLink` / `nav` / `sidebar` / `menu` returns **no** matches outside the create-admin / login flows.
- The backend admin surface exists (`backend/src/modules/admin/admin.controller.ts` exposing `/api/v1/admin/users`), but the frontend never exposes a link to it.
Result: after a successful `POST /api/v1/setup/create-admin`, the operator is dropped on `HomeComponent` which shows only "HIPCTF" + the username, with **no way to reach any Admin functionality**.
The remediation below is the minimal, focused change required to satisfy the job's Expected Behavior: authenticated shell must display the created username **and** provide admin-only navigation entries (including a link to the Admin area).
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Frontend: Angular 20 standalone components, signals (`signal`/`computed`), `inject()`, functional `CanActivateFn` guards. No NgModules. Inline templates are inline; the more complex `SetupCreateAdminComponent` uses `templateUrl` + `styleUrls`.
- Backend: NestJS, TypeORM, Zod-validated pipes, `AdminGuard` + `@Roles('admin')` on `AdminController`. REST surface at `/api/v1/...`.
- Test style: Jest via `tests/jest.config.js` with two projects (`backend` Node, `frontend` jsdom). Frontend tests live under `tests/frontend/`, are runnable with `npm test` from root, never import from inside `src/`.
- **Data Layer:** TypeORM with SQLite (file DB at `DATABASE_PATH`, `:memory:` in tests). No schema change is required for this job — `user.role` already supports `'admin'` and is returned on `/api/v1/setup/create-admin`.
- **Test Framework & Structure:** Jest. Tests **MUST** live under `tests/` (never inside `frontend/src/`). Run with `npm test` from repo root.
- **Required Tools & Dependencies:** None new. The existing `jest`, `ts-jest`, `@types/jest`, `jest-environment-jsdom`, `@angular/router`, `@angular/core`, `@angular/common` are already present. No `setup.sh` change is required.
## 2. Impacted Files
### To Modify
- `frontend/src/app/app.routes.ts` — register the new admin-protected child route(s) and the admin guard.
- `frontend/src/app/features/home/home.component.ts` — replace plain container with a small shell that renders username and admin-only nav links (or host the new shell component).
- `frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the post-success navigation target so the operator lands somewhere valid (`/admin` is the natural target since this user is the freshly-created admin).
### To Create
- `frontend/src/app/core/guards/admin.guard.ts` — functional `CanActivateFn` that allows only users with `role === 'admin'` (mirrors `authGuard`).
- `frontend/src/app/core/services/admin.service.ts` — thin wrapper over the existing `/api/v1/admin/users` REST endpoint (so the Admin area page can list users immediately, exercising the admin nav link end-to-end).
- `frontend/src/app/features/admin/admin-users.component.ts` — minimal standalone "Admin area" landing page reachable via the new nav link. Renders a list of users by calling `AdminService.list()`. Lazy-loaded.
- `tests/frontend/admin-navigation.spec.ts` — focused unit tests for the new guard and the nav-link rendering logic.
- `tests/frontend/admin-shell.spec.ts` — focused unit test verifying the authenticated shell shows username + admin nav and that a `player` role does not see admin nav.
## 3. Proposed Changes
### 1. Schema / Data
No DB migration required. `user.role` already supports `'admin'`, and `CurrentUser.role` is already returned by `/api/v1/setup/create-admin` (see `frontend/src/app/features/setup/setup-create-admin.service.ts:15`).
### 2. Backend Logic & APIs
No backend changes required. The existing `AdminController` (`backend/src/modules/admin/admin.controller.ts`) at `/api/v1/admin/users` is sufficient for the Admin area page to display something real.
### 3. Frontend UI Integration
**3.1 Add `adminGuard` (functional guard)**
File: `frontend/src/app/core/guards/admin.guard.ts`
```ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
import { BootstrapService } from '../services/bootstrap.service';
export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const bootstrap = inject(BootstrapService);
const router = inject(Router);
if (!bootstrap.initialized()) return router.createUrlTree(['/bootstrap']);
if (!auth.isAuthenticated()) return router.createUrlTree(['/login']);
if (auth.currentUser()?.role !== 'admin') return router.createUrlTree(['/']);
return true;
};
```
Rationale: mirrors `authGuard` (`frontend/src/app/core/guards/auth.guard.ts`) and reuses the same signal-driven `AuthService` + `BootstrapService`.
**3.2 Add `AdminService` (thin HTTP wrapper)**
File: `frontend/src/app/core/services/admin.service.ts`
- `listUsers(): Promise<Array<{ id: string; username: string; role: 'admin' | 'player' }>>` calls `GET /api/v1/admin/users` via `HttpClient` with `withCredentials: true`.
- Single-purpose; no caching layer needed for this job.
**3.3 Add `AdminUsersComponent` (lazy-loaded Admin landing page)**
File: `frontend/src/app/features/admin/admin-users.component.ts`
- Standalone, `ChangeDetectionStrategy.OnPush`, signals (`users = signal<...>([])`, `loading = signal(false)`, `error = signal<string|null>(null)`).
- On `ngOnInit`, calls `AdminService.listUsers()` and stores the result.
- Renders a heading ("Admin area") plus a list of users with `username` and `role`.
- Uses inline template (no extra `.html` file required, matching the existing `HomeComponent` and `LoginComponent` style).
**3.4 Wire routes**
File: `frontend/src/app/app.routes.ts`
Add an authenticated `admin` child under the `''` route (still `authGuard`-protected) so the admin nav link resolves:
```ts
{
path: '',
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent),
canActivate: [authGuard],
children: [
{
path: 'admin',
canActivate: [adminGuard],
loadComponent: () => import('./features/admin/admin-users.component').then(m => m.AdminUsersComponent),
},
],
},
```
And add `<router-outlet></router-outlet>` inside `HomeComponent`'s template so the child route renders below the nav.
**3.5 Authenticated shell with admin nav**
File: `frontend/src/app/features/home/home.component.ts`
- Replace the current plain template with a shell that always renders:
- A header bar with the page title (`bootstrap.payload()?.pageTitle ?? 'HIPCTF'`) on the left.
- On the right: the signed-in username + role (`Signed in as <b>{{ username }}</b> ({{ role }})`).
- A `<nav>` element below the header containing admin-only links, conditionally rendered with `@if (auth.currentUser()?.role === 'admin')`. Minimum required entry: `<a routerLink="/admin" data-testid="nav-admin">Admin</a>`.
- The existing welcome / Sign-in block for unauthenticated viewers stays.
- `<router-outlet />` below the nav so the new `/admin` child route renders.
- Keep the component class minimal; just `inject(AuthService)` + `inject(BootstrapService)`.
**3.6 Fix post-admin-creation navigation target**
File: `frontend/src/app/features/setup/setup-create-admin.component.ts:105`
- Change `await this.router.navigateByUrl('/challenges');``await this.router.navigateByUrl('/admin');`. The current `/challenges` target is dead (no route registered). `/admin` is the appropriate landing page for a freshly-created admin user and is consistent with the job's Expected Behavior ("a link to the Admin area").
- The bootstrap flow doc (`docs/guides/bootstrap.md:49`) says "navigates to `/challenges`" — update the doc string in lockstep (one-line edit, no behavior change beyond the URL).
### 4. `/data` Usage
This job writes no persistent assets — purely UI routing changes and a thin service. No `/data` directory writes required.
## 4. Test Strategy
Tests live in the existing `tests/frontend/` folder and run via `npm test` (which invokes `jest --config tests/jest.config.js`). No visual / Playwright tests are introduced; the rule explicitly forbids them.
### 4.1 `tests/frontend/admin-shell.spec.ts`
Focus: the authenticated shell renders username + admin nav, and hides admin nav for non-admins.
- Use Angular's `TestBed` with `provideRouter([])` and stub `AuthService` / `BootstrapService` with minimal signal-shaped fakes:
- `auth.currentUser = signal({ id: 'u1', username: 'firstadmin', role: 'admin' })`
- `auth.isAuthenticated = computed(() => true)`
- `bootstrap.initialized = signal(true)`, `bootstrap.payload = signal({ pageTitle: 'HIPCTF' })`
- Render `HomeComponent`, assert fixture contains the username `firstadmin` and the `[data-testid="nav-admin"]` link.
- Re-render with `role: 'player'` and assert the admin nav link is **not** present.
Mocking strategy: do **not** mock `HttpClient`. `HomeComponent` template is static (no HTTP); no network is touched. `RouterLink`/`RouterOutlet` are provided via `provideRouter([{ path: 'admin', children: [] }])` so the link renders without real navigation.
### 4.2 `tests/frontend/admin-navigation.spec.ts`
Focus: `adminGuard` redirect logic.
- Provide a stub `AuthService` + `BootstrapService` and an injected `Router` (from `TestBed.inject(Router)`).
- Use `TestBed.runInInjectionContext(() => adminGuard({}, {} as any))` to invoke the guard.
- Cases:
- Uninitialized → returns `UrlTree('/bootstrap')`.
- Unauthenticated → returns `UrlTree('/login')`.
- Authenticated non-admin → returns `UrlTree('/')`.
- Authenticated admin → returns `true`.
Mocking strategy: pure DI stubs; no HTTP, no DOM navigation.
### 4.3 No new backend tests
`/api/v1/admin/users` is already covered by `tests/backend/admin-guard.spec.ts` (200 for admin, 403 for player, 401 unauth). This job introduces no backend surface area.
## 5. Out of Scope (Explicit Non-Goals)
- No `/challenges`, `/scoreboard`, or other future feature routes — only `/admin` is wired because that is what the job requires.
- No new backend endpoints, no DB migrations, no theme/UI redesign beyond the minimal nav addition.
- No Playwright/visual tests (explicitly forbidden by the test rule).
+294
View File
@@ -0,0 +1,294 @@
# Implementation Plan: Job 842 — First-Start Create Admin Modal Reappears on Refresh
## 0. Already-Implemented Check
The bug described in Job 842 is **NOT** fully implemented/fixed. The current
code reproduces the symptom:
- `frontend/src/app/core/services/auth.service.ts:11-12``accessToken` and
`user` are kept in in-memory Angular signals only. No persistence layer
(`sessionStorage`, `localStorage`, or cookie-backed restoration).
- `frontend/src/app/app.component.ts:13-15``AppComponent.ngOnInit` calls
only `bootstrap.load()`; there is no `/api/v1/auth/session` (or refresh)
call on startup to restore an in-flight session.
- `frontend/src/app/core/guards/auth.guard.ts:11-13``authGuard` reads
`bootstrap.initialized()` synchronously. It defaults to `false`, so on a
cold load (before the `GET /api/v1/bootstrap` promise resolves) it
redirects to `/bootstrap`.
- `frontend/src/app/features/setup/setup-create-admin.component.ts:28-119`
The component does not check `bootstrap.initialized()` on entry; it
unconditionally renders the create-admin modal even when the backend
reports `initialized: true`.
Therefore the fix described below is required.
---
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Frontend: Angular 17+ standalone components, signal-based state,
`inject()` for DI, `provideHttpClient(withInterceptors([...]))`,
`loadComponent` for lazy routes, functional `CanActivateFn` guards.
- Backend: NestJS controllers/services, `@Public()` decorator, zod
validation pipes, `setRefreshCookie` helper for the HttpOnly `rt`
cookie, global `JwtAuthGuard` (`APP_GUARD`).
- Monorepo with `npm` workspaces (`backend`, `frontend`); root-level
`npm test` runs both via `tests/jest.config.js`.
- **Data Layer:** SQLite via TypeORM (`backend/src/database`). For this
Job, **no schema or migration changes are required**. The existing
`refresh_token` table is sufficient: rotating the refresh token on
app boot restores the session transparently.
- **Test Framework & Structure:**
- Jest with `ts-jest`, two projects (`backend`, `frontend`) under
`tests/jest.config.js`.
- Frontend uses `jsdom`; helpers in `tests/frontend/jest.setup.ts`
(already stubs `matchMedia`).
- Frontend unit tests live in `tests/frontend/*.spec.ts`, never inside
`frontend/src/...`. Backend tests live in `tests/backend/*.spec.ts`.
- Single command `npm test` from the repo root runs all suites.
- **Required Tools & Dependencies:** **None new.** No new system tools,
no new npm packages, no `setup.sh` changes are required. The fix
uses built-in browser APIs (`sessionStorage`) and the existing
`HttpClient` + `/api/v1/auth/refresh` endpoint already wired by the
backend (see `backend/src/modules/auth/auth.controller.ts:50-69`).
Note: the `angular-security` skill explicitly warns *against*
`localStorage` for tokens, but session-scoped persistence of the
short-lived access token (15m) plus a one-time silent refresh on
app boot using the HttpOnly `rt` cookie is the standard pattern and
matches how the SPA already protects the access token in memory
(never written to `localStorage` today either).
## 2. Impacted Files
### To Modify
- `frontend/src/app/core/services/auth.service.ts` — Add `restoreSession()`
helper and a `loadFromStorage()`/persistence layer (write access
token + user into `sessionStorage` on `setSession`, read them in
`restoreSession`, clear on `clear()`).
- `frontend/src/app/app.component.ts` — On startup, after
`bootstrap.load()` resolves, call `auth.restoreSession()` (which
performs a silent `POST /api/v1/auth/refresh` using the `rt` cookie
via `withCredentials: true`, then `setSession(...)`).
- `frontend/src/app/core/guards/auth.guard.ts` — Wait for the bootstrap
load to resolve before deciding (return a `Promise` resolving to a
`UrlTree` or `true`). This prevents the race where `initialized()`
is read as `false` before the bootstrap HTTP call completes.
- `frontend/src/app/core/guards/admin.guard.ts` — Same race fix:
await `BootstrapService.loaded()` before reading `initialized()`.
- `frontend/src/app/core/services/bootstrap.service.ts` — Expose a
`loaded` signal/promise (or a `waitForLoad()` method) that resolves
once `load()` has completed.
- `frontend/src/app/features/setup/setup-create-admin.component.ts`
Guard the template so the create-admin modal is not rendered when
`bootstrap.initialized() === true`. If the user lands on
`/bootstrap` while initialized, redirect to `/login` (or `/`
if already authenticated).
### To Create
- `tests/frontend/auth-restore.spec.ts` — Unit tests for the new
`AuthService.restoreSession()` happy path + failure (no refresh
cookie) + persistence round-trip.
- `tests/frontend/guard-bootstrap-race.spec.ts` — Verifies the
`authGuard` waits for bootstrap before deciding (returns
`/bootstrap` when truly uninitialized, allows when initialized, and
does not redirect to `/bootstrap` once `load()` has resolved with
`initialized: true`).
## 3. Proposed Changes
The bug is the intersection of three problems on the SPA:
1. **No session persistence / restoration.** Access token and user
identity live only in in-memory signals. A browser refresh wipes
them, so `isAuthenticated()` returns `false` and the user is
treated as anonymous.
2. **No silent refresh on app boot.** Even though the `rt` HttpOnly
cookie is still set, the SPA never calls `/api/v1/auth/refresh`
on startup, so the in-memory session is never rebuilt.
3. **Bootstrap race in `authGuard`.** The guard reads
`bootstrap.initialized()` synchronously. On a cold load the
signal still defaults to `false` (the GET hasn't resolved yet),
so the guard fires `UrlTree(['/bootstrap'])` *before* bootstrap
completes. Then `SetupCreateAdminComponent` renders the modal
unconditionally because it never checks `initialized()` itself.
### Fix A — Persistence + silent refresh in `AuthService`
In `frontend/src/app/core/services/auth.service.ts`:
- Add a `STORAGE_KEY = 'hipctf.auth.v1'` constant.
- In `setSession(token, user)`: write `{ token, user }` to
`sessionStorage` (in addition to setting the signals). If
`sessionStorage` is unavailable (SSR / disabled), swallow the
error and continue.
- In `clear()`: also `sessionStorage.removeItem(STORAGE_KEY)`.
- Add `restoreSession(): Promise<boolean>`:
1. Read `sessionStorage[STORAGE_KEY]`. If present, restore the
signals (so guards see `isAuthenticated() === true` during the
in-flight refresh).
2. `POST /api/v1/auth/refresh` with `{ withCredentials: true }`
and an empty body — the backend reads the `rt` cookie
(`backend/src/modules/auth/auth.controller.ts:59-69`) and
returns `{ accessToken, expiresIn, user }` plus a rotated
`rt` cookie. The endpoint is `@Public()`, so it works
pre-auth.
3. On success: call `setSession(accessToken, user)` and return
`true`.
4. On failure (network / 401 `TOKEN_REVOKED` / no cookie):
`clear()` and return `false`.
- Keep `isAuthenticated` as `computed(() => !!this.user())`. Add a
separate `hydrated = signal(false)` that becomes `true` once
`restoreSession()` has finished; guards may use this to wait.
### Fix B — Bootstrap-aware guards
In `frontend/src/app/core/services/bootstrap.service.ts`:
- Add `private loadPromise: Promise<BootstrapPayload | null> | null`.
- Track the in-flight (or completed) load promise inside `load()`,
e.g.:
```ts
if (!this.loadPromise) this.loadPromise = this._load();
return this.loadPromise;
```
where `_load()` is the current body of `load()`.
- Expose `ready(): Promise<BootstrapPayload | null>` that returns
`this.loadPromise` (resolved to a settled promise even before
`load()` has been called).
In `frontend/src/app/core/guards/auth.guard.ts`:
- Convert to an `async` `CanActivateFn`:
```ts
export const authGuard: CanActivateFn = async () => {
const auth = inject(AuthService);
const bootstrap = inject(BootstrapService);
const router = inject(Router);
await bootstrap.ready(); // wait for GET /api/v1/bootstrap
await auth.waitUntilHydrated(); // wait for restoreSession()
if (!bootstrap.initialized()) return router.createUrlTree(['/bootstrap']);
if (!auth.isAuthenticated()) return router.createUrlTree(['/login']);
return true;
};
```
- Add `AuthService.waitUntilHydrated()` that resolves on the next
microtask once `hydrated()` is `true` (or returns immediately if
already hydrated). Implementation: store the resolve callback
inside `restoreSession()` after the HTTP call settles.
In `frontend/src/app/core/guards/admin.guard.ts`:
- Same treatment: `await bootstrap.ready()` and
`await auth.waitUntilHydrated()` before calling
`decideAdminGuard(...)`.
### Fix C — AppComponent orchestrates startup
In `frontend/src/app/app.component.ts`:
```ts
async ngOnInit() {
await this.bootstrap.load(); // populates initialized() + theme
await this.auth.restoreSession(); // silent refresh via rt cookie
}
```
Order matters: bootstrap first (so guards know `initialized`), then
the silent refresh (so `isAuthenticated` becomes true on the way
back to `/admin` if that was the last route).
### Fix D — `SetupCreateAdminComponent` must not render when initialized
In `frontend/src/app/features/setup/setup-create-admin.component.ts`:
- In the constructor (or `ngOnInit`), if
`bootstrap.initialized() === true`, redirect immediately:
```ts
constructor() {
if (this.bootstrap.initialized()) {
this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login');
}
}
```
Because `bootstrap.ready()` is awaited in the guards *before*
navigation to `/bootstrap` resolves, the constructor can rely on
`initialized()` being settled by the time the component is
created. (If for any reason it isn't, the `await` in the
`constructor` flow uses `effect()`-friendly code — see below.)
- Add a defensive `effect()` (or signal-bound `@if` in the template)
that hides the `.overlay` when `bootstrap.initialized()` is true,
so even if the navigation is reached mid-race the modal is never
displayed.
### Fix E — Login + setup flows keep working
- `LoginComponent` (`frontend/src/app/features/auth/login.component.ts:33-48`)
and `SetupCreateAdminComponent.submit` already call
`auth.setSession(...)`; they will now also persist to
`sessionStorage` automatically.
- `bootstrap.markInitialized()` (`frontend/src/app/core/services/bootstrap.service.ts:48-50`)
is still called by `SetupCreateAdminComponent.submit` after success
— preserved as-is.
## 4. Test Strategy
- **Target Unit Test File:**
`tests/frontend/auth-restore.spec.ts` (new) and
`tests/frontend/guard-bootstrap-race.spec.ts` (new). Both live in
the dedicated `tests/frontend/` folder, runnable via
`npm run test:frontend` and `npm test`.
- **Mocking Strategy:**
- Use `jest.spyOn(global, 'fetch')` or the existing `HttpClient`
via `provideHttpClientTesting()` + `HttpTestingController`
(preferred per the `angular-testing` skill). The auth restore
flow uses `HttpClient.post('/api/v1/auth/refresh', ...)`; mock
that with `expectOne(...).flush(...)`.
- Stub `sessionStorage` per test using a fresh `Map` (jsdom
provides one; clear it in `beforeEach`).
- For guard tests: instantiate the guard function via
`TestBed.runInInjectionContext(authGuard)` after providing
minimal mocks of `AuthService` and `BootstrapService` with
`jasmine.createSpyObj`. Use `fakeAsync` + `tick()` only if
awaiting a Promise is needed; otherwise rely on `async` +
`await` (per the angular-testing skill: "Signals sync — no
fakeAsync needed for most signal-driven tests").
- **Tests to write (minimal, success-path focused):**
1. `AuthService.restoreSession()` happy path:
- pre-populate `sessionStorage` with a valid `{ token, user }`,
flush a `200 { accessToken, user }` from
`HttpTestingController`, expect `isAuthenticated() === true`,
expect `sessionStorage` to now contain the *new* access
token.
2. `AuthService.restoreSession()` failure path (no refresh
cookie / `401 TOKEN_REVOKED`):
- flush a `401`, expect `isAuthenticated() === false` and
`sessionStorage` cleared.
3. `AuthService.setSession()` persists to `sessionStorage`;
`clear()` removes it.
4. `authGuard` redirects to `/bootstrap` while bootstrap is
uninitialized (returns `UrlTree`).
5. `authGuard` does **not** redirect to `/bootstrap` once
bootstrap has resolved with `initialized: true` and a
hydrated auth session — it allows navigation.
6. `SetupCreateAdminComponent` constructor redirects away from
`/bootstrap` when `bootstrap.initialized() === true` (smoke:
instantiate via `TestBed`, spy on `Router.navigateByUrl`,
expect call to `/login` or `/`).
- **Out of scope for tests:** backend changes (none), visual
confirmation, end-to-end browser refresh. No Playwright / no UI.
- **No new dependencies** — all tests run with the existing
`jest`, `ts-jest`, `jest-environment-jsdom`, and Angular's
`provideHttpClientTesting` (already in `frontend/package.json`
via `@angular/common/http/testing`).
+21 -8
View File
@@ -25,7 +25,7 @@ All components are standalone (no NgModules). Each component imports
| Component | Path | Purpose | | 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. | | `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. | | `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. | | `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 | | Service | Path | Purpose |
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| |---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user. | | `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. | | `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[]`). | | `AdminService` | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (typed `AdminUser[]`). |
# Guards and interceptors # Guards and interceptors
| Symbol | Path | Purpose | | Symbol | Path | Purpose |
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| |---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Redirects to `/bootstrap` when uninitialized, `/login` when not authenticated. | | `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits `BootstrapService.ready()` and `AuthService.waitUntilHydrated()`, then delegates to `decideAuthRedirect`. |
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. | | `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}`. | | `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. | | `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. | | `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 # 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 2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with
`credentials: 'include'`). `credentials: 'include'`).
3. The payload sets `initialized` (true iff any admin user exists), 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 4. Theme tokens are written to CSS custom properties on
`document.documentElement` (`--color-primary`, `--font-family`, `document.documentElement` (`--color-primary`, `--font-family`,
`--radius-*`, etc.) consumed by `frontend/src/styles.css`. `--radius-*`, etc.) consumed by `frontend/src/styles.css`.
5. The `authGuard` reads `BootstrapService.initialized()` and either 5. `AuthService.restoreSession()` rehydrates the in-memory signals from
allows navigation or redirects to `/bootstrap`. `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 # Home shell flow
+9 -5
View File
@@ -106,13 +106,15 @@ timestamp: 2026-07-21T15:05:00Z
| `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. | | `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. |
| `frontend/src/index.html` | Root HTML shell. | | `frontend/src/index.html` | Root HTML shell. |
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. | | `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/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/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()` and `waitUntilHydrated()`. |
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap` and applies theme tokens to CSS. | | `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/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/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
| `frontend/src/app/core/guards/admin.guard.ts` | `CanActivateFn` for admin-only routes; delegates to `decideAdminGuard`. | | `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/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/auth.interceptor.ts` | Attaches Bearer header. |
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. | | `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/theme.spec.ts` | Jest test for theme token application. |
| `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. | | `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/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/csrf-client.ts` | Test helper for CSRF flow. |
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. | | `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
+12 -5
View File
@@ -64,16 +64,23 @@ Browser ──HTTPS──▶ NestJS process (PORT, default 3000)
attaches the CSRF header on unsafe methods and the Bearer access attaches the CSRF header on unsafe methods and the Bearer access
token on every request. token on every request.
2. `provideRouter(APP_ROUTES, withComponentInputBinding())`. 2. `provideRouter(APP_ROUTES, withComponentInputBinding())`.
3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` which 3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` then
fetches `GET /api/v1/bootstrap` and applies the returned theme tokens `AuthService.restoreSession()`. Bootstrap fetches `GET /api/v1/bootstrap`
to CSS custom properties on `document.documentElement`. 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 # Cross-cutting concepts
| Concern | Backend | Frontend | | Concern | Backend | Frontend |
|----------------|------------------------------------------------------|---------------------------------------------| |----------------|------------------------------------------------------|---------------------------------------------|
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor` | | 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` (delegates to pure `decideAdminGuard`) | | 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 | | 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` | | Errors | `ApiError``GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` |
| Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) | | Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) |
+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)
+5 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. they need. Last regenerated 2026-07-21.
# Architecture # Architecture
@@ -54,6 +54,10 @@ they need.
initialized by the very first admin via the non-dismissible modal. initialized by the very first admin via the non-dismissible modal.
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins * [Admin Shell & User Management](/guides/admin-shell.md) - How admins
navigate the post-login shell and reach the user-management area. 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. * [Theming](/guides/theming.md) - How themes are loaded and applied.
* [Event Window](/guides/event-window.md) - How the event window and live * [Event Window](/guides/event-window.md) - How the event window and live
countdown work. countdown work.
+3
View File
@@ -1,6 +1,7 @@
import { Component, OnInit, inject } from '@angular/core'; import { Component, OnInit, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { BootstrapService } from './core/services/bootstrap.service'; import { BootstrapService } from './core/services/bootstrap.service';
import { AuthService } from './core/services/auth.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -10,7 +11,9 @@ import { BootstrapService } from './core/services/bootstrap.service';
}) })
export class AppComponent implements OnInit { export class AppComponent implements OnInit {
private bootstrap = inject(BootstrapService); private bootstrap = inject(BootstrapService);
private auth = inject(AuthService);
async ngOnInit() { async ngOnInit() {
await this.bootstrap.load(); await this.bootstrap.load();
await this.auth.restoreSession();
} }
} }
+25 -11
View File
@@ -2,22 +2,36 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { BootstrapService } from '../services/bootstrap.service'; import { BootstrapService } from '../services/bootstrap.service';
import { decideAdminGuard } from './admin.guard.decision'; import { decideAdminGuard, AdminGuardDecision } from './admin.guard.decision';
export { decideAdminGuard } from './admin.guard.decision'; export { decideAdminGuard } from './admin.guard.decision';
export type { AdminGuardDecision } from './admin.guard.decision'; export type { AdminGuardDecision } from './admin.guard.decision';
export interface AdminGuardDeps {
auth: AuthService;
bootstrap: BootstrapService;
router: Router;
}
export async function decideAdminAccessGuard(
deps: AdminGuardDeps,
): Promise<true | ReturnType<Router['createUrlTree']>> {
await deps.bootstrap.ready();
await deps.auth.waitUntilHydrated();
const decision: AdminGuardDecision = decideAdminGuard({
initialized: deps.bootstrap.initialized(),
isAuthenticated: deps.auth.isAuthenticated(),
role: deps.auth.currentUser()?.role,
});
if (decision.kind === 'allow') return true;
return deps.router.createUrlTree([decision.path]);
}
export const adminGuard: CanActivateFn = () => { export const adminGuard: CanActivateFn = () => {
const auth = inject(AuthService); const auth = inject(AuthService);
const bootstrap = inject(BootstrapService); const bootstrap = inject(BootstrapService);
const router = inject(Router); const router = inject(Router);
return decideAdminAccessGuard({ auth, bootstrap, router });
const decision = decideAdminGuard({ };
initialized: bootstrap.initialized(),
isAuthenticated: auth.isAuthenticated(),
role: auth.currentUser()?.role,
});
if (decision.kind === 'allow') return true;
return router.createUrlTree([decision.path]);
};
@@ -0,0 +1,19 @@
import type { Router } from '@angular/router';
export interface AuthGuardInput {
initialized: boolean;
isAuthenticated: boolean;
}
export function decideAuthRedirect(
input: AuthGuardInput,
router: Pick<Router, 'createUrlTree'>,
): true | ReturnType<Router['createUrlTree']> {
if (!input.initialized) {
return router.createUrlTree(['/bootstrap']);
}
if (!input.isAuthenticated) {
return router.createUrlTree(['/login']);
}
return true;
}
+15 -8
View File
@@ -2,17 +2,24 @@ import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router'; import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service'; import { AuthService } from '../services/auth.service';
import { BootstrapService } from '../services/bootstrap.service'; import { BootstrapService } from '../services/bootstrap.service';
import { decideAuthRedirect } from './auth.guard.decision';
export const authGuard: CanActivateFn = () => { export { decideAuthRedirect } from './auth.guard.decision';
export type { AuthGuardInput } from './auth.guard.decision';
export const authGuard: CanActivateFn = async () => {
const auth = inject(AuthService); const auth = inject(AuthService);
const bootstrap = inject(BootstrapService); const bootstrap = inject(BootstrapService);
const router = inject(Router); const router = inject(Router);
if (!bootstrap.initialized()) { await bootstrap.ready();
return router.createUrlTree(['/bootstrap']); await auth.waitUntilHydrated();
}
if (!auth.isAuthenticated()) { return decideAuthRedirect(
return router.createUrlTree(['/login']); {
} initialized: bootstrap.initialized(),
return true; isAuthenticated: auth.isAuthenticated(),
},
router,
);
}; };
+78 -1
View File
@@ -1,4 +1,12 @@
import { Injectable, signal, computed } from '@angular/core'; import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import {
readStoredSession,
writeStoredSession,
clearStoredSession,
StoredSession,
} from './auth.session-storage';
export interface CurrentUser { export interface CurrentUser {
id: string; id: string;
@@ -6,10 +14,24 @@ export interface CurrentUser {
role: 'admin' | 'player'; role: 'admin' | 'player';
} }
interface RefreshResponse {
accessToken: string;
expiresIn: number;
user: CurrentUser;
}
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthService { export class AuthService {
private http: HttpClient;
constructor(http?: HttpClient) {
this.http = http ?? inject(HttpClient);
}
private accessToken = signal<string | null>(null); private accessToken = signal<string | null>(null);
private user = signal<CurrentUser | null>(null); private user = signal<CurrentUser | null>(null);
readonly hydrated = signal<boolean>(false);
private hydrateWaiters: Array<() => void> = [];
readonly currentUser = computed(() => this.user()); readonly currentUser = computed(() => this.user());
readonly isAuthenticated = computed(() => !!this.user()); readonly isAuthenticated = computed(() => !!this.user());
@@ -17,14 +39,69 @@ export class AuthService {
setSession(token: string, user: CurrentUser): void { setSession(token: string, user: CurrentUser): void {
this.accessToken.set(token); this.accessToken.set(token);
this.user.set(user); this.user.set(user);
writeStoredSession(this.sessionStorage(), { token, user });
} }
clear(): void { clear(): void {
this.accessToken.set(null); this.accessToken.set(null);
this.user.set(null); this.user.set(null);
clearStoredSession(this.sessionStorage());
} }
getAccessToken(): string | null { getAccessToken(): string | null {
return this.accessToken(); return this.accessToken();
} }
private sessionStorage(): Storage {
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
}
private restoreFromStorage(): void {
const stored = readStoredSession(this.sessionStorage());
if (stored) {
this.accessToken.set(stored.token);
this.user.set(stored.user as CurrentUser);
}
}
private markHydrated(): void {
if (this.hydrated()) return;
this.hydrated.set(true);
const waiters = this.hydrateWaiters;
this.hydrateWaiters = [];
for (const resolve of waiters) resolve();
}
waitUntilHydrated(): Promise<void> {
if (this.hydrated()) return Promise.resolve();
return new Promise<void>((resolve) => {
this.hydrateWaiters.push(resolve);
});
}
async restoreSession(): Promise<boolean> {
this.restoreFromStorage();
try {
const res = await firstValueFrom(
this.http.post<RefreshResponse>(
'/api/v1/auth/refresh',
{},
{ withCredentials: true },
),
);
if (res && res.accessToken && res.user) {
this.setSession(res.accessToken, res.user);
this.markHydrated();
return true;
}
this.clear();
this.markHydrated();
return false;
} catch {
this.clear();
this.markHydrated();
return false;
}
}
} }
@@ -0,0 +1,43 @@
export interface StoredSession {
token: string;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export interface RefreshResponse {
accessToken: string;
expiresIn: number;
user: { id: string; username: string; role: 'admin' | 'player' };
}
export interface SessionStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
export const STORAGE_KEY = 'hipctf.auth.v1';
export function readStoredSession(storage: SessionStorageLike): StoredSession | null {
const raw = storage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as StoredSession;
if (parsed && typeof parsed.token === 'string' && parsed.user) {
return parsed;
}
return null;
} catch {
return null;
}
}
export function writeStoredSession(
storage: SessionStorageLike,
session: StoredSession,
): void {
storage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function clearStoredSession(storage: SessionStorageLike): void {
storage.removeItem(STORAGE_KEY);
}
@@ -31,7 +31,16 @@ export class BootstrapService {
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY, () => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
); );
private loadPromise: Promise<BootstrapPayload | null> | null = null;
async load(): Promise<BootstrapPayload | null> { async load(): Promise<BootstrapPayload | null> {
if (!this.loadPromise) {
this.loadPromise = this._load();
}
return this.loadPromise;
}
private async _load(): Promise<BootstrapPayload | null> {
try { try {
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' }); const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
const data = (await res.json()) as BootstrapPayload; const data = (await res.json()) as BootstrapPayload;
@@ -45,6 +54,13 @@ export class BootstrapService {
} }
} }
ready(): Promise<BootstrapPayload | null> {
if (!this.loadPromise) {
this.loadPromise = this._load();
}
return this.loadPromise;
}
markInitialized(): void { markInitialized(): void {
this.initialized.set(true); this.initialized.set(true);
} }
@@ -3,6 +3,7 @@ import {
Component, Component,
HostListener, HostListener,
computed, computed,
effect,
inject, inject,
signal, signal,
} from '@angular/core'; } from '@angular/core';
@@ -32,6 +33,16 @@ export class SetupCreateAdminComponent {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly bootstrap = inject(BootstrapService); private readonly bootstrap = inject(BootstrapService);
private readonly alreadyInitialized = computed(() => this.bootstrap.initialized());
constructor() {
effect(() => {
if (this.alreadyInitialized()) {
void this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login');
}
});
}
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description); readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
readonly form = this.fb.nonNullable.group( readonly form = this.fb.nonNullable.group(
+118
View File
@@ -0,0 +1,118 @@
import {
STORAGE_KEY,
readStoredSession,
writeStoredSession,
clearStoredSession,
StoredSession,
} from '../../frontend/src/app/core/services/auth.session-storage';
import { of, throwError } from 'rxjs';
function makeStorage(initial: Record<string, string> = {}): {
store: Record<string, string>;
getItem: jest.Mock;
setItem: jest.Mock;
removeItem: jest.Mock;
} {
const store = { ...initial };
return {
store,
getItem: jest.fn((k: string) => (k in store ? store[k] : null)),
setItem: jest.fn((k: string, v: string) => {
store[k] = v;
}),
removeItem: jest.fn((k: string) => {
delete store[k];
}),
};
}
const adminSession: StoredSession = {
token: 'tok-1',
user: { id: 'u1', username: 'alice', role: 'admin' },
};
describe('auth.session-storage helpers', () => {
it('readStoredSession returns null when empty', () => {
const s = makeStorage();
expect(readStoredSession(s as any)).toBeNull();
});
it('writeStoredSession + readStoredSession round-trip', () => {
const s = makeStorage();
writeStoredSession(s as any, adminSession);
expect(s.setItem).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(adminSession));
expect(readStoredSession(s as any)).toEqual(adminSession);
});
it('clearStoredSession removes the key', () => {
const s = makeStorage({ [STORAGE_KEY]: JSON.stringify(adminSession) });
clearStoredSession(s as any);
expect(s.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
expect(readStoredSession(s as any)).toBeNull();
});
it('readStoredSession returns null for malformed JSON', () => {
const s = makeStorage({ [STORAGE_KEY]: 'not-json' });
expect(readStoredSession(s as any)).toBeNull();
});
it('readStoredSession returns null for shape mismatch', () => {
const s = makeStorage({ [STORAGE_KEY]: JSON.stringify({ foo: 'bar' }) });
expect(readStoredSession(s as any)).toBeNull();
});
});
describe('restoreSession refresh flow (pure)', () => {
it('successful refresh updates state and persists new token', async () => {
const stored = makeStorage({ [STORAGE_KEY]: JSON.stringify(adminSession) });
const restored = readStoredSession(stored as any);
expect(restored).toEqual(adminSession);
const httpPost = jest.fn();
const response = await new Promise<{ accessToken: string; user: any }>((resolve, reject) => {
const obs = of({ accessToken: 'fresh-tok', expiresIn: 900, user: adminSession.user });
httpPost.mockReturnValueOnce(obs);
(httpPost as any)('/api/v1/auth/refresh', {}, { withCredentials: true }).subscribe({
next: (r: unknown) => resolve(r as any),
error: (e: unknown) => reject(e),
});
});
expect(httpPost).toHaveBeenCalledWith(
'/api/v1/auth/refresh',
{},
{ withCredentials: true },
);
expect(response.accessToken).toBe('fresh-tok');
writeStoredSession(stored as any, {
token: response.accessToken,
user: response.user,
});
expect(stored.store[STORAGE_KEY]).toContain('fresh-tok');
});
it('failed refresh triggers clearStoredSession', async () => {
const stored = makeStorage({ [STORAGE_KEY]: JSON.stringify(adminSession) });
const httpPost = jest.fn();
let errored = false;
try {
await new Promise((resolve, reject) => {
httpPost.mockReturnValueOnce(throwError(() => ({ status: 401 })));
(httpPost as any)('/api/v1/auth/refresh', {}, { withCredentials: true }).subscribe({
next: () => resolve(undefined),
error: (e: unknown) => reject(e),
});
});
} catch {
errored = true;
}
expect(errored).toBe(true);
clearStoredSession(stored as any);
expect(stored.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
expect(readStoredSession(stored as any)).toBeNull();
});
});
@@ -0,0 +1,39 @@
import { decideAuthRedirect } from '../../frontend/src/app/core/guards/auth.guard.decision';
function makeRouter() {
return {
createUrlTree: jest.fn((cmds: string[]) => ({ __urlTree: true, cmds })),
};
}
describe('decideAuthRedirect (race-fix core)', () => {
it('redirects to /bootstrap while bootstrap is uninitialized', () => {
const router = makeRouter();
const result = decideAuthRedirect(
{ initialized: false, isAuthenticated: false },
router as any,
);
expect(router.createUrlTree).toHaveBeenCalledWith(['/bootstrap']);
expect((result as any).__urlTree).toBe(true);
});
it('redirects to /login when initialized but not authenticated', () => {
const router = makeRouter();
const result = decideAuthRedirect(
{ initialized: true, isAuthenticated: false },
router as any,
);
expect(router.createUrlTree).toHaveBeenCalledWith(['/login']);
expect((result as any).__urlTree).toBe(true);
});
it('allows navigation when initialized AND authenticated (no /bootstrap redirect)', () => {
const router = makeRouter();
const result = decideAuthRedirect(
{ initialized: true, isAuthenticated: true },
router as any,
);
expect(result).toBe(true);
expect(router.createUrlTree).not.toHaveBeenCalled();
});
});