From e5720c66dc654fc96ea0211d81ba5201ba5c59e6 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Tue, 21 Jul 2026 22:26:44 +0000 Subject: [PATCH] docs: update documentation to OKF v0.1 format --- docs/api/auth.md | 120 ++++++++++++++--- docs/api/rest-overview.md | 5 +- docs/api/system.md | 118 +++++++++++++---- docs/architecture/backend-modules.md | 10 +- docs/architecture/frontend-structure.md | 100 ++++++++++---- docs/architecture/key-files.md | 58 ++++++--- docs/guides/admin-shell.md | 4 +- docs/guides/authenticated-shell.md | 166 ++++++++++++++++++++++++ docs/guides/change-password.md | 155 ++++++++++++++++++++++ docs/guides/event-window.md | 89 ++++++++++--- docs/index.md | 24 ++-- 11 files changed, 727 insertions(+), 122 deletions(-) create mode 100644 docs/guides/authenticated-shell.md create mode 100644 docs/guides/change-password.md diff --git a/docs/api/auth.md b/docs/api/auth.md index 0aff1d9..eb8faf1 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -1,9 +1,9 @@ --- type: api title: Auth Endpoints -description: Login, refresh, logout, public self-registration, CSRF, first-admin registration, and registration throttling. -tags: [api, auth, login, register, refresh, csrf] -timestamp: 2026-07-21T18:28:00Z +description: Login, refresh, logout, public self-registration, CSRF, /me, change-password, first-admin registration, and registration throttling. +tags: [api, auth, login, register, refresh, csrf, me, change-password] +timestamp: 2026-07-21T22:19:08Z --- # Endpoints @@ -14,6 +14,8 @@ timestamp: 2026-07-21T18:28:00Z | `POST` | `/api/v1/auth/login` | `@Public()` | Enforced — same pattern as `register`. CSRF skip was removed in this change. | Yes — `LoginBackoffService` per IP + username | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/refresh` | Public route, requires a valid refresh cookie. | Skipped (the request is a `POST` carrying an HttpOnly cookie, not a state-changing user action). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/logout` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` | +| `GET` | `/api/v1/auth/me` | Authenticated (JWT). | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` | +| `POST` | `/api/v1/auth/change-password` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` | | `GET` | `/api/v1/auth/csrf` | `@Public()` | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` | | `POST` | `/api/v1/auth/register-first-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`). | Yes | `backend/src/modules/users/users.controller.ts` | @@ -64,27 +66,103 @@ The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, sess * `AuthService.registerFirstAdmin(username, password, ip)` uses the same limiter before checking whether an administrator already exists. * The limiter is an in-memory Nest provider in `backend/src/common/services/registration-rate-limit.service.ts`; counters are process-local and reset when the API process restarts. +# `GET /api/v1/auth/me` + +Authenticated-only projection of the current user, including derived rank +and total points from the `solve` table. + +| HTTP | `code` | When | +|------|-----------------|-----------------------------------------------------| +| 200 | — | Returns `{ id, username, role, rank, points }`. | +| 401 | `UNAUTHORIZED` | No JWT on the request (handled by `JwtAuthGuard`). | + +## Successful response (200) + +```json +{ + "id": "", + "username": "player1", + "role": "player", + "rank": 3, + "points": 275 +} +``` + +* `rank` is `null` while the user has 0 points (no rank assigned). +* `points` is `SUM(solve.pointsAwarded)` for that user. +* `rank` is `1 + (count of users with strictly higher points total)`. + +## Wiring + +`AuthService.getMe(userId)` (`backend/src/modules/auth/auth.service.ts`) +loads the `UserEntity` then delegates the rank/points math to +`UsersRankService.rankOfUser(manager, userId)` +(`backend/src/modules/users/users-rank.service.ts`). + +# `POST /api/v1/auth/change-password` + +Authenticated-only password change. Requires the current password +(`mode='self'`); the admin-reset variant lives in a later Job. + +| Field | Type | Constraints | +|-----------------------|--------|-----------------------------------------------------------------------------------| +| `oldPassword` | string | 1–256 chars. Required when `mode='self'`. | +| `newPassword` | string | 1–256 chars; passes `validatePassword` (Argon2 policy) AND must differ from old. | +| `confirmNewPassword` | string | Must equal `newPassword` (zod refine → `VALIDATION_FAILED`). | + +Validated by `ChangePasswordDtoSchema` in `backend/src/modules/auth/dto/auth.dto.ts`. + +## Successful response (204) + +No body. The new password hash is persisted inside a `DataSource.transaction` +that also revokes every active `refresh_token` row for the user +(`revokedAt = now`), forcing the user to re-authenticate on other devices. + +## Error envelope + +| HTTP | `code` | When | +|------|---------------------------|----------------------------------------------------------------------------------------| +| 400 | `VALIDATION_FAILED` | zod body validation failed. | +| 400 | `PASSWORDS_DO_NOT_MATCH` | `newPassword !== confirmNewPassword`. | +| 400 | `PASSWORD_POLICY` | New password equals the old, or fails the Argon2 policy (`PASSWORD_MIN_LENGTH`, mixed-case requirement). | +| 401 | `UNAUTHORIZED` | No JWT on the request. | +| 401 | `INVALID_OLD_PASSWORD` | `argon2.verify(oldPasswordHash, oldPassword)` failed. | + +## Wiring + +`AuthService.changePassword(userId, dto)` runs inside a single +`DataSource.transaction`: + +1. Validate `newPassword === confirmNewPassword` → `PASSWORDS_DO_NOT_MATCH`. +2. Reject `oldPassword === newPassword` → `PASSWORD_POLICY`. +3. `argon2.verify(user.passwordHash, dto.oldPassword)` → `INVALID_OLD_PASSWORD` on miss. +4. `validatePassword(dto.newPassword, config)` → rethrown as `PASSWORD_POLICY` on failure. +5. Re-hash with Argon2id and `manager.save(user)`. +6. UPDATE all `refresh_token` rows for the user where `revokedAt IS NULL`. + # Key Files | File | Responsibility | |------|----------------| -| `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, and mints sessions. | +| `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, mints sessions, exposes `getMe()` and `changePassword()`. | | `backend/src/common/services/registration-rate-limit.service.ts` | Tracks timestamps and enforces the 10-per-minute per-IP limit. | -| `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes. | -| `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration request fields. | +| `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes plus authenticated `/me` and `/change-password`. | +| `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration, login, refresh, `/me`, and `change-password` request fields. | +| `backend/src/modules/users/users-rank.service.ts` | Pure provider computing `{ rank, points }` over the `solve` table for a user. | +| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` (now includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`). | # Frontend consumer -`frontend/src/app/core/services/auth.service.ts` exposes `login()` and -`register()`. Both: - -1. Call `ensureCsrf()` → `GET /api/v1/auth/csrf` to materialise the `csrf` - cookie if it isn't set yet. -2. `POST` with `{ withCredentials: true, observe: 'response' }`. -3. Return success or the standard [error envelope](/api/rest-overview.md). - -The `LandingComponent` consumes both methods and maps failures to user-facing -copy (see [Landing Page](/guides/landing-page.md)). +`frontend/src/app/core/services/auth.service.ts` exposes `login()`, +`register()`, `me()`, `changePassword()`, `logout()`, and +`restoreSession()`. Each writes through the existing CSRF + auth +interceptors and returns a discriminated result type (`LoginResult`, +`RegisterResult`, `ChangePasswordResult`) for component-level error +handling. The authenticated shell's +[change-password modal](/guides/change-password.md) consumes +`changePassword()` and maps failures via +`formatChangePasswordError()` in +`frontend/src/app/features/shell/change-password/password-feedback.ts`. # Examples @@ -96,8 +174,18 @@ copy (see [Landing Page](/guides/landing-page.md)). 4. The first 10 attempts are processed normally; the 11th within 60 seconds returns HTTP `429` with code `RATE_LIMITED`. 5. After the rolling window expires, a new attempt is accepted if other requirements pass. +## Tester flow: change-password happy path + +1. Sign in via `/login`. +2. Click the username dropdown in the shell header → "Change password". +3. Enter the current password, a new password that meets the policy, and the same new password in "Confirm new password". +4. Click **OK**. The modal closes and the success indicator (modal closes) is visible. +5. The user's existing refresh tokens have been revoked, so a `refresh` from any other tab will return 401 and that tab will be sent to `/login` on the next guard tick. + # See also - [REST API Overview](/api/rest-overview.md) - [Setup Endpoint](/api/setup.md) - [Landing Page Guide](/guides/landing-page.md) +- [Authenticated Shell Guide](/guides/authenticated-shell.md) +- [Change Password Guide](/guides/change-password.md) diff --git a/docs/api/rest-overview.md b/docs/api/rest-overview.md index 6e77399..3f7c567 100644 --- a/docs/api/rest-overview.md +++ b/docs/api/rest-overview.md @@ -3,7 +3,7 @@ type: api title: REST API Overview description: Base URL, versioning, auth, CSRF, and the standard error envelope. tags: [api, rest, overview, csrf] -timestamp: 2026-07-21T18:28:00Z +timestamp: 2026-07-21T22:19:08Z --- # Base URL & versioning @@ -77,7 +77,8 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`: `CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`, `CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`, `INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, -`REGISTRATIONS_DISABLED`, `INTERNAL`. +`REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, +`PASSWORDS_DO_NOT_MATCH`, `INTERNAL`. # See also diff --git a/docs/api/system.md b/docs/api/system.md index 133f9b8..5f1adc8 100644 --- a/docs/api/system.md +++ b/docs/api/system.md @@ -1,19 +1,25 @@ --- type: api title: System Endpoints -description: Bootstrap payload, event status, and SSE streams. -tags: [api, system, bootstrap, event, sse] -timestamp: 2026-07-21T18:28:00Z +description: Bootstrap payload, public event status, public/authenticated SSE streams, and public event-window timestamps. +tags: [api, system, bootstrap, event, sse, settings] +timestamp: 2026-07-21T22:19:08Z --- # Endpoints -| Method | Path | Auth | Source | -|--------|---------------------------------|--------|-----------------------------------------------------| -| `GET` | `/api/v1/bootstrap` | Public | `backend/src/modules/system/system.controller.ts` | -| `GET` | `/api/v1/event/status` | Public | Same. | -| `GET` | `/api/v1/event/stream` | Public (SSE) | Same. | -| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. | +| Method | Path | Auth | Source | +|--------|-----------------------------------|--------|-----------------------------------------------------| +| `GET` | `/api/v1/bootstrap` | Public | `backend/src/modules/system/system.controller.ts` | +| `GET` | `/api/v1/event/status` | Public | Same. | +| `GET` | `/api/v1/event/stream` | Public (SSE) | Same. | +| `GET` | `/api/v1/settings/event` | Public | Same. | +| `GET` | `/api/v1/events/status` | Authenticated (SSE) | Same. | +| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. | + +> Path note: the **authenticated** status stream is mounted at the plural +> `/api/v1/events/status` so it can be distinguished from the legacy +> public `/api/v1/event/status` snapshot endpoint. # `GET /api/v1/bootstrap` @@ -28,7 +34,12 @@ SPA's `BootstrapService`: "welcomeMarkdown": "# Welcome\n\n...", "theme": { "id": "classic", "tokens": { "...": "..." } }, "defaultChallengeIp": "127.0.0.1", - "registrationsEnabled": false + "registrationsEnabled": false, + "passwordPolicy": { + "minLength": 12, + "requireMixed": true, + "description": "At least 12 characters with upper, lower, digit and symbol" + } } ``` @@ -38,32 +49,89 @@ SPA's `BootstrapService`: * `registrationsEnabled` is read from the `setting` table (`SETTINGS_KEYS.REGISTRATIONS_ENABLED`) and gates the new public register endpoint (see [Auth Endpoints](/api/auth.md)). +* `passwordPolicy` is derived from `PASSWORD_MIN_LENGTH` / + `PASSWORD_REQUIRE_MIXED` env vars (see + [Change Password Guide](/guides/change-password.md)). -# `GET /api/v1/event/status` +# `GET /api/v1/event/status` (legacy public snapshot) -Returns the current event window computed by `EventStatusService`: +Returns the current event window computed by `EventStatusService.getStatus()`: ```json -{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "..." } +{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "...", "serverNowUtc": "..." } ``` -`state` is `Stopped` when `now < eventStartUtc` (countdown), `Running` -between start and end, and `Stopped` once `now >= eventEndUtc`. +`status` is `Stopped` while `now < eventStartUtc` (countdown), `Running` +between start and end, and `Stopped` once `now >= eventEndUtc`. This +endpoint is preserved for backward compatibility — new consumers should +use `getState()` (see below). -# SSE streams +# `GET /api/v1/settings/event` (public timestamps) -* `/api/v1/event/stream` — emits the same payload as - `GET /api/v1/event/status` whenever `SseHubService.publish` is invoked - (typically via an admin cron tick). -* `/api/v1/scoreboard/stream` — emits the public scoreboard projection - after each new solve. +Returns the raw UTC event-window timestamps without computing state. +Useful for the SPA when only the window endpoints are needed: -Both use NestJS `@Sse()` and are proxied through `SseHubService` -(`backend/src/common/services/sse-hub.service.ts`). +```json +{ "eventStartUtc": "2026-07-21T12:00:00.000Z", "eventEndUtc": "2026-07-28T12:00:00.000Z" } +``` + +A `null` value for either field means that the underlying `setting` row +is missing or empty; in that case `EventStatusService.getState()` returns +the `unconfigured` state. + +# `GET /api/v1/events/status` (authenticated SSE) + +Authenticated Server-Sent Events stream that pushes the full +`EventStatePayload` whenever it changes. Protected by the global +`JwtAuthGuard` — unauthenticated clients receive `401` before any frame +is sent. + +Each frame: + +```json +{ + "state": "countdown" | "running" | "stopped" | "unconfigured", + "serverNowUtc": "2026-07-21T12:00:00.000Z", + "eventStartUtc": "2026-07-21T12:00:00.000Z" | null, + "eventEndUtc": "2026-07-28T12:00:00.000Z" | null, + "secondsToStart": 3600, + "secondsToEnd": 604800 +} +``` + +* `state` transitions: + * `unconfigured` — `eventStartUtc`/`eventEndUtc` settings are missing or unparseable. + * `countdown` — `now < eventStartUtc`. + * `running` — `start <= now <= end`. + * `stopped` — `now > eventEndUtc`. +* The stream emits: + 1. An **initial** frame on connect (`defer(getState())`). + 2. A **60-second tick** frame so the SPA can update even if no admin + cron publishes through the hub. + 3. Any frame pushed via `SseHubService.publish()`. + * Consecutive identical payloads are suppressed via `distinctUntilChanged(JSON.stringify)`. + +See `backend/src/common/services/event-status.service.ts` for the +authoritative state machine; see +[Event Window Guide](/guides/event-window.md) for the full diagram and +the SPA wiring. + +# `GET /api/v1/event/stream` (public SSE, legacy) + +Public, flattened event stream that emits the legacy payload shape +(`status`, `countdownMs`, `startUtc`, `endUtc`, `serverNowUtc`) on a +1-second tick and on hub pushes. Preserved for backward compatibility +with the original landing page status badge. + +# `GET /api/v1/scoreboard/stream` + +Public SSE stream that pushes a flattened solve payload whenever a new +solve is published (`backend/src/common/services/sse-hub.service.ts`). # See also - [Auth and Settings Tables](/database/auth-settings.md) - [Theming](/guides/theming.md) -- [Event Window](/guides/event-window.md) -- [Scoreboard Stream](/guides/scoreboard-stream.md) +- [Event Window Guide](/guides/event-window.md) +- [Scoreboard Stream Guide](/guides/scoreboard-stream.md) +- [Authenticated Shell Guide](/guides/authenticated-shell.md) diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index e965da1..740383e 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -3,7 +3,7 @@ type: architecture title: Backend Module Map description: NestJS modules, controllers, services, and how they are wired together. tags: [architecture, backend, nestjs, modules] -timestamp: 2026-07-21T14:43:00Z +timestamp: 2026-07-21T22:19:08Z --- # Module Map @@ -23,8 +23,8 @@ also registers two global providers: | `DatabaseModule` | `backend/src/database/database.module.ts` | Configures TypeORM with `better-sqlite3`, registers entities, runs migrations, enforces WAL. | | `CommonModule` | `backend/src/common/common.module.ts` | Provides shared services (CSRF middleware class, backoff, registration rate limit, SSE hub, theme loader, etc.). | | `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). | -| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin + public `register`). Imports `SettingsModule` so the public-register flow can read the `registrationsEnabled` flag. | -| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). | +| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/{register,login,refresh,logout,me,change-password,csrf}`) + `AuthService` (login/refresh/logout/register-first-admin + public `register` + `getMe` + `changePassword`). Imports `SettingsModule` so the public-register flow can read the `registrationsEnabled` flag, and `UsersModule` (forwardRef) for `UsersRankService`. | +| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). | | `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. | | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. | @@ -40,7 +40,7 @@ also registers two global providers: | `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` | | `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 409 `SYSTEM_INITIALIZED` once an admin exists) | `backend/src/modules/setup/setup.controller.ts` | | `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` | -| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` | +| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` | @@ -53,7 +53,7 @@ also registers two global providers: | `AdminGuard` | `backend/src/common/guards/admin.guard.ts` | Requires authenticated user with `role === 'admin'`. | | `RolesGuard` | `backend/src/common/guards/roles.guard.ts` | Enforces `@Roles(...)` metadata. | | `GlobalExceptionFilter` | `backend/src/common/filters/global-exception.filter.ts` | Converts exceptions to standard envelope. | -| `EventStatusService` | `backend/src/common/services/event-status.service.ts` | Computes `Stopped`/`Running` and `countdownMs`. | +| `EventStatusService` | `backend/src/common/services/event-status.service.ts` | Computes the 4-state event state machine (`countdown`/`running`/`stopped`/`unconfigured`) via `getState()` plus the legacy `getStatus()` shape (`Stopped`/`Running` + `countdownMs`). | | `LoginBackoffService` | `backend/src/common/services/login-backoff.service.ts` | Per-IP+username brute-force throttle. | | `RegistrationRateLimitService` | `backend/src/common/services/registration-rate-limit.service.ts` | Per-IP rate limit on first-admin registration. | | `SseHubService` | `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for SSE streams. | diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 45e7078..59feb69 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -2,21 +2,24 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, and interceptors. -tags: [architecture, frontend, angular] -timestamp: 2026-07-21T18:28:00Z +tags: [architecture, frontend, angular, shell, sse] +timestamp: 2026-07-21T22:19:08Z --- # Routes Routes live in `frontend/src/app/app.routes.ts`: -| Path | Component | Guard | Notes | -|--------------|----------------------------------|---------------|----------------------------------------| -| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. | -| `/login` | `LandingComponent` | `landingGuard`| Public landing page that hosts the login (and registration, when enabled) modal. The `landingGuard` redirects to `/bootstrap` until the instance is initialized and to `/` if the user is already authenticated. | -| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `` for child routes. | -| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. | -| `**` | Redirect to `/` | — | Wildcard fallback. | +| Path | Component | Guard | Notes | +|-----------------|----------------------------------|---------------|----------------------------------------| +| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. | +| `/login` | `LandingComponent` | `landingGuard`| Public landing page that hosts the login (and registration, when enabled) modal. The `landingGuard` redirects to `/bootstrap` until the instance is initialized and to `/` if the user is already authenticated. | +| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts the header (LED + countdown + user menu), quick-jump tabs (Challenges/Scoreboard/Blog), and a `` for child routes. Default child route redirects to `/challenges`. | +| `/challenges` | `ChallengesPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the challenges Job lands. | +| `/scoreboard` | `ScoreboardPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the scoreboard Job lands. | +| `/blog` | `BlogPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the blog Job lands. | +| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. | +| `**` | Redirect to `/` | — | Wildcard fallback. | # Components @@ -26,23 +29,34 @@ All components are standalone (no NgModules). Each component imports | Component | Path | Purpose | |------------------------|-----------------------------------------------------------------|-------------------------------------------------| | `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. | +| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart authenticated shell. Hosts the `ShellHeaderComponent`, `QuickTabsComponent`, the `ChangePasswordModalComponent`, and the `` for child routes. Owns the active section / active tab signals and the SSE lifecycle for `EventStatusStore`. | +| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb presentational header. Inputs: `pageTitle`, `activeSection`, `eventState`, `countdownText`, `username`, `rankText`, `canAccessAdmin`, `userMenuOpen`. Outputs: `titleClick`, `usernameMenuToggle`, `rankClick`, `changePasswordClick`, `adminClick`, `logoutClick`. Renders the LED (state class), `DD:HH:mm` countdown, and an inline user menu dropdown (`Change password`, optional `Admin area`, `Logout`). | +| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb presentational tab strip. Inputs: `tabs: ShellTab[]`, `active: string`. Output: `tabChange: {id}`. | +| `ChangePasswordModalComponent` | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal with three fields (`oldPassword` only in `mode='self'`), `passwordMatchValidator` group validation, policy hint from `BootstrapService.passwordPolicy()`, and `errorMessage`/`submitting` inputs. Emits `submit(payload)` and `cancel()`. Closes on Escape and on overlay click. | +| `ChallengesPage` | `frontend/src/app/features/challenges/challenges.page.ts` | Placeholder child route. | +| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Placeholder child route. | +| `BlogPage` | `frontend/src/app/features/blog/blog.page.ts` | Placeholder child route. | | `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. | -| `LoginComponent` | *(removed)* — replaced by `LandingComponent`. | | -| `LandingComponent` | `frontend/src/app/features/landing/landing.component.{ts,html,css}` | Public landing page: logo, page title, rendered `welcomeMarkdown`, "Login" button, blog post list, and the modal that contains both the login and registration forms (mode toggled by a single signal). | -| `LandingService` | `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals. | +| `LandingComponent` | `frontend/src/app/features/landing/landing.component.{ts,html,css}` | Public landing page: logo, page title, rendered `welcomeMarkdown`, "Login" button, blog post list, and the modal that contains both the login and registration forms (mode toggled by a single signal). | +| `LandingService` | `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals, `refresh()`. | | `LoginModalService` (pure helpers) | `frontend/src/app/features/landing/login-modal.service.ts` | Pure `buildLoginFailureMessage` that maps an API error envelope into user-facing copy (and extracts a `retryAfterSeconds` for `RATE_LIMITED`). | | `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. | +| `HomeShell` (pure helpers) | `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` + `deriveActiveSectionFromUrl(url)` predicates (unit-tested). | # Services | Service | Path | Purpose | |---------------------|---------------------------------------------------------------|-----------------------------------------------------------| -| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage`, exposes `restoreSession()` + `waitUntilHydrated()`, plus `login()` / `register()` (each calls `ensureCsrf()` then posts, returning a discriminated `LoginResult` / `RegisterResult`). | -| `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. | +| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage`, exposes `restoreSession()` + `waitUntilHydrated()`, plus `login()` / `register()` / `logout()` / `me()` / `changePassword()` (each calls `ensureCsrf()` first). Returns discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult` for component-level error handling. | +| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS, exposes a `passwordPolicy` computed signal derived from the bootstrap payload; `load()` / `ready()` deduplicate the in-flight fetch. | +| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window. Owns `state`/`serverNowUtc`/`eventStartUtc`/`eventEndUtc`, derives a `countdownText` computed signal (`DD:HH:mm` or `"Event ended"`), and exposes `start(createSource)` / `stop()` so `HomeComponent` can drive a 1-second tick that re-derives the seconds-to-start/end locally between SSE pushes. | +| `EventStatusPure` | `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers consumed by the store and by tests: `deriveCountdownText(state, sStart, sEnd)`, `formatDdHhMm(totalSeconds)`, `EventSourceLike` interface, and the `EventState`/`EventStatePayload` types. | +| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (id/username/role/rank/points + loading/error). `hydrateFromAuth()` mirrors `AuthService.currentUser()`; `loadMe()` calls `AuthService.me()` and merges rank/points. Exposes `rankText` (`"Rank: - Points:

"`). | | `MarkdownService` | `frontend/src/app/core/services/markdown.service.ts` | Wraps `DomSanitizer.bypassSecurityTrustHtml` around the pure `renderMarkdownToHtml` helper. Used to render `welcomeMarkdown` and blog post bodies. | | `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). | | `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. | +| `ChangePassword` (pure helpers) | `frontend/src/app/features/shell/change-password/password-feedback.ts` | `formatChangePasswordError({code, message})` maps the API error code (`INVALID_OLD_PASSWORD` / `PASSWORDS_DO_NOT_MATCH` / `PASSWORD_POLICY` / `UNAUTHORIZED`) to user-facing copy. | +| `ChangePassword` (validators) | `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` (group-level reactive-forms validator) + `PasswordPolicy` zod schema used by the modal. | # Guards and interceptors @@ -70,8 +84,9 @@ Both interceptors are wired in `frontend/src/main.ts` via 2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with `credentials: 'include'`). 3. The payload sets `initialized` (true iff any admin user exists), - `pageTitle`, `logo`, `welcomeMarkdown`, the active `theme`, and the - default challenge IP. + `pageTitle`, `logo`, `welcomeMarkdown`, the active `theme`, the + `defaultChallengeIp`, the `registrationsEnabled` flag, and the + `passwordPolicy` descriptor consumed by the change-password modal. 4. Theme tokens are written to CSS custom properties on `document.documentElement` (`--color-primary`, `--font-family`, `--radius-*`, etc.) consumed by `frontend/src/styles.css`. @@ -86,23 +101,52 @@ Both interceptors are wired in `frontend/src/main.ts` via / `isAuthenticated()`, which prevents a flash of `/bootstrap` or `/login` on a hard refresh of a deep link. -# Home shell flow +# Authenticated shell flow -After successful auth, `HomeComponent` renders a thin shell layout: +After successful auth, `HomeComponent` renders a full shell layout: -1. The header (`shell-header`) shows the page title and signed-in - identity. -2. When `shouldShowAdminNav({isAuthenticated, role})` returns `true` - (see `frontend/src/app/features/home/home.shell.ts`) the admin nav - link (`data-testid="nav-admin"`) is rendered. -3. Child routes (e.g. `/admin` → `AdminUsersComponent`) render into the - shell's ``. -4. `AdminUsersComponent` calls `AdminService.listUsers()` on init; - loading, error, and the rendered list are exposed as Angular signals. +1. **`ShellHeaderComponent`** is fed (signal inputs) the page title + from bootstrap, the active section label (computed from the current + router URL), the `EventStatusStore.state()` (used to colour the + LED — `running` / `countdown` / `stopped` / `unconfigured`), and the + `countdownText()` (`DD:HH:mm` or `"Event ended"`). The user menu + trigger opens an inline dropdown with `Rank: - Points:

`, + `Change password`, optional `Admin area` (visible only when + `canAccessAdmin()` is true), and `Logout`. +2. **`QuickTabsComponent`** renders `Challenges / Scoreboard / Blog` + tabs. The `active` input is computed from the current URL + (`HomeComponent.activeTabId()`). Clicking a tab emits `tabChange` + and the parent calls `router.navigateByUrl('/' + id)` (or `/admin` + for `id === 'admin'`). +3. The `` renders whichever child route is active + (`ChallengesPage`, `ScoreboardPage`, `BlogPage`, or `AdminUsersComponent`). +4. On `ngOnInit`, `HomeComponent` calls + `UserStore.hydrateFromAuth()` + `UserStore.loadMe()` (which calls + `AuthService.me()` → `GET /api/v1/auth/me`) and starts the SSE + consumer via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`. + The store parses each `message` frame, applies the state via + `applyServerStatus(payload)`, and ticks the local + `secondsToStart` / `secondsToEnd` signals every second. +5. On `ngOnDestroy` (and via `DestroyRef.onDestroy` for safety), + `HomeComponent` calls `EventStatusStore.stop()` to close the SSE + source and clear the tick interval. +6. Clicking `Change password` in the header opens the + `ChangePasswordModalComponent` (mode `'self'`). Submitting posts + `/api/v1/auth/change-password` via `AuthService.changePassword()`; + on success the modal closes and the user's existing refresh tokens + are revoked server-side. See the + [Change Password Guide](/guides/change-password.md) for the full + tester flow. +7. Clicking `Logout` calls `AuthService.logout()` (which POSTs + `/api/v1/auth/logout`), resets the `UserStore`, and navigates to + `/login`. # See also - [System Overview](/architecture/overview.md) - [Backend Module Map](/architecture/backend-modules.md) +- [Key Files Index](/architecture/key-files.md) - [First-Run Bootstrap](/guides/bootstrap.md) - [Admin Shell & User Management](/guides/admin-shell.md) +- [Authenticated Shell](/guides/authenticated-shell.md) +- [Change Password](/guides/change-password.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 3f06c9f..ba6279a 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -3,7 +3,7 @@ type: architecture title: Key Files Index description: One-line responsibility for every important source file in the repository. tags: [architecture, index, key-files] -timestamp: 2026-07-21T18:45:54Z +timestamp: 2026-07-21T22:19:08Z --- # Backend @@ -25,12 +25,12 @@ timestamp: 2026-07-21T18:45:54Z | `backend/src/database/migrations/1700000000000-InitSchema.ts` | Creates all tables and sets WAL + foreign keys. | | `backend/src/database/migrations/1700000000100-SeedSystemData.ts` | Seeds the 6 system categories and default settings. | | `backend/src/database/entities/user.entity.ts` | `user` table (admin/player role, enabled/disabled status). | -| `backend/src/database/entities/setting.entity.ts` | `setting` key/value table. | +| `backend/src/database/entities/setting.entity.ts` | `setting` table. | | `backend/src/database/entities/category.entity.ts` | `category` table with `system_key` unique index. | | `backend/src/database/entities/challenge.entity.ts` | `challenge` table (difficulty, decay, protocol, flag, port). | | `backend/src/database/entities/challenge-file.entity.ts` | `challenge_file` table for attachments. | -| `backend/src/database/entities/solve.entity.ts` | `solve` table with unique `(challenge_id, user_id)`. | -| `backend/src/database/entities/refresh-token.entity.ts` | `refresh_token` table (token_hash + revocation). | +| `backend/src/database/entities/solve.entity.ts` | `solve` table with unique `(challenge_id, user_id)` (used by `UsersRankService`). | +| `backend/src/database/entities/refresh-token.entity.ts` | `refresh_token` table (token_hash + revocation). | | `backend/src/database/entities/blog-post.entity.ts` | `blog_post` table (draft/published + publishedAt index). | ## Common @@ -48,7 +48,7 @@ timestamp: 2026-07-21T18:45:54Z | `backend/src/common/filters/global-exception.filter.ts` | Converts all errors to the standard envelope. | | `backend/src/common/pipes/zod-validation.pipe.ts` | Validates body/param/query with a zod schema. | | `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. | -| `backend/src/common/services/event-status.service.ts` | Computes event `Stopped`/`Running` and `countdownMs`. | +| `backend/src/common/services/event-status.service.ts` | 4-state machine (`countdown`/`running`/`stopped`/`unconfigured`) via `getState()`; legacy `getStatus()` shape preserved for backward compatibility. | | `backend/src/common/services/login-backoff.service.ts` | Per-IP+username failed-login throttle. | | `backend/src/common/services/registration-rate-limit.service.ts` | Per-IP first-admin registration throttle. | | `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for event status and scoreboard streams. | @@ -58,19 +58,20 @@ timestamp: 2026-07-21T18:45:54Z | `backend/src/common/utils/upload.ts` | Upload size-limit parser + filename sanitizer. | | `backend/src/common/types/theme.ts`, `theme-ids.ts` | `Theme` interface and the 10 canonical `ThemeId`s. | | `backend/src/common/errors/api-error.ts` | `ApiError` class (status + machine code + message + details). | -| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` enum. | +| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` enum (now includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`). | ## Modules | File | Responsibility | |--------------------------------------------------------------------------|--------------------------------------------------------------------------------| -| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`; imports `SettingsModule` for the public register flow. | -| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{register,login,refresh,logout,csrf}`. | -| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, public player self-registration (`registerPlayer`), session minting. | +| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`; imports `SettingsModule` for the public register flow and `UsersModule` (forwardRef) for `UsersRankService`. | +| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{register,login,refresh,logout,csrf,me,change-password}`. `logout`, `me`, and `change-password` are JWT-protected (CSRF-enforced on `change-password`). | +| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, public player self-registration (`registerPlayer`), session minting, `getMe(userId)` (rank + points), `changePassword(userId, dto)` (revoke-all-refresh-tokens on success). | | `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. | -| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh + `RegisterDtoSchema` (with `password === passwordConfirm` refine + username regex). | +| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh + `RegisterDtoSchema` + `ChangePasswordDtoSchema` + `MeResponseDtoSchema`. | | `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. | -| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService`. | +| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService` + `UsersRankService`. | +| `backend/src/modules/users/users-rank.service.ts` | Pure provider `rankOfUser(manager, userId)` returning `{rank, points}` by summing `pointsAwarded` from `solve` and counting distinct users ahead. | | `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. | | `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. | | `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. | @@ -83,8 +84,8 @@ timestamp: 2026-07-21T18:45:54Z | `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. | | `backend/src/modules/admin/dto/admin.dto.ts` | Zod schemas for admin endpoints. | | `backend/src/modules/system/system.module.ts` | Wires `SystemController` + `SystemService` + status + hub services. | -| `backend/src/modules/system/system.controller.ts` | `/api/v1/{bootstrap,event/status}` + SSE `/event/stream`, `/scoreboard/stream`. | -| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. | +| `backend/src/modules/system/system.controller.ts` | `/api/v1/{bootstrap,event/status,event/stream,settings/event,scoreboard/stream}` (public) + authenticated SSE `/events/status` (plural, JWT-protected). | +| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count + password policy. | | `backend/src/modules/uploads/uploads.module.ts` | Wires `UploadsController`. | | `backend/src/modules/uploads/uploads.controller.ts` | `/api/v1/uploads/{category-icon,challenge-file}`. | | `backend/src/modules/blog/blog.module.ts` | Wires `BlogController` + `BlogService` (registers `BlogPostEntity`). | @@ -111,10 +112,13 @@ timestamp: 2026-07-21T18:45:54Z | `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 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; persists to `sessionStorage`, exposes `restoreSession()`, `waitUntilHydrated()`, `login()`, `register()` (both with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`), `setSession()`. | +| `frontend/src/app/app.routes.ts` | Angular route table (`bootstrap`, `login`, shell `/` with child routes `challenges`/`scoreboard`/`blog`/`admin`, wildcard `**`). | +| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()`, `waitUntilHydrated()`, `login()`, `register()`, `logout()`, `me()`, `changePassword()` (all with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`/`ChangePasswordResult`), `setSession()`. | | `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/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes a `passwordPolicy` computed signal derived from the bootstrap payload. | +| `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window (`state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, derived `countdownText`); `start(createSource)`/`stop()` manage the SSE connection + 1-second tick. | +| `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers used by the store and tests: `deriveCountdownText`, `formatDdHhMm`, `EventSourceLike`, `EventState`/`EventStatePayload`. | +| `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (`user`, `loading`, `error`, derived `rankText`); `hydrateFromAuth()` + `loadMe()` (calls `AuthService.me()`). | | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. | | `frontend/src/app/core/services/markdown.service.ts` | Thin Angular `MarkdownService.render(md): string` that forwards to `renderMarkdownToHtml`. The DOMPurify sanitization happens inside `renderMarkdownToHtml` so Angular's `[innerHTML]` binding receives a sanitized plain string — no `DomSanitizer.bypassSecurityTrustHtml` wrapper is required. | | `frontend/src/app/core/services/markdown.pure.ts` | Pure `renderMarkdownToHtml` helper using `marked` + `DOMPurify` (kept in its own file so it can be unit-tested without Angular's ESM-only runtime and so the sanitization boundary is shared between production and tests). | @@ -134,14 +138,22 @@ timestamp: 2026-07-21T18:45:54Z | `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. | | `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. | | `frontend/src/app/features/setup/setup-create-admin.field-errors.ts` | Pure `usernameError` / `passwordError` / `confirmError` helpers that turn reactive-form state into the inline validation messages rendered next to each input. | -| `frontend/src/app/features/home/home.component.ts` | Authenticated shell: header + conditional admin nav + ``. | -| `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` predicate (unit-tested). | +| `frontend/src/app/features/home/home.component.ts` | Smart authenticated shell: renders `ShellHeaderComponent`, `QuickTabsComponent`, `ChangePasswordModalComponent`, and ``. Owns the SSE lifecycle (`EventStatusStore.start`/`stop`), the `UserStore.loadMe()` call, and the open/close signals for the change-password modal. | +| `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` and `deriveActiveSectionFromUrl(url)` predicates (unit-tested). | +| `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb header: page title, active-section label, LED (`led-{state}` class), countdown text, user menu trigger, inline user menu dropdown with rank / change-password / admin / logout entries. | +| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb `Challenges / Scoreboard / Blog` tab strip; `tabs`/`active` inputs, `tabChange` output. | +| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal (mode `'self'|'admin-reset'`, `passwordMatchValidator`, policy hint, `errorMessage`/`submitting` inputs). Emits `submit({old,new,confirm})` and `cancel()`. | +| `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` group validator + `PasswordPolicy` zod schema. | +| `frontend/src/app/features/shell/change-password/password-feedback.ts` | Pure `formatChangePasswordError({code, message})` helper that maps the API code to user-facing copy. | +| `frontend/src/app/features/challenges/challenges.page.ts` | Placeholder `/challenges` page. | +| `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Placeholder `/scoreboard` page. | +| `frontend/src/app/features/blog/blog.page.ts` | Placeholder `/blog` page. | # Tests | File | Responsibility | |------------------------------------------------|--------------------------------------------------------------------------------| -| `tests/backend/*.spec.ts` (18 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits. | +| `tests/backend/*.spec.ts` (~25 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits, the new `/me` + `/change-password` + authenticated SSE flows, and `UsersRankService`. | | `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. | @@ -152,6 +164,14 @@ timestamp: 2026-07-21T18:45:54Z | `tests/frontend/landing-markdown.spec.ts` | Pins `renderMarkdownToHtml` (sanitization of raw `