From 5499e30f95e8f84dce8b307ae7824f73357245ac Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Tue, 21 Jul 2026 15:32:48 +0000 Subject: [PATCH 1/2] docs: update documentation to OKF v0.1 format --- docs/architecture/frontend-structure.md | 29 ++++-- docs/architecture/key-files.md | 14 ++- docs/architecture/overview.md | 17 +++- docs/guides/session-restoration.md | 126 ++++++++++++++++++++++++ docs/index.md | 6 +- 5 files changed, 173 insertions(+), 19 deletions(-) create mode 100644 docs/guides/session-restoration.md diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 3b70e5b..3ddcdc9 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -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 `` and calls `BootstrapService.load()`. | +| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders ``, calls `BootstrapService.load()` then `AuthService.restoreSession()`. | | `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `` 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 ` 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 diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index ab34784..ce02e30 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -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. | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index b1d1439..9f5e6bc 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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) | diff --git a/docs/guides/session-restoration.md b/docs/guides/session-restoration.md new file mode 100644 index 0000000..ae97578 --- /dev/null +++ b/docs/guides/session-restoration.md @@ -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) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 490aa84..2432d93 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. -- 2.52.0 From 8ef5bc70496d3fa097e8284dc685510c5cc719e7 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Tue, 21 Jul 2026 15:32:48 +0000 Subject: [PATCH 2/2] feat: First-Start Create Admin Modal 1.01 --- .kilo/plans/841.md | 178 ----------- .kilo/plans/842.md | 294 ++++++++++++++++++ frontend/src/app/app.component.ts | 3 + frontend/src/app/core/guards/admin.guard.ts | 36 ++- .../app/core/guards/auth.guard.decision.ts | 19 ++ frontend/src/app/core/guards/auth.guard.ts | 23 +- .../src/app/core/services/auth.service.ts | 79 ++++- .../app/core/services/auth.session-storage.ts | 43 +++ .../app/core/services/bootstrap.service.ts | 16 + .../setup/setup-create-admin.component.ts | 11 + tests/frontend/auth-restore.spec.ts | 118 +++++++ tests/frontend/guard-bootstrap-race.spec.ts | 39 +++ 12 files changed, 661 insertions(+), 198 deletions(-) delete mode 100644 .kilo/plans/841.md create mode 100644 .kilo/plans/842.md create mode 100644 frontend/src/app/core/guards/auth.guard.decision.ts create mode 100644 frontend/src/app/core/services/auth.session-storage.ts create mode 100644 tests/frontend/auth-restore.spec.ts create mode 100644 tests/frontend/guard-bootstrap-race.spec.ts diff --git a/.kilo/plans/841.md b/.kilo/plans/841.md deleted file mode 100644 index 2e612ec..0000000 --- a/.kilo/plans/841.md +++ /dev/null @@ -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 `
` with no nav bar, no sidebar, no menu, and no `routerLink` to any admin area. -- `frontend/src/app/app.component.ts` only renders ``; 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>` 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(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 `` 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 {{ username }} ({{ role }})`). - - A `