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:
@@ -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).
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { BootstrapService } from './core/services/bootstrap.service';
|
||||
import { AuthService } from './core/services/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -10,7 +11,9 @@ import { BootstrapService } from './core/services/bootstrap.service';
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
private bootstrap = inject(BootstrapService);
|
||||
private auth = inject(AuthService);
|
||||
async ngOnInit() {
|
||||
await this.bootstrap.load();
|
||||
await this.auth.restoreSession();
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,36 @@ import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.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 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 = () => {
|
||||
const auth = inject(AuthService);
|
||||
const bootstrap = inject(BootstrapService);
|
||||
const router = inject(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]);
|
||||
};
|
||||
return decideAdminAccessGuard({ auth, bootstrap, router });
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,17 +2,24 @@ import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AuthService } from '../services/auth.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 bootstrap = inject(BootstrapService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (!bootstrap.initialized()) {
|
||||
return router.createUrlTree(['/bootstrap']);
|
||||
}
|
||||
if (!auth.isAuthenticated()) {
|
||||
return router.createUrlTree(['/login']);
|
||||
}
|
||||
return true;
|
||||
await bootstrap.ready();
|
||||
await auth.waitUntilHydrated();
|
||||
|
||||
return decideAuthRedirect(
|
||||
{
|
||||
initialized: bootstrap.initialized(),
|
||||
isAuthenticated: auth.isAuthenticated(),
|
||||
},
|
||||
router,
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
id: string;
|
||||
@@ -6,10 +14,24 @@ export interface CurrentUser {
|
||||
role: 'admin' | 'player';
|
||||
}
|
||||
|
||||
interface RefreshResponse {
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http: HttpClient;
|
||||
|
||||
constructor(http?: HttpClient) {
|
||||
this.http = http ?? inject(HttpClient);
|
||||
}
|
||||
|
||||
private accessToken = signal<string | null>(null);
|
||||
private user = signal<CurrentUser | null>(null);
|
||||
readonly hydrated = signal<boolean>(false);
|
||||
private hydrateWaiters: Array<() => void> = [];
|
||||
|
||||
readonly currentUser = computed(() => this.user());
|
||||
readonly isAuthenticated = computed(() => !!this.user());
|
||||
@@ -17,14 +39,69 @@ export class AuthService {
|
||||
setSession(token: string, user: CurrentUser): void {
|
||||
this.accessToken.set(token);
|
||||
this.user.set(user);
|
||||
writeStoredSession(this.sessionStorage(), { token, user });
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.accessToken.set(null);
|
||||
this.user.set(null);
|
||||
clearStoredSession(this.sessionStorage());
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
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,
|
||||
);
|
||||
|
||||
private loadPromise: Promise<BootstrapPayload | null> | null = null;
|
||||
|
||||
async load(): Promise<BootstrapPayload | null> {
|
||||
if (!this.loadPromise) {
|
||||
this.loadPromise = this._load();
|
||||
}
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private async _load(): Promise<BootstrapPayload | null> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
|
||||
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 {
|
||||
this.initialized.set(true);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
HostListener,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
@@ -32,6 +33,16 @@ export class SetupCreateAdminComponent {
|
||||
private readonly router = inject(Router);
|
||||
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 form = this.fb.nonNullable.group(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user