AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password #14
+104
-16
@@ -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": "<uuid>",
|
||||
"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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+93
-25
@@ -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)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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 `<router-outlet>` 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 `<router-outlet>` 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 `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart authenticated shell. Hosts the `ShellHeaderComponent`, `QuickTabsComponent`, the `ChangePasswordModalComponent`, and the `<router-outlet>` 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: <r> - Points: <p>"`). |
|
||||
| `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 `<router-outlet>`.
|
||||
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: <r> - Points: <p>`,
|
||||
`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 `<router-outlet>` 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)
|
||||
|
||||
@@ -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 + `<router-outlet>`. |
|
||||
| `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 `<router-outlet>`. 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 `<script>` payloads, script URL schemes, etc.). |
|
||||
| `tests/frontend/landing-welcome.spec.ts` | Regression test for the landing welcome binding — asserts the bootstrap payload's `welcomeMarkdown` produces the expected sanitized HTML (h1 + paragraph) and never the Angular runtime helper source string, including for null/empty/hostile payloads. |
|
||||
| `tests/frontend/landing-modal.spec.ts` | Pins `buildLoginFailureMessage` (`INVALID_CREDENTIALS`, `RATE_LIMITED` with retry-seconds extraction, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`, default fallback). |
|
||||
| `tests/frontend/event-status.store.spec.ts` | Pins `EventStatusStore.applyServerStatus` (state + delta anchor) and `deriveCountdownText` (`countdown` / `running` / `stopped` / `unconfigured`). |
|
||||
| `tests/frontend/change-password-modal.spec.ts` | Pins the dumb modal's reactive form (validation, submit/cancel emission, error rendering). |
|
||||
| `tests/frontend/shell-active-section.spec.ts` | Pins `deriveActiveSectionFromUrl` for the four URL branches. |
|
||||
| `tests/backend/change-password.spec.ts` | Integration coverage for `/api/v1/auth/change-password` (wrong-old / mismatched / weak / unauth branches). |
|
||||
| `tests/backend/event-status-state.spec.ts` | Pins the 4-state derivation in `EventStatusService.getState()`. |
|
||||
| `tests/backend/events-status-sse.spec.ts` | Pins the authenticated SSE stream (401 + initial `EventStatePayload`). |
|
||||
| `tests/backend/me-endpoint.spec.ts` | Pins `/api/v1/auth/me` (401 + payload shape including rank/points). |
|
||||
| `tests/backend/rank-points.spec.ts` | Pure unit test for `UsersRankService.rankOfUser()`. |
|
||||
| `tests/backend/auth-register.spec.ts` | Backend integration tests for `POST /api/v1/auth/register` (happy path, disabled flag, duplicate username, weak password, rate limit). |
|
||||
| `tests/backend/blog-public.spec.ts` | Backend integration tests for `GET /api/v1/blog/posts` (only published rows, descending order). |
|
||||
| `tests/backend/csrf-protected-routes.spec.ts` | Pins CSRF enforcement on the formerly-skipped `/api/v1/auth/login` route. |
|
||||
|
||||
@@ -3,7 +3,7 @@ type: guide
|
||||
title: Admin Shell & User Management
|
||||
description: How an authenticated admin navigates the post-login shell and reaches the admin user-management area.
|
||||
tags: [guide, admin, shell, navigation, tester]
|
||||
timestamp: 2026-07-21T15:05:00Z
|
||||
timestamp: 2026-07-21T22:19:08Z
|
||||
---
|
||||
|
||||
# When this view is available
|
||||
@@ -96,5 +96,7 @@ renders.
|
||||
|
||||
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md) — header / LED / quick tabs / change-password modal / user menu (the surrounding shell this page renders into).
|
||||
- [Change Password](/guides/change-password.md)
|
||||
- [REST API Overview](/api/rest-overview.md)
|
||||
- [Admin Endpoints](/api/admin.md)
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
type: guide
|
||||
title: Authenticated Shell
|
||||
description: How a signed-in user experiences the post-login shell — header, LED + countdown, quick tabs, user menu, and change-password modal.
|
||||
tags: [guide, shell, header, sse, navigation, tester]
|
||||
timestamp: 2026-07-21T22:19:08Z
|
||||
---
|
||||
|
||||
# When this view is available
|
||||
|
||||
The authenticated shell is the post-login UI rendered by
|
||||
`HomeComponent` (`frontend/src/app/features/home/home.component.ts`).
|
||||
It is gated by:
|
||||
|
||||
| Layer | File | Check |
|
||||
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
|
||||
| Client route | `frontend/src/app/app.routes.ts` | `''` uses `authGuard`; child routes inherit it. |
|
||||
| Server route | `backend/src/modules/system/system.controller.ts` (`@Sse('events/status')`) | JWT-required (global `JwtAuthGuard`). 401 on missing/expired token. |
|
||||
|
||||
A user who is not signed in (or whose session has expired) is sent to
|
||||
`/login` by `authGuard` before the shell ever mounts; the SSE stream
|
||||
will not be opened until a valid JWT is present.
|
||||
|
||||
# How to access (tester steps)
|
||||
|
||||
1. Ensure the instance is initialized
|
||||
(`GET /api/v1/bootstrap` → `initialized: true`). If not, follow
|
||||
[First-Run Bootstrap](/guides/bootstrap.md).
|
||||
2. Sign in via `/login` (admin or player).
|
||||
3. The browser lands on `/` (which redirects to `/challenges`) and
|
||||
renders the full **authenticated shell**.
|
||||
|
||||
# Expected behavior
|
||||
|
||||
## Header (`ShellHeaderComponent`)
|
||||
|
||||
| Region / element | Selector | Expected |
|
||||
|------------------------------|------------------------------------------|---------------------------------------------------------------------------|
|
||||
| Page title (clickable) | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle` (default `HIPCTF`). Clicking it navigates to `/challenges`. |
|
||||
| Active section label | `[data-testid="shell-active-section"]` | `Challenges` / `Scoreboard` / `Blog` / `Admin` based on the URL (`deriveActiveSectionFromUrl`). |
|
||||
| Event status LED | `[data-testid="shell-led"]` | One of `led-running`, `led-countdown`, `led-stopped`, `led-unconfigured` classes. `aria-label="Event status: <state>"`. |
|
||||
| Countdown text | `[data-testid="shell-countdown"]` | `DD:HH:mm` while `countdown` / `running`, `"Event ended"` while `stopped`, empty while `unconfigured`. Updates every second. |
|
||||
| User menu trigger | `[data-testid="user-menu-trigger"]` | Shows the current username + `▾`. Toggles the inline dropdown. |
|
||||
| User menu — rank entry | `[data-testid="user-menu-rank"]` | `"Rank: <r> - Points: <p>"` (or empty while rank unknown / 0 points). Clicking navigates to `/scoreboard`. |
|
||||
| User menu — change password | `[data-testid="user-menu-change-password"]` | Opens the [change-password modal](/guides/change-password.md). |
|
||||
| User menu — admin area | `[data-testid="user-menu-admin"]` | Visible only when `canAccessAdmin()` is true (admin role). Navigates to `/admin`. |
|
||||
| User menu — logout | `[data-testid="user-menu-logout"]` | Logs out and navigates to `/login`. See the [auth.md logout flow](/api/auth.md). |
|
||||
|
||||
## Quick tabs (`QuickTabsComponent`)
|
||||
|
||||
| Region / element | Selector | Expected |
|
||||
|-------------------------|--------------------------------|----------------------------------------------------------------|
|
||||
| Tab strip | `[data-testid="quick-tabs"]` | Three buttons in order: `Challenges`, `Scoreboard`, `Blog`. |
|
||||
| Each tab button | `[data-testid="quick-tab-<id>"]` | `class.active` matches the current `active()` tab. |
|
||||
| Tab click | emits `tabChange: {id}` | Parent navigates to `/{id}` (or `/admin` when `id === 'admin'`). |
|
||||
|
||||
The default child route is `'' → challenges`, so visiting `/` lands on
|
||||
the Challenges page with the `Challenges` tab highlighted.
|
||||
|
||||
## Body
|
||||
|
||||
| Region | Notes |
|
||||
|-------------------------|-------------------------------------------------------------------------|
|
||||
| `<router-outlet>` | Renders the active child route (`ChallengesPage`, `ScoreboardPage`, `BlogPage`, `AdminUsersComponent`). |
|
||||
|
||||
## Live event window
|
||||
|
||||
1. On `ngOnInit` `HomeComponent` opens
|
||||
`new EventSource('/api/v1/events/status', { withCredentials: true })`
|
||||
via `EventStatusStore.start(...)`.
|
||||
2. The first frame is the current state; subsequent frames arrive on
|
||||
transitions and on the server's 60-second tick.
|
||||
3. The store runs a local 1-second tick so the displayed countdown
|
||||
advances between server pushes, using the stored clock-skew anchor.
|
||||
4. On `ngOnDestroy` (and the store's `DestroyRef.onDestroy`) the source
|
||||
is closed and the tick is cleared.
|
||||
|
||||
If the user logs out, the next refresh tick or route change tears the
|
||||
SSE down. If the SSE errors out, the store simply stops applying
|
||||
frames; the header LED retains its last class until a new connect
|
||||
attempt lands a frame.
|
||||
|
||||
# Visual elements
|
||||
|
||||
| Element | Selector | Purpose |
|
||||
|----------------------------------|---------------------------------------|-------------------------------------------------------------------------|
|
||||
| Shell header bar | `[data-testid="shell-header"]` | Holds the title, active section, LED + countdown, and user menu. |
|
||||
| Tab strip | `[data-testid="quick-tabs"]` | Top-level navigation between the three main pages. |
|
||||
| Shell body | `.shell-body` | Hosts `<router-outlet>` for child routes. |
|
||||
|
||||
# Architecture map
|
||||
|
||||
| Step | Where | What happens |
|
||||
|------|----------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
|
||||
| 1 | `frontend/src/app/app.routes.ts` | `/` is a parent route with `authGuard`; child routes `challenges`/`scoreboard`/`blog`/`admin` (admin uses `adminGuard`). |
|
||||
| 2 | `frontend/src/app/core/guards/auth.guard.ts` | `authGuard` waits on bootstrap + auth hydration and delegates to `decideAuthRedirect`. |
|
||||
| 3 | `frontend/src/app/features/home/home.component.ts` | Smart shell: instantiates `EventStatusStore`, `UserStore`, `AuthService`; manages the SSE lifecycle. |
|
||||
| 4 | `frontend/src/app/core/services/event-status.store.ts` | `start(createSource)` opens the SSE connection, parses each `message` frame into `applyServerStatus()`, and ticks `secondsToStart`/`secondsToEnd` locally. |
|
||||
| 5 | `frontend/src/app/core/services/user.store.ts` | `loadMe()` calls `AuthService.me()` (`GET /api/v1/auth/me`) → `UsersRankService.rankOfUser` for `rank` + `points`. |
|
||||
| 6 | `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb presentational header rendering title, active section, LED, countdown, and user menu. |
|
||||
| 7 | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb tab strip. Click emits `tabChange`; parent navigates. |
|
||||
| 8 | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal opened via the `changePasswordClick` output. See [Change Password Guide](/guides/change-password.md). |
|
||||
| 9 | `backend/src/modules/auth/auth.controller.ts` | `GET /api/v1/auth/me` returns `{id, username, role, rank, points}`. |
|
||||
| 10 | `backend/src/modules/users/users-rank.service.ts` | `rankOfUser(manager, userId)` → `{rank, points}` over the `solve` table. |
|
||||
| 11 | `backend/src/modules/system/system.controller.ts` | `GET /api/v1/events/status` (JWT) → SSE emitting the full `EventStatePayload`. |
|
||||
|
||||
# Tester flows
|
||||
|
||||
## Live event window
|
||||
|
||||
1. Configure `eventStartUtc` so the event is currently in `countdown`:
|
||||
the LED should show the countdown colour, the countdown text should
|
||||
read `00:00:NN` and decrement each second.
|
||||
2. Move `eventStartUtc` to a past timestamp and `eventEndUtc` to a
|
||||
future timestamp. The LED switches to the `running` class and the
|
||||
countdown now reads `DD:HH:mm` to end.
|
||||
3. Move `eventEndUtc` to a past timestamp. The LED switches to the
|
||||
`stopped` class and the countdown text reads `Event ended`.
|
||||
4. Clear one of the two `setting` rows. The LED switches to
|
||||
`unconfigured` and the countdown text is empty.
|
||||
|
||||
## Change-password modal (happy path)
|
||||
|
||||
1. Sign in as a player (or admin).
|
||||
2. Open the user menu and click **Change password**.
|
||||
3. Enter the current password, a new password that meets the policy
|
||||
(length + mixed-case), and the same new password in confirmation.
|
||||
4. Click **OK** → the modal closes. Other tabs that were signed in to
|
||||
the same account will be forced to `/login` because all refresh
|
||||
tokens for the user were revoked server-side.
|
||||
5. See [Change Password Guide](/guides/change-password.md) for the
|
||||
error branches (wrong-old / mismatched / weak / unauth).
|
||||
|
||||
## Admin-only entries
|
||||
|
||||
1. Sign in as an admin and confirm the **Admin area** entry appears in
|
||||
the user menu. Clicking it navigates to `/admin` and renders the
|
||||
existing `AdminUsersComponent` inside the shell body.
|
||||
2. Sign in as a player and confirm the **Admin area** entry is hidden.
|
||||
Navigating directly to `/admin` is intercepted by `adminGuard` and
|
||||
redirects back to `/`.
|
||||
|
||||
# Notes
|
||||
|
||||
* The shell deliberately keeps `HomeComponent` as a smart container
|
||||
with dumb children (`ShellHeaderComponent`, `QuickTabsComponent`,
|
||||
`ChangePasswordModalComponent`) so they can be unit-tested in
|
||||
isolation.
|
||||
* The active-section predicate (`deriveActiveSectionFromUrl`) and the
|
||||
admin-nav predicate (`shouldShowAdminNav`) live in
|
||||
`frontend/src/app/features/home/home.shell.ts` and are exported so
|
||||
they can be unit-tested without Angular DI. See
|
||||
`tests/frontend/shell-active-section.spec.ts` and
|
||||
`tests/frontend/admin-shell.spec.ts`.
|
||||
* The change-password modal renders `mode='self'` from the shell; the
|
||||
`mode='admin-reset'` variant (where `oldPassword` is hidden) is wired
|
||||
by the future admin-reset Job.
|
||||
|
||||
# See also
|
||||
|
||||
- [Admin Shell & User Management](/guides/admin-shell.md)
|
||||
- [Change Password](/guides/change-password.md)
|
||||
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||
- [Auth Endpoints](/api/auth.md)
|
||||
- [System Endpoints](/api/system.md)
|
||||
- [Event Window](/guides/event-window.md)
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
type: guide
|
||||
title: Change Password
|
||||
description: How an authenticated user changes their own password from the shell, and what each error branch looks like.
|
||||
tags: [guide, change-password, auth, tester]
|
||||
timestamp: 2026-07-21T22:19:08Z
|
||||
---
|
||||
|
||||
# When this view is available
|
||||
|
||||
The change-password modal is part of the [authenticated shell](/guides/authenticated-shell.md).
|
||||
It is opened from the user menu (trigger `data-testid="user-menu-trigger"`
|
||||
→ entry `data-testid="user-menu-change-password"`).
|
||||
|
||||
| Layer | File | Check |
|
||||
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
|
||||
| Client route | `frontend/src/app/app.routes.ts` | The shell (`/`) uses `authGuard`. |
|
||||
| Client modal | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Rendered inside the shell by `HomeComponent`. |
|
||||
| Server route | `backend/src/modules/auth/auth.controller.ts` | `POST /api/v1/auth/change-password` — JWT-protected, CSRF-enforced. |
|
||||
|
||||
# How to access (tester steps)
|
||||
|
||||
1. Sign in via `/login`.
|
||||
2. Click the username dropdown in the shell header (`data-testid="user-menu-trigger"`).
|
||||
3. Click **Change password** (`data-testid="user-menu-change-password"`).
|
||||
4. The modal overlay appears (`data-testid="change-password-overlay"`) with the modal card (`data-testid="change-password-modal"`).
|
||||
|
||||
# Expected behavior
|
||||
|
||||
## Inputs (mode = `self`)
|
||||
|
||||
| Field | Selector | Constraints |
|
||||
|------------------------|---------------------------|------------------------------------------------------------------------------|
|
||||
| Old password | `[data-testid="cp-old"]` | Required when `mode === 'self'`. Hidden in `mode === 'admin-reset'` (future Job). |
|
||||
| New password | `[data-testid="cp-new"]` | Required. Must satisfy the server-side `validatePassword` (length + mixed-case per `PASSWORD_MIN_LENGTH` / `PASSWORD_REQUIRE_MIXED`). |
|
||||
| Confirm new password | `[data-testid="cp-confirm"]` | Required. Must equal `newPassword` (group-level `passwordMatchValidator`). |
|
||||
| Policy hint | `[data-testid="cp-policy"]` | Renders `passwordPolicy.description` from the bootstrap payload (e.g. `"At least 12 characters with upper, lower, digit and symbol"`). |
|
||||
| Cancel button | `[data-testid="cp-cancel"]` | Disabled while submitting. Emits `cancel`. |
|
||||
| OK button | `[data-testid="cp-ok"]` | Disabled while submitting. Submits the form. |
|
||||
|
||||
The modal closes on Escape (`@HostListener('document:keydown.escape')`)
|
||||
and on overlay click (the card stops propagation). Cancel is blocked
|
||||
while `submitting()` is true.
|
||||
|
||||
## Inline validation (client-side)
|
||||
|
||||
| State | Selector | Message |
|
||||
|---------------------------------------------|----------------------------------|----------------------------------|
|
||||
| `newPassword` and `confirmNewPassword` differ | `[data-testid="cp-form-error"]` | `Passwords do not match` |
|
||||
| API error returned | `[data-testid="cp-error"]` | Mapped via `formatChangePasswordError()` (see below). |
|
||||
|
||||
## Successful response
|
||||
|
||||
| Outcome | Visible behaviour |
|
||||
|--------------------------------------------|------------------------------------------------------------------------|
|
||||
| HTTP 204 | Modal closes (`changePasswordOpen.set(false)`), user stays signed in on this tab. All other refresh tokens for the user are revoked server-side — other tabs will be forced to `/login` on their next guard tick. |
|
||||
|
||||
## Error envelope
|
||||
|
||||
The frontend maps each error code to user-facing copy via
|
||||
`formatChangePasswordError({code, message})` in
|
||||
`frontend/src/app/features/shell/change-password/password-feedback.ts`:
|
||||
|
||||
| HTTP | `code` | Modal message |
|
||||
|------|---------------------------|--------------------------------------------------------------|
|
||||
| 400 | `PASSWORDS_DO_NOT_MATCH` | "New password and confirmation do not match" |
|
||||
| 400 | `PASSWORD_POLICY` | The server's policy text (or "New password does not meet the policy requirements" if empty). |
|
||||
| 400 | `VALIDATION_FAILED` | The server's policy text (fallback "Failed to change password"). |
|
||||
| 401 | `INVALID_OLD_PASSWORD` | "Old password is incorrect" |
|
||||
| 401 | `UNAUTHORIZED` | "You must be signed in to change your password" |
|
||||
| other| (anything else) | Server `message` (or fallback "Failed to change password"). |
|
||||
|
||||
# Visual elements
|
||||
|
||||
| Element | Selector | Purpose |
|
||||
|----------------------------------|-------------------------------------------|--------------------------------------------------------|
|
||||
| Modal overlay | `[data-testid="change-password-overlay"]` | Backdrop; clicking it cancels. |
|
||||
| Modal card | `[data-testid="change-password-modal"]` | Stops propagation so card clicks don't cancel. |
|
||||
| Form error (match) | `[data-testid="cp-form-error"]` | Inline error from `passwordMatchValidator`. |
|
||||
| Server error | `[data-testid="cp-error"]` | Inline error from `formatChangePasswordError()`. |
|
||||
|
||||
# Architecture map
|
||||
|
||||
| Step | Where | What happens |
|
||||
|------|-----------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||
| 1 | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb modal with reactive form, `passwordMatchValidator`, policy hint, `errorMessage`/`submitting` inputs. |
|
||||
| 2 | `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` group validator + `PasswordPolicy` zod schema. |
|
||||
| 3 | `frontend/src/app/features/shell/change-password/password-feedback.ts` | `formatChangePasswordError({code, message})` — pure helper unit-tested in `tests/frontend/change-password-modal.spec.ts`. |
|
||||
| 4 | `frontend/src/app/features/home/home.component.ts` | Smart shell: owns `changePasswordOpen`/`changePasswordSubmitting`/`changePasswordError` signals; on `submit(payload)` calls `AuthService.changePassword()`. |
|
||||
| 5 | `frontend/src/app/core/services/auth.service.ts` | `changePassword(dto)` runs `ensureCsrf()` then `POST /api/v1/auth/change-password` with `withCredentials: true`; returns `ChangePasswordResult`. |
|
||||
| 6 | `backend/src/modules/auth/dto/auth.dto.ts` | `ChangePasswordDtoSchema` (zod) — `confirmNewPassword` refines to `=== newPassword`. |
|
||||
| 7 | `backend/src/modules/auth/auth.service.ts` | `changePassword(userId, dto)` (transaction): mismatch check → `argon2.verify(old)` → `validatePassword(new)` → re-hash + UPDATE `refresh_token SET revokedAt = now WHERE userId = ? AND revokedAt IS NULL`. |
|
||||
| 8 | `backend/src/common/utils/password-policy.ts` | `validatePassword(password, config)` enforces `PASSWORD_MIN_LENGTH` and (when enabled) the mixed-case requirement. |
|
||||
|
||||
# Tester flows
|
||||
|
||||
## Happy path
|
||||
|
||||
1. Sign in. Open the user menu → **Change password**.
|
||||
2. Enter the current password, a new password meeting the policy, and
|
||||
the same new password in confirmation.
|
||||
3. Click **OK**. The modal closes; the user remains signed in.
|
||||
4. Sign-in from another browser session using the same account — the
|
||||
new password works; the previous password no longer works.
|
||||
|
||||
## Wrong old password
|
||||
|
||||
1. Sign in, open the modal.
|
||||
2. Enter an incorrect "Old password", a valid "New password", and the
|
||||
same new password in confirmation.
|
||||
3. Click **OK**. The modal stays open and the inline error reads
|
||||
"Old password is incorrect". No DB write happens.
|
||||
|
||||
## Mismatched confirmation
|
||||
|
||||
1. Sign in, open the modal.
|
||||
2. Enter the current password, a valid new password, and a different
|
||||
confirmation. The `cp-form-error` element appears immediately with
|
||||
"Passwords do not match" (the OK button stays disabled until the
|
||||
values match because the reactive form is invalid).
|
||||
3. Fix the confirmation. The error disappears.
|
||||
|
||||
## Weak new password
|
||||
|
||||
1. Sign in, open the modal.
|
||||
2. Enter the current password and a too-short new password (e.g.
|
||||
`"abc"`). The modal submits; the server returns 400 with
|
||||
`code=PASSWORD_POLICY`; the inline error reads the server-supplied
|
||||
policy message.
|
||||
|
||||
## Unauthenticated
|
||||
|
||||
1. Manually clear the session token in DevTools and submit the modal.
|
||||
The server returns 401 `UNAUTHORIZED`; the inline error reads
|
||||
"You must be signed in to change your password". The frontend
|
||||
then has no usable session, so the next guard tick (e.g. clicking
|
||||
a tab) sends the user to `/login`.
|
||||
|
||||
# Notes
|
||||
|
||||
* The change-password modal always runs in `mode='self'` from the
|
||||
shell. The `mode='admin-reset'` input (where the `oldPassword` field
|
||||
is hidden) is supported by the modal component and will be wired by
|
||||
the admin-reset Job.
|
||||
* The server revokes **all** refresh tokens for the user on success,
|
||||
so other devices must re-authenticate. The local `sessionStorage`
|
||||
session on this tab is preserved because the response is 204 (no new
|
||||
tokens are issued).
|
||||
|
||||
# See also
|
||||
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||
- [Auth Endpoints](/api/auth.md) (`GET /api/v1/auth/me` + `POST /api/v1/auth/change-password`)
|
||||
- [Auth and Settings Tables](/database/auth-settings.md) (`refresh_token` revocation)
|
||||
- [REST API Overview](/api/rest-overview.md) (error envelope + CSRF)
|
||||
+71
-18
@@ -1,9 +1,9 @@
|
||||
---
|
||||
type: guide
|
||||
title: Event Window
|
||||
description: How the live event countdown is computed and surfaced via REST + SSE.
|
||||
tags: [guide, event, countdown, sse]
|
||||
timestamp: 2026-07-21T18:28:00Z
|
||||
description: How the event window state machine is computed and surfaced via REST + SSE.
|
||||
tags: [guide, event, countdown, sse, state-machine]
|
||||
timestamp: 2026-07-21T22:19:08Z
|
||||
---
|
||||
|
||||
# Configuration
|
||||
@@ -13,33 +13,86 @@ Two `setting` rows control the window:
|
||||
| Setting key | Default (seeded) | Description |
|
||||
|-------------------|------------------|--------------------------------------------|
|
||||
| `eventStartUtc` | `now + 60s` | ISO 8601 instant the event becomes live. |
|
||||
| `eventEndUtc` | `now + 7d` | ISO 8601 instant the event ends. |
|
||||
| `eventEndUtc` | `now + 7d` | ISO 8601 instant the event ends. |
|
||||
|
||||
Both are seeded by `SeedSystemData1700000000100` and editable through
|
||||
`SettingsService`.
|
||||
|
||||
# State computation
|
||||
# State machine
|
||||
|
||||
`EventStatusService.getStatus(now)` (`backend/src/common/services/event-status.service.ts`)
|
||||
returns one of three branches:
|
||||
`EventStatusService.getState(now)`
|
||||
(`backend/src/common/services/event-status.service.ts`) returns one of
|
||||
four branches:
|
||||
|
||||
| Condition | `status` | `countdownMs` |
|
||||
|-------------------------|----------|----------------------|
|
||||
| `now < eventStartUtc` | `Stopped` (countdown) | `eventStartUtc - now` |
|
||||
| `now ∈ [start, end]` | `Running` | `eventEndUtc - now` |
|
||||
| `now >= eventEndUtc` | `Stopped` (ended) | `0` |
|
||||
| Condition | `state` | `secondsToStart` | `secondsToEnd` |
|
||||
|----------------------------------------|------------------|--------------------------|--------------------------|
|
||||
| `eventStartUtc` / `eventEndUtc` missing or unparseable | `unconfigured` | `null` | `null` |
|
||||
| `now < eventStartUtc` | `countdown` | `floor((start - now)/s)` | `null` |
|
||||
| `start <= now <= end` | `running` | `null` | `floor((end - now)/s)` |
|
||||
| `now > eventEndUtc` | `stopped` | `null` | `null` |
|
||||
|
||||
The legacy `EventStatusService.getStatus(now)` shape
|
||||
(`{status: 'Stopped'|'Running', countdownMs, startUtc, endUtc, serverNowUtc}`)
|
||||
is preserved for backward compatibility. `Stopped` is overloaded to
|
||||
mean "still counting down" (`countdownMs = eventStartUtc - now`) when
|
||||
`state === 'countdown'`, and "ended" (`countdownMs = 0`) when
|
||||
`state === 'stopped'` / `unconfigured`.
|
||||
|
||||
# Endpoints
|
||||
|
||||
* `GET /api/v1/event/status` — one-shot read (see
|
||||
[System Endpoints](/api/system.md)).
|
||||
* `GET /api/v1/event/stream` — SSE stream that pushes the same payload
|
||||
whenever `SseHubService.publish()` is called (typically by an admin
|
||||
cron tick). The SPA subscribes to keep the countdown ticking without
|
||||
polling.
|
||||
| Endpoint | Auth | Shape |
|
||||
|--------------------------------------------|-------------|-------------------------------------------|
|
||||
| `GET /api/v1/event/status` | `@Public()` | Legacy `{status, countdownMs, startUtc, endUtc, serverNowUtc}`. |
|
||||
| `GET /api/v1/event/stream` | `@Public()` | SSE that emits the legacy payload on a 1-second tick + on hub pushes. |
|
||||
| `GET /api/v1/settings/event` | `@Public()` | Raw `{eventStartUtc, eventEndUtc}` (null when unset). |
|
||||
| `GET /api/v1/events/status` | Authenticated (JWT) | SSE that emits the full `EventStatePayload` on connect + on a 60-second tick + on hub pushes; consecutive identical payloads suppressed via `distinctUntilChanged(JSON.stringify)`. |
|
||||
|
||||
See [System Endpoints](/api/system.md) for the full request/response
|
||||
shape.
|
||||
|
||||
# Frontend wiring
|
||||
|
||||
The authenticated shell subscribes to `/api/v1/events/status` from
|
||||
`HomeComponent.ngOnInit()` via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`
|
||||
(`frontend/src/app/features/home/home.component.ts`,
|
||||
`frontend/src/app/core/services/event-status.store.ts`). The store:
|
||||
|
||||
1. Calls `applyServerStatus(payload)` on every `message` frame (parses
|
||||
JSON, sets `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`,
|
||||
and stores the clock-skew anchor `Date.now() - serverNow`).
|
||||
2. Runs a local 1-second tick so the `secondsToStart` / `secondsToEnd`
|
||||
computed signals update between SSE pushes.
|
||||
3. Exposes `countdownText` (computed): `""` for `unconfigured`,
|
||||
`"Event ended"` for `stopped`, and `formatDdHhMm(seconds)` for
|
||||
`countdown` / `running`. The format helper lives in the pure module
|
||||
`event-status.pure.ts` (`DD:HH:mm`, zero-padded).
|
||||
|
||||
The header (`ShellHeaderComponent`) renders an LED with the
|
||||
`led-{state}` class so the colour reflects the current state (the
|
||||
exact theme colours are wired in `styles.css` against the existing
|
||||
`--color-success` / `--color-warning` / `--color-danger` tokens).
|
||||
|
||||
`HomeComponent.ngOnDestroy()` (and the `DestroyRef` hook in the store
|
||||
itself) close the SSE source and clear the tick interval, so leaving
|
||||
the shell (e.g. by logging out and landing on `/login`) tears the
|
||||
stream down cleanly.
|
||||
|
||||
# Push path
|
||||
|
||||
1. The admin cron / settings update writes to `setting.eventStartUtc` /
|
||||
`setting.eventEndUtc`.
|
||||
2. `SseHubService.publish('event', payload)` fans the new payload out
|
||||
to every subscriber of `/api/v1/event/stream` and (via the
|
||||
`flat()` mapping) `/api/v1/events/status`.
|
||||
3. Each connected client receives the new state immediately; otherwise
|
||||
it picks up the next 60-second tick.
|
||||
|
||||
The hub is in-process; multi-replica deployments need a shared pub/sub
|
||||
to fan out across pods (not in scope for this repo).
|
||||
|
||||
# See also
|
||||
|
||||
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||
- [System Endpoints](/api/system.md)
|
||||
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||
|
||||
+16
-8
@@ -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. Last regenerated 2026-07-21T21:18:00Z.
|
||||
they need. Last regenerated 2026-07-21T22:19:08Z.
|
||||
|
||||
# Architecture
|
||||
|
||||
@@ -20,7 +20,8 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
|
||||
* [Backend Module Map](/architecture/backend-modules.md) - NestJS modules,
|
||||
controllers, services, and their wiring.
|
||||
* [Frontend Structure](/architecture/frontend-structure.md) - Angular routes,
|
||||
components, services, and guards.
|
||||
components, services, guards, and stores (including the authenticated shell
|
||||
+ signal stores).
|
||||
* [Key Files Index](/architecture/key-files.md) - One-line summary of every
|
||||
important source file in the repository.
|
||||
|
||||
@@ -38,14 +39,15 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
|
||||
|
||||
* [REST API Overview](/api/rest-overview.md) - Base URL, versioning, auth,
|
||||
CSRF, and error envelope.
|
||||
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and
|
||||
first-admin registration.
|
||||
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, `/me`,
|
||||
`/change-password`, and first-admin registration.
|
||||
* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin`
|
||||
used only while no administrator exists.
|
||||
* [Admin Endpoints](/api/admin.md) - User management endpoints
|
||||
(admin role required).
|
||||
* [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE
|
||||
streams.
|
||||
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
|
||||
streams, public event-window settings, and the authenticated
|
||||
`/events/status` SSE stream.
|
||||
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads.
|
||||
|
||||
# Guides
|
||||
@@ -57,6 +59,12 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
|
||||
messages on the first-admin modal.
|
||||
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins
|
||||
navigate the post-login shell and reach the user-management area.
|
||||
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
|
||||
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
|
||||
every signed-in page.
|
||||
* [Change Password](/guides/change-password.md) - How a signed-in user
|
||||
changes their own password, including every error branch and the
|
||||
refresh-token revocation behavior.
|
||||
* [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
|
||||
@@ -65,7 +73,7 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
|
||||
(logo, welcome Markdown, blog list) and the login + registration modal
|
||||
rendered at `/login` once the instance is initialized.
|
||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||
countdown work.
|
||||
* [Event Window](/guides/event-window.md) - How the 4-state event
|
||||
window and live countdown work, including the authenticated SSE stream.
|
||||
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
|
||||
broadcast to clients.
|
||||
|
||||
Reference in New Issue
Block a user