AI Implementation feature(857): Authenticated Shell: Header, Quick Tabs and Change Password (#14)
This commit was merged in pull request #14.
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
# Implementation Plan: Job 857 — Authenticated Shell (Header, Quick Tabs, Change Password, SSE-driven LED/Countdown)
|
||||
|
||||
## Status: IN PROGRESS — Build error to fix
|
||||
|
||||
Most of the implementation has been applied (backend service extensions, new endpoints, frontend stores, dumb components, smart `HomeComponent`, and tests). The Angular production build now fails with a discriminated-union narrowing error in `home.component.ts:185`. See **Section 6** for the exact, surgical fix required.
|
||||
|
||||
---
|
||||
|
||||
## 0. Already-Implemented Check
|
||||
|
||||
A scan of the current repository shows that Job 857 is **NOT** fully implemented:
|
||||
|
||||
* `frontend/src/app/features/home/home.component.ts` already renders a minimal header (page title + "Signed in as" line + admin nav link) but it does **not** include the LED, countdown, quick-jump tab menu, user dropdown, or change-password modal.
|
||||
* `frontend/src/app/app.routes.ts` has only `/`, `/login`, `/bootstrap`, `/admin`, plus the `/` shell child route for admin. There are no `/challenges`, `/scoreboard`, `/blog` child routes under the protected shell.
|
||||
* `backend/src/modules/auth/auth.controller.ts` exposes no `GET /api/v1/me` and no `POST /api/v1/auth/change-password` endpoint.
|
||||
* `backend/src/modules/system/system.controller.ts` exposes `GET /api/v1/event/stream` (Public), but no authenticated `GET /api/v1/events/status` (plural) protected-by-auth SSE.
|
||||
* `backend/src/modules/settings/*` does not yet expose `GET /api/v1/settings/event` (a public-readable event-window projection).
|
||||
* No `ChangePasswordModal` component exists; no `UserMenu` dropdown exists; no `QuickTabs` (Challenges/Scoreboard/Blog) component exists; no `EventStatusStore` (signals) exists.
|
||||
* `AuthService` has no `changePassword()` method.
|
||||
|
||||
No part of the new Job is fully done, so the plan proceeds with full implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
### Codebase style & conventions
|
||||
* **Backend**: NestJS 10 + TypeORM + better-sqlite3 (WAL). Modules use `@Module({...})`, providers are dependency-injected, services live in `backend/src/modules/<feature>/<feature>.service.ts`. Validation uses `ZodValidationPipe` (zod schemas in `dto/*.dto.ts`). A global `APP_GUARD = JwtAuthGuard` enforces auth by default; routes are opted-out with `@Public()`. CSRF is enforced on unsafe methods via `CsrfMiddleware` mounted in `main.ts`; `@SkipCsrf()` opts out. Errors are standardized via `ApiError` + `ERROR_CODES` and normalized by `GlobalExceptionFilter`.
|
||||
* **Frontend**: Angular 17.3 standalone components, `provideHttpClient(withInterceptors([...]))`, Angular Signals, `OnPush` change detection, `@if`/`@for` control flow. Smart/dumb component split. State lives in services using `signal()`/`computed()` (e.g. `AuthService`, `BootstrapService`). Lazy loading via `loadComponent`. Functional `CanActivateFn` route guards. Cookie-based auth (HttpOnly refresh cookie + Bearer access token in sessionStorage); HTTP requests use `withCredentials: true`.
|
||||
* **Tests**: Jest with two projects (`backend`, `frontend`) in `/repo/tests/jest.config.js`. Tests live in `/repo/tests/backend/**/*.spec.ts` and `/repo/tests/frontend/**/*.spec.ts`. Run via `npm test` from repo root. Backend tests typically bootstrap `AppModule` with `Test.createTestingModule({imports:[AppModule]})` against in-memory SQLite (`process.env.DATABASE_PATH=':memory:'`). Frontend tests use `jsdom` with `jest.setup.ts`.
|
||||
|
||||
### Data Layer
|
||||
* TypeORM + better-sqlite3, `user`, `refresh_token`, `setting`, `solve`, `challenge`, `category`, `challenge_file`, `blog_post` tables.
|
||||
* `setting` rows (`eventStartUtc`, `eventEndUtc`) are already seeded; `EventStatusService` derives `Stopped`/`Running` from `Date.now()` vs those ISO instants, returning `{status, startUtc, endUtc, serverNowUtc, countdownMs}` (all UTC).
|
||||
* No schema migrations are required for Job 857. The existing `solve` table already gives `pointsAwarded` (sum gives total points) and a derived rank is computed as `1 + count(user solves ranking above this user)` from the existing `solve` table.
|
||||
|
||||
### Test Framework & Structure
|
||||
* **Jest** (workspaces: backend + frontend). Single command: `npm test` (wired through root `package.json`).
|
||||
* Backend specs are Nest `INestApplication` integration tests OR plain `new EventStatusService({...})` unit tests; one helper exists (`tests/backend/db-helper.ts`) for test seeding.
|
||||
* Frontend specs use Angular `TestBed`, pure decision-function unit tests live alongside `.decision.ts` files, signal-store specs are pure logic assertions.
|
||||
|
||||
### Required Tools & Dependencies
|
||||
* **No new packages required.** All features rely on already-installed deps: NestJS `@Sse()`/RxJS, Angular Signals (`signal`, `computed`, `effect`), Angular Forms (`ReactiveFormsModule`), `argon2`, `passport-jwt`, `class-validator`-style zod. The existing `EventStatusService`, `SseHubService`, `JwtAuthGuard`, `CsrfMiddleware`, `@Public()`, `@Roles()` and `SettingsService` are reused. `setup.sh` does not need changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
### Backend — To Modify
|
||||
* `backend/src/modules/auth/auth.controller.ts` — add `GET /api/v1/me`, `POST /api/v1/auth/change-password`; remove `@Public()` on `POST /api/v1/auth/logout` so it becomes authenticated (the frontend already sends `Authorization` once logged in).
|
||||
* `backend/src/modules/auth/auth.service.ts` — add `getMe(userId) -> {id, username, role, rank, points, passwordPolicy}` (rank + points computed over `solve` table); add `changePassword(userId, oldPassword, newPassword, confirm)` that verifies old with `argon2.verify`, validates new with `validatePassword` + zod refinement, and persists a new Argon2id hash inside a transaction. The existing `logout()` already revokes the refresh token; only the controller decoration changes.
|
||||
* `backend/src/modules/auth/dto/auth.dto.ts` — add `ChangePasswordDtoSchema` (zod) with `oldPassword`, `newPassword` (with policy guards), `confirmNewPassword` refinement (`=== newPassword`).
|
||||
* `backend/src/modules/system/system.controller.ts` — add `GET /api/v1/events/status` SSE endpoint (NOT `@Public()`, authenticated) that emits `EventStatus` via the existing `EventStatusService` + `SseHubService`; add `GET /api/v1/settings/event` (`@Public()`) returning `{eventStartUtc, eventEndUtc}`.
|
||||
* `backend/src/common/services/event-status.service.ts` — extend interface to add an `unconfigured` branch (when `eventStartUtc`/`eventEndUtc` settings are missing/invalid) and a 4-state `state` field with `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, `secondsToStart`, `secondsToEnd` to match the Job's required payload shape. Backward-compatible: existing `status: 'Stopped'|'Running'` callers keep working because `Stopped` is overloaded for both countdown and ended.
|
||||
* `backend/src/common/errors/error-codes.ts` — add `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`.
|
||||
* `backend/src/app.module.ts` — none required; the new pieces sit inside `AuthModule`/`SystemModule`.
|
||||
|
||||
### Backend — To Create
|
||||
* `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. Registered in `UsersModule`.
|
||||
|
||||
### Frontend — To Modify
|
||||
* `frontend/src/app/app.routes.ts` — keep `/`, `/bootstrap`, `/login`. Inside the `''` shell route add lazy `loadChildren` for `challenges/`, `scoreboard/`, `blog/` placeholder pages.
|
||||
* `frontend/src/app/core/services/auth.service.ts` — add `changePassword({oldPassword, newPassword, confirmNewPassword})` and `logout()`; add `me()` (`GET /api/v1/me`) and extend `CurrentUser` with optional `rank`/`points`.
|
||||
* `frontend/src/app/features/home/home.component.ts` — convert from the existing inline header into a **smart shell container**.
|
||||
|
||||
### Frontend — To Create
|
||||
* `frontend/src/app/core/services/event-status.store.ts` — signals store.
|
||||
* `frontend/src/app/core/services/user.store.ts` — signals store for the authenticated user.
|
||||
* `frontend/src/app/features/shell/header/shell-header.component.ts` — **dumb** presentational component.
|
||||
* `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` — **dumb** presentational component.
|
||||
* `frontend/src/app/features/shell/user-menu/user-menu.component.ts` — **dumb** dropdown component.
|
||||
* `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` — **dumb** modal.
|
||||
* `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` — pure `passwordMatchValidator`.
|
||||
* `frontend/src/app/features/shell/change-password/password-feedback.ts` — pure `formatChangePasswordError()` helper.
|
||||
* `frontend/src/app/features/challenges/challenges.page.ts` — stub placeholder page.
|
||||
* `frontend/src/app/features/scoreboard/scoreboard.page.ts` — stub placeholder page.
|
||||
* `frontend/src/app/features/blog/blog.page.ts` — stub placeholder page.
|
||||
|
||||
### Test Files — To Create
|
||||
* `tests/backend/event-status-state.spec.ts` — 4-state derivation coverage.
|
||||
* `tests/backend/change-password.spec.ts` — integration: wrong-old / mismatched / weak / unauth.
|
||||
* `tests/backend/events-status-sse.spec.ts` — SSE initial payload + 401.
|
||||
* `tests/backend/me-endpoint.spec.ts` — `/api/v1/auth/me` shape + 401.
|
||||
* `tests/backend/rank-points.spec.ts` — pure unit test for `rankOfUser()`.
|
||||
* `tests/frontend/event-status.store.spec.ts` — pure store unit test (DD:HH:mm formatting + SSE message apply).
|
||||
* `tests/frontend/quick-tabs.spec.ts` — pure render test for the dumb quick-tabs component.
|
||||
* `tests/frontend/change-password-modal.spec.ts` — pure render test for the dumb modal.
|
||||
* `tests/frontend/shell-active-section.spec.ts` — pure helper unit test for `deriveActiveSectionFromUrl`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### 3.1 Database / Schema Migration
|
||||
No DB changes. Reuses existing `user`, `setting`, `solve`, `refresh_token` rows.
|
||||
|
||||
### 3.2 Backend Logic & APIs
|
||||
|
||||
#### a. Extend `EventStatusService`
|
||||
File: `backend/src/common/services/event-status.service.ts`
|
||||
* New `getState(now)` returning `EventStatePayload = {state: 'countdown'|'running'|'stopped'|'unconfigured', serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}`.
|
||||
* `getStatus(now)` is preserved for backward compatibility and computes its legacy `status`/`countdownMs` from the new `state` (`Stopped` for countdown, `Running` for running, `Stopped` with `0` for stopped/unconfigured).
|
||||
|
||||
#### b. Add `GET /api/v1/me`
|
||||
File: `backend/src/modules/auth/auth.controller.ts`
|
||||
* Authenticated (no `@Public()`). Returns `{id, username, role, rank, points}`.
|
||||
* `rank`/`points` computed via `UsersRankService`.
|
||||
|
||||
#### c. Add `POST /api/v1/auth/change-password`
|
||||
* Authenticated, CSRF enforced.
|
||||
* Body via `ZodValidationPipe(ChangePasswordDtoSchema)`.
|
||||
* Returns 204 on success, 400 `PASSWORDS_DO_NOT_MATCH` / 400 `PASSWORD_POLICY` / 401 `INVALID_OLD_PASSWORD`.
|
||||
|
||||
#### d. Make `POST /api/v1/auth/logout` authenticated
|
||||
* Remove `@Public()` on the `logout()` method.
|
||||
|
||||
#### e. `AuthService.changePassword()` method
|
||||
File: `backend/src/modules/auth/auth.service.ts`
|
||||
* `changePassword(userId, dto)`:
|
||||
1. Validate `newPassword === confirmNewPassword`.
|
||||
2. Reject when `oldPassword === newPassword` with `PASSWORD_POLICY`.
|
||||
3. Verify old via `argon2.verify`.
|
||||
4. Validate new via `validatePassword()` (rethrow with `PASSWORD_POLICY` code).
|
||||
5. Re-hash with Argon2id and persist.
|
||||
6. Revoke all existing refresh tokens for the user.
|
||||
|
||||
#### f. Add public `GET /api/v1/settings/event`
|
||||
File: `backend/src/modules/system/system.controller.ts`
|
||||
* `@Public()` endpoint returning `{eventStartUtc, eventEndUtc}`.
|
||||
|
||||
#### g. Add authenticated `GET /api/v1/events/status` SSE
|
||||
* NOT `@Public()`.
|
||||
* Emits initial state immediately on connect, plus 60s tick and hub pushes.
|
||||
* Each `data` frame: `{state, serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}`.
|
||||
|
||||
### 3.3 Frontend UI Integration
|
||||
|
||||
#### a. Routes
|
||||
File: `frontend/src/app/app.routes.ts`
|
||||
* Keep `bootstrap`, `login`, `**` fallback.
|
||||
* Shell `''` route gains lazy children: `'' → challenges`, `scoreboard`, `blog`, `admin` (already admin-gated).
|
||||
* Default child redirects to `/challenges`.
|
||||
|
||||
#### b. `AuthService` additions
|
||||
File: `frontend/src/app/core/services/auth.service.ts`
|
||||
* `logout()`, `me()`, `changePassword(dto)` with discriminated `ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure`.
|
||||
* Extend `CurrentUser` with optional `rank?: number | null` and `points?: number`.
|
||||
|
||||
#### c. `EventStatusStore`
|
||||
File: `frontend/src/app/core/services/event-status.store.ts`
|
||||
* Signals `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, private `anchorDeltaMs`.
|
||||
* `computed countdownText` formats as `DD:HH:mm` (`"Event ended"` while stopped; `""` while unconfigured).
|
||||
* `applyServerStatus(payload)`, `start(createSource)`, `stop()` lifecycle. Cleanup via `inject(DestroyRef)`.
|
||||
|
||||
#### d. `UserStore`
|
||||
File: `frontend/src/app/core/services/user.store.ts`
|
||||
* Signals `user`, `loading`, `error`. `computed rankText` formats `"Rank: <r> - Points: <p>"`.
|
||||
* `loadMe()` calls `AuthService.me()`.
|
||||
|
||||
#### e. `HomeComponent` smart container
|
||||
File: `frontend/src/app/features/home/home.component.ts`
|
||||
* Injects `EventStatusStore`, `UserStore`, `AuthService`, `Router`.
|
||||
* Subscribes to `NavigationEnd` and exposes `currentUrl` signal → `activeSection = computed(deriveActiveSectionFromUrl(currentUrl))`.
|
||||
* `ngOnInit()` calls `UserStore.loadMe()` and starts the SSE consumer via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`.
|
||||
* `ngOnDestroy()` calls `EventStatusStore.stop()`.
|
||||
|
||||
#### f. Dumb components
|
||||
* `ShellHeaderComponent` — signal inputs, outputs; LED via host bindings; user menu inline.
|
||||
* `QuickTabsComponent` — signal inputs `tabs`/`active`, `tabChange` output.
|
||||
* `ChangePasswordModalComponent` — three reactive controls with `passwordMatchValidator`; emits `submit({oldPassword,newPassword,confirmNewPassword})` and `cancel`; mode `'self'|'admin-reset'` input.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
### Target unit test files
|
||||
* **Backend** (`tests/backend/`):
|
||||
* `event-status-state.spec.ts` — covers countdown / running / stopped / unconfigured.
|
||||
* `change-password.spec.ts` — covers wrong-old / mismatched / weak / unauth (HTTP surface).
|
||||
* `events-status-sse.spec.ts` — covers 401 + initial payload shape.
|
||||
* `me-endpoint.spec.ts` — covers 401 + payload shape.
|
||||
* `rank-points.spec.ts` — covers points sum + 1-based rank.
|
||||
* **Frontend** (`tests/frontend/`):
|
||||
* `event-status.store.spec.ts` — DD:HH:mm formatting + SSE message apply.
|
||||
* `change-password-modal.spec.ts` — form validation, submit/cancel emission, error rendering.
|
||||
* `quick-tabs.spec.ts` — active class + tabChange emission.
|
||||
* `shell-active-section.spec.ts` — pure helper unit test.
|
||||
|
||||
### Mocking Strategy
|
||||
* **Backend SSE**: same supertest `.buffer(true).parse(...)` pattern already used by `tests/backend/sse-flatten.spec.ts`. Auth via `Authorization: Bearer <token>`.
|
||||
* **Backend DB**: in-memory SQLite + `initDb(app)`.
|
||||
* **Frontend HTTP**: `TestBed.configureTestingModule({ imports: [...] })` + `setInput()` for inputs. No HTTP needed for these pure-logic specs.
|
||||
* **Frontend SSE consumer**: a `FakeEventSource` implementing `EventSourceLike`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-cutting Notes
|
||||
|
||||
* **Reuse over reinvention**: The Job's "single source of truth for state derivation" maps cleanly to the existing `EventStatusService`. We **extend** it (do not bypass it) and surface the SSE through `SseHubService` so the existing 1-second tick (and any future scheduler) drives both the public `event/stream` AND the new authenticated `events/status`.
|
||||
* **UX-only route guards** + **backend enforcement** are already true on `/` (existing `authGuard` + global `JwtAuthGuard`). All new routes inherit the same.
|
||||
* **Cookie auth**: No tokens in localStorage. The change-password endpoint uses the same HttpOnly refresh cookie + Bearer access token pattern as `login`/`register`. Frontend uses `withCredentials: true` on every call.
|
||||
* **Error codes**: `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH` are added to `ERROR_CODES`; mapped to stable HTTP statuses (`401` / `400` / `400`).
|
||||
* **Persistence**: No new data on `/data` is required for this job. The `/data/hipctf` directory created by `setup.sh` continues to host the SQLite DB; settings live inside the DB.
|
||||
* **Conformance to skills**: Standalone components, OnPush, signal inputs/outputs, dumb component split, lazy-loaded routes, functional `CanActivateFn` — all already enforced on existing code and applied to new code. SSE side: backend uses standard `@Sse()` returning `Observable<MessageEvent>` and respects existing JWT guard semantics. Argon2id is reused; no bcrypt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Build-time Fix (Discovered During Implementation)
|
||||
|
||||
### 6.1 Problem
|
||||
|
||||
The Angular production build (`npm --workspace frontend run build`) failed with:
|
||||
|
||||
```
|
||||
TS2339: Property 'code' does not exist on type 'ChangePasswordResult'.
|
||||
Property 'code' does not exist on type 'ChangePasswordSuccess'.
|
||||
|
||||
src/app/features/home/home.component.ts:185:74
|
||||
...gePasswordError({ code: result.code, message: result.message }));
|
||||
|
||||
TS2339: Property 'message' does not exist on type 'ChangePasswordResult'.
|
||||
Property 'message' does not exist on type 'ChangePasswordSuccess'.
|
||||
|
||||
src/app/features/home/home.component.ts:185:96
|
||||
...gePasswordError({ code: result.code, message: result.message }));
|
||||
```
|
||||
|
||||
### 6.2 Root cause
|
||||
|
||||
`AuthService.changePassword()` returns `Promise<ChangePasswordResult>` where:
|
||||
|
||||
```ts
|
||||
export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure;
|
||||
export interface ChangePasswordSuccess { ok: true }
|
||||
export interface ChangePasswordFailure { ok: false; status: number; code: string; message: string }
|
||||
```
|
||||
|
||||
In `submitChangePassword()` (currently line 185) we wrote:
|
||||
|
||||
```ts
|
||||
const result = await this.auth.changePassword(payload);
|
||||
this.changePasswordSubmitting.set(false);
|
||||
if (result.ok) {
|
||||
this.changePasswordOpen.set(false);
|
||||
return;
|
||||
}
|
||||
this.changePasswordError.set(formatChangePasswordError({ code: result.code, message: result.message }));
|
||||
```
|
||||
|
||||
After the `if (result.ok) { return; }` block the compiler should narrow `result` to `ChangePasswordFailure`, but the call to `this.changePasswordSubmitting.set(false)` between the `await` and the `if` is a non-trivial statement (it invokes a method on an injected object), and Angular's strict template + TypeScript settings do not propagate the narrowing through that call in this configuration. `result` therefore remains typed as the full `ChangePasswordResult` union at line 185.
|
||||
|
||||
### 6.3 Fix (surgical)
|
||||
|
||||
In `frontend/src/app/features/home/home.component.ts`, narrow explicitly by binding the failure branch to a typed local:
|
||||
|
||||
```ts
|
||||
async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise<void> {
|
||||
this.changePasswordSubmitting.set(true);
|
||||
this.changePasswordError.set(null);
|
||||
const result: ChangePasswordResult = await this.auth.changePassword(payload);
|
||||
this.changePasswordSubmitting.set(false);
|
||||
if (result.ok) {
|
||||
this.changePasswordOpen.set(false);
|
||||
return;
|
||||
}
|
||||
const failure: ChangePasswordFailure = result;
|
||||
this.changePasswordError.set(
|
||||
formatChangePasswordError({ code: failure.code, message: failure.message }),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This requires:
|
||||
1. Adding `ChangePasswordFailure` to the existing `import { ChangePasswordModalComponent, ChangePasswordSubmitPayload } from '../shell/change-password/change-password-modal.component';` line — change to:
|
||||
```ts
|
||||
import { ChangePasswordModalComponent, ChangePasswordSubmitPayload } from '../shell/change-password/change-password-modal.component';
|
||||
import { ChangePasswordResult, ChangePasswordFailure } from '../../core/services/auth.service';
|
||||
```
|
||||
2. Using the typed local `failure` for the error mapping call.
|
||||
|
||||
No other source files need changes for the build to succeed.
|
||||
|
||||
### 6.4 Why this is the only remaining build error
|
||||
* The backend files compile cleanly (`tsc` was exercised during test setup).
|
||||
* All other frontend files compile cleanly (template syntax, signal imports, standalone declarations, OnPush, route lazy-loading).
|
||||
* The existing `tests/backend/event-status.spec.ts` continues to pass because `getStatus()` still returns its legacy `{status, startUtc, endUtc, serverNowUtc, countdownMs}` shape (`status` is preserved for backward compat, mirroring the new `state` field).
|
||||
|
||||
### 6.5 Re-verify after the fix
|
||||
Run:
|
||||
```bash
|
||||
cd /repo
|
||||
npm --workspace frontend run build
|
||||
npm test
|
||||
```
|
||||
Expected: clean Angular production build + all backend and frontend tests green.
|
||||
|
||||
### 6.6 No test changes required for this build fix
|
||||
The backend `tests/backend/change-password.spec.ts` integration test already exercises every error branch (`PASSWORDS_DO_NOT_MATCH`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `401`) via the public HTTP surface, so no test changes are needed to validate the backend. The frontend discriminated-union issue is purely a TypeScript-level concern resolved by explicit narrowing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out-of-scope (for this Job)
|
||||
|
||||
* Admin-initiated password reset UI in the Players admin section (`admin-reset` mode). The modal already accepts the `mode` input and would only render `oldPassword` for `'self'`; the admin-side wiring belongs to a future Job.
|
||||
* Theming of the LED colors. The header uses existing `--color-success` / `--color-warning` / `--color-danger` tokens applied via `BootstrapService.applyTheme`.
|
||||
* Authoring full Challenges / Scoreboard / Blog pages. Placeholder pages exist so the Quick Tabs navigate to real routes; richer content belongs to their respective Jobs.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Implementation Plan: Landing Page and Login/Register Modal 1.01
|
||||
|
||||
## Status
|
||||
|
||||
This job is **not fully implemented**. The repository already contains the landing page, login/register modal, public registration endpoint, and a nominal ten-per-minute limiter, but the reported end-to-end behavior demonstrates an off-by-one/account-count defect that must be isolated and corrected.
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
- **Codebase style & conventions:** Node.js npm-workspace monorepo using TypeScript, NestJS 10, Angular 17 standalone components, async service methods, Angular signals, reactive forms, Zod DTO validation, and Jest. Controllers are thin and delegate business logic to services; the Angular landing component owns modal/form state while `AuthService` owns HTTP calls and session setup.
|
||||
- **Data Layer:** TypeORM over `better-sqlite3`; the `user` table has a unique username and stores player/admin accounts. Multi-step registration/session creation runs in a `DataSource.transaction`. No schema migration is expected because rate-limit state is currently in-memory and no user schema change is needed.
|
||||
- **Relevant existing flow:** `POST /api/v1/auth/register` in `backend/src/modules/auth/auth.controller.ts` extracts the request IP and calls `AuthService.registerPlayer`. `AuthService.registerPlayer` checks `RegistrationRateLimitService`, checks `registrationsEnabled`, checks username uniqueness, hashes with Argon2id, inserts a player, records a hit, and mints a session. `RegistrationRateLimitService` currently allows fewer than ten retained timestamps. The Angular `/login` route renders `LandingComponent`, whose registration modal calls `AuthService.register`, signs the user in on success, and maps `RATE_LIMITED` errors through `buildLoginFailureMessage`.
|
||||
- **Test Framework & Structure:** Root `npm test` runs Jest projects defined in `tests/jest.config.js`; backend tests run in `tests/backend/`, frontend logic tests run in `tests/frontend/`, and tests are not colocated with source. Existing registration integration coverage is in `tests/backend/auth-register.spec.ts`; pure limiter coverage is in `tests/backend/registration-rate-limit.spec.ts`; frontend error-message coverage is in `tests/frontend/landing-modal.spec.ts`.
|
||||
- **Required Tools & Dependencies:** No new dependency or system tool is indicated. Existing Node/npm, NestJS, Angular, Jest, TypeORM, SQLite, Supertest, cookie-parser, cookiejar, and Argon2 setup are sufficient. Do not add Redis or another external rate-limit store for this single-process requirement. `setup.sh` already installs dependencies and builds both workspaces; it should remain unchanged unless implementation introduces a dependency, in which case update the lockfile and setup installation path rather than adding runtime infrastructure unnecessarily. Persistent `/data` storage is not needed because limiter state is intentionally ephemeral and tests use `DATABASE_PATH=:memory:`.
|
||||
|
||||
## 2. Impacted Files
|
||||
- **To Modify:**
|
||||
- `backend/src/common/services/registration-rate-limit.service.ts` — make the fixed-window boundary and/or reservation semantics explicit so exactly ten successful registrations are admitted and the eleventh is rejected.
|
||||
- `backend/src/modules/auth/auth.service.ts` — adjust the registration admission/recording sequence if investigation confirms concurrent requests can pass separate `isAllowed` checks before either records, or otherwise centralize an atomic consume operation.
|
||||
- `tests/backend/registration-rate-limit.spec.ts` — add a focused regression test for the exact ten-success/eleventh-rejection/rollover contract, preferably at the service level and with deterministic timestamps.
|
||||
- `tests/backend/auth-register.spec.ts` — strengthen the existing public registration integration assertion to verify that the rejected request does not create a user and that a request after the window succeeds, without creating a large test matrix.
|
||||
- **To Create:** None expected. Keep tests in the existing dedicated `tests/backend/` folder.
|
||||
|
||||
## 3. Proposed Changes
|
||||
Explain the exact implementation logic step-by-step:
|
||||
1. **Database / Schema Migration:** No migration. Preserve the `user` table and existing unique username constraint. The fix must ensure a rejected registration fails before user insertion; successful registrations remain player rows with `role='player'` and `status='enabled'`.
|
||||
2. **Backend Logic & APIs:**
|
||||
1. Reproduce the reported sequence using deterministic limiter time and the same IP representation used by the controller (`req.ip`, commonly `::ffff:127.0.0.1`). Distinguish a true limiter boundary bug from test contamination or a race between `isAllowed()` and `record()`.
|
||||
2. Replace the externally split check-then-record path with an atomic “consume/admit” operation, or add equivalent synchronization inside `RegistrationRateLimitService`. The operation must prune timestamps older than the one-minute window, admit exactly when the retained count is below ten, and reserve/record the timestamp as part of the same operation. A blocked call must not append a timestamp.
|
||||
3. Define the rollover boundary consistently: timestamps with age at least 60,000 ms are expired, so the first request after the minute rolls over is admitted. Use an injectable/deterministic `now` argument in the pure service path to avoid sleeping in unit tests; production calls use `Date.now()`.
|
||||
4. Call the atomic admission operation at the correct point in `AuthService.registerPlayer`. Preserve existing validation, registration-enabled, username-conflict, Argon2id, transaction, response, CSRF, and refresh-cookie behavior. Decide and document whether failed validation/disabled/duplicate attempts count based on the existing intended semantics; the job’s acceptance criterion concerns successful account creation, so avoid charging the window for requests that cannot create an account unless repository requirements explicitly require otherwise.
|
||||
5. Check the legacy first-admin/setup callers because they share `RegistrationRateLimitService`. Preserve their protection and ensure the API error remains `429 RATE_LIMITED`; use the existing public-registration message exactly: `Too many registration attempts; please wait a minute before trying again.`
|
||||
6. Do not change the landing-page route or modal unless backend regression analysis exposes a client contract issue. The existing `LandingComponent` already switches login/register modes, calls `AuthService.register`, establishes the session, and routes to `/`; the existing error helper handles the registration rate-limit response as a generic wait message because the server text contains no numeric seconds.
|
||||
3. **Frontend UI Integration:** No frontend code change is expected for this server-side off-by-one defect. If implementation changes the API error payload to include a numeric retry duration, update `login-modal.service.ts` and its focused test only to preserve the documented user-facing message; otherwise leave the already implemented landing page and modal intact.
|
||||
|
||||
## 4. Test Strategy
|
||||
- **Target Unit Test File:** `tests/backend/registration-rate-limit.spec.ts` for deterministic limiter behavior: admit exactly ten calls, reject the eleventh without increasing the retained count, isolate IPs, and admit after a timestamp reaches the one-minute expiry boundary. Keep the existing service tests minimal and extend rather than creating a new test hierarchy.
|
||||
- **Target Integration Test File:** `tests/backend/auth-register.spec.ts` for one focused public API regression: enable registrations, issue ten unique valid registrations from one agent/IP, assert each returns `201`, submit the eleventh with a unique username and assert `429 RATE_LIMITED`, query the repository or count users to prove no eleventh account exists, then invoke the limiter with deterministic time or use a narrowly controlled clock/expiry strategy to verify the next request after rollover succeeds. Avoid real 31/60-second sleeps.
|
||||
- **Frontend Test File:** No new frontend test unless the error contract changes. Existing `tests/frontend/landing-modal.spec.ts` already covers the registration rate-limit mapping and should remain green.
|
||||
- **Mocking Strategy:** Unit-test the limiter without mocks. For backend integration, use the existing Nest `AppModule`, in-memory SQLite initialization via `initDb`, real TypeORM persistence, CSRF cookie priming, and Supertest as already established; mock only time if required by the implementation, using Jest fake timers or an injected clock rather than mocking repositories. Ensure app/database resources are closed and avoid shared limiter state between tests by using a fresh service/app or clearing the service state through its public lifecycle/design. All assertions should be CLI-verifiable with the existing root `npm test` command.
|
||||
- **Acceptance assertions:** There are exactly ten successful `201` registrations in the one-minute window; the eleventh returns HTTP 429 with code `RATE_LIMITED` and the required message; the rejected username is absent from `user`; the first request after the one-minute window succeeds with `201`; and no unrelated landing/login behavior regresses.
|
||||
@@ -14,6 +14,9 @@ export const ERROR_CODES = {
|
||||
TOKEN_REVOKED: 'TOKEN_REVOKED',
|
||||
THEME_INVALID: 'THEME_INVALID',
|
||||
REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED',
|
||||
INVALID_OLD_PASSWORD: 'INVALID_OLD_PASSWORD',
|
||||
PASSWORD_POLICY: 'PASSWORD_POLICY',
|
||||
PASSWORDS_DO_NOT_MATCH: 'PASSWORDS_DO_NOT_MATCH',
|
||||
INTERNAL: 'INTERNAL',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SettingsService } from '../../modules/settings/settings.module';
|
||||
|
||||
export type EventStatusName = 'Stopped' | 'Running';
|
||||
export type EventState = 'countdown' | 'running' | 'stopped' | 'unconfigured';
|
||||
|
||||
export interface EventStatus {
|
||||
status: EventStatusName;
|
||||
@@ -11,38 +12,94 @@ export interface EventStatus {
|
||||
countdownMs: number;
|
||||
}
|
||||
|
||||
export interface EventStatePayload {
|
||||
state: EventState;
|
||||
serverNowUtc: string;
|
||||
eventStartUtc: string | null;
|
||||
eventEndUtc: string | null;
|
||||
secondsToStart: number | null;
|
||||
secondsToEnd: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EventStatusService {
|
||||
constructor(private readonly settings: SettingsService) {}
|
||||
|
||||
async getStatus(now: Date = new Date()): Promise<EventStatus> {
|
||||
const startStr = await this.settings.get('eventStartUtc');
|
||||
const endStr = await this.settings.get('eventEndUtc');
|
||||
const start = new Date(startStr);
|
||||
const end = new Date(endStr);
|
||||
const nowMs = now.getTime();
|
||||
const startMs = start.getTime();
|
||||
const endMs = end.getTime();
|
||||
|
||||
const s = await this.getState(now);
|
||||
let status: EventStatusName = 'Stopped';
|
||||
let countdownMs = 0;
|
||||
if (nowMs < startMs) {
|
||||
if (s.state === 'countdown' && s.secondsToStart != null) {
|
||||
status = 'Stopped';
|
||||
countdownMs = startMs - nowMs;
|
||||
} else if (nowMs <= endMs) {
|
||||
countdownMs = s.secondsToStart * 1000;
|
||||
} else if (s.state === 'running' && s.secondsToEnd != null) {
|
||||
status = 'Running';
|
||||
countdownMs = endMs - nowMs;
|
||||
countdownMs = s.secondsToEnd * 1000;
|
||||
} else if (s.state === 'stopped') {
|
||||
status = 'Stopped';
|
||||
countdownMs = 0;
|
||||
} else {
|
||||
status = 'Stopped';
|
||||
countdownMs = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
startUtc: start.toISOString(),
|
||||
endUtc: end.toISOString(),
|
||||
serverNowUtc: now.toISOString(),
|
||||
startUtc: s.eventStartUtc ?? '',
|
||||
endUtc: s.eventEndUtc ?? '',
|
||||
serverNowUtc: s.serverNowUtc,
|
||||
countdownMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getState(now: Date = new Date()): Promise<EventStatePayload> {
|
||||
const startStr = await this.settings.get('eventStartUtc');
|
||||
const endStr = await this.settings.get('eventEndUtc');
|
||||
if (!startStr || !endStr) {
|
||||
return {
|
||||
state: 'unconfigured',
|
||||
serverNowUtc: now.toISOString(),
|
||||
eventStartUtc: startStr ?? null,
|
||||
eventEndUtc: endStr ?? null,
|
||||
secondsToStart: null,
|
||||
secondsToEnd: null,
|
||||
};
|
||||
}
|
||||
const start = new Date(startStr);
|
||||
const end = new Date(endStr);
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||
return {
|
||||
state: 'unconfigured',
|
||||
serverNowUtc: now.toISOString(),
|
||||
eventStartUtc: startStr,
|
||||
eventEndUtc: endStr,
|
||||
secondsToStart: null,
|
||||
secondsToEnd: null,
|
||||
};
|
||||
}
|
||||
const nowMs = now.getTime();
|
||||
const startMs = start.getTime();
|
||||
const endMs = end.getTime();
|
||||
|
||||
let state: EventState;
|
||||
let secondsToStart: number | null = null;
|
||||
let secondsToEnd: number | null = null;
|
||||
if (nowMs < startMs) {
|
||||
state = 'countdown';
|
||||
secondsToStart = Math.max(0, Math.floor((startMs - nowMs) / 1000));
|
||||
} else if (nowMs <= endMs) {
|
||||
state = 'running';
|
||||
secondsToEnd = Math.max(0, Math.floor((endMs - nowMs) / 1000));
|
||||
} else {
|
||||
state = 'stopped';
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
serverNowUtc: now.toISOString(),
|
||||
eventStartUtc: start.toISOString(),
|
||||
eventEndUtc: end.toISOString(),
|
||||
secondsToStart,
|
||||
secondsToEnd,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ import { Request, Response } from 'express';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { LoginDtoSchema, RefreshDtoSchema, RegisterDtoSchema } from './dto/auth.dto';
|
||||
import {
|
||||
ChangePasswordDtoSchema,
|
||||
LoginDtoSchema,
|
||||
RefreshDtoSchema,
|
||||
RegisterDtoSchema,
|
||||
} from './dto/auth.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CsrfMiddleware } from '../../common/middleware/csrf.middleware';
|
||||
import { setRefreshCookie, clearRefreshCookie } from './cookie';
|
||||
@@ -101,6 +106,45 @@ export class AuthController {
|
||||
clearRefreshCookie(res);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'Current authenticated user profile (id, username, role, rank, points)' })
|
||||
@ApiResponse({ status: 200, description: 'Returns the current user projection.' })
|
||||
async me(@Req() req: Request): Promise<{
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'player';
|
||||
rank: number | null;
|
||||
points: number;
|
||||
}> {
|
||||
const user = req.user as { sub: string } | undefined;
|
||||
const userId = user?.sub;
|
||||
if (!userId) {
|
||||
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
|
||||
}
|
||||
return this.auth.getMe(userId);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Change the current user password (requires old password)' })
|
||||
@ApiBody({
|
||||
schema: {
|
||||
example: { oldPassword: 'Sup3rSecret!Pass', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'NewSecret!Pass1' },
|
||||
},
|
||||
})
|
||||
async changePassword(
|
||||
@Req() req: Request,
|
||||
@Body(new ZodValidationPipe(ChangePasswordDtoSchema))
|
||||
body: { oldPassword: string; newPassword: string; confirmNewPassword: string },
|
||||
): Promise<void> {
|
||||
const user = req.user as { sub: string } | undefined;
|
||||
const userId = user?.sub;
|
||||
if (!userId) {
|
||||
throw new (await import('@nestjs/common')).UnauthorizedException('Not authenticated');
|
||||
}
|
||||
await this.auth.changePassword(userId, body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('csrf')
|
||||
@ApiOperation({ summary: 'Issue a CSRF token (sets csrf cookie, returns token)' })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
@@ -10,6 +10,7 @@ import { AuthController } from './auth.controller';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { AdminGuard } from '../../common/guards/admin.guard';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,6 +25,7 @@ import { SettingsModule } from '../settings/settings.module';
|
||||
}),
|
||||
}),
|
||||
SettingsModule,
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, AdminGuard],
|
||||
controllers: [AuthController],
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
Logger,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
@@ -16,10 +18,11 @@ import { ApiError } from '../../common/errors/api-error';
|
||||
import { ERROR_CODES } from '../../common/errors/error-codes';
|
||||
import { LoginBackoffService } from '../../common/services/login-backoff.service';
|
||||
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
|
||||
import { LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
|
||||
import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
|
||||
import { validatePassword } from '../../common/utils/password-policy';
|
||||
import { SettingsService } from '../settings/settings.module';
|
||||
import { SETTINGS_KEYS } from '../../config/env.schema';
|
||||
import { UsersRankService } from '../users/users-rank.service';
|
||||
|
||||
const REFRESH_TTL_SECONDS_DEFAULT = 7 * 24 * 60 * 60;
|
||||
|
||||
@@ -37,6 +40,7 @@ export class AuthService implements OnModuleInit {
|
||||
private readonly backoff: LoginBackoffService,
|
||||
private readonly registrationRateLimit: RegistrationRateLimitService,
|
||||
private readonly settings: SettingsService,
|
||||
private readonly rank: UsersRankService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
@@ -177,6 +181,64 @@ export class AuthService implements OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
async getMe(userId: string): Promise<{ id: string; username: string; role: 'admin' | 'player'; rank: number | null; points: number }> {
|
||||
const user = await this.users.findOne({ where: { id: userId } });
|
||||
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
|
||||
const rankInfo = await this.rank.rankOfUser(this.dataSource.manager, user.id);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
rank: rankInfo.rank,
|
||||
points: rankInfo.points,
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: string, dto: ChangePasswordDto): Promise<void> {
|
||||
if (dto.newPassword !== dto.confirmNewPassword) {
|
||||
throw new ApiError(ERROR_CODES.PASSWORDS_DO_NOT_MATCH, 'Passwords do not match', 400);
|
||||
}
|
||||
if (dto.oldPassword === dto.newPassword) {
|
||||
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, 'New password must differ from the old one', 400);
|
||||
}
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const user = await manager.findOne(UserEntity, { where: { id: userId } });
|
||||
if (!user) throw new ApiError(ERROR_CODES.NOT_FOUND, 'User not found', 404);
|
||||
|
||||
const ok = await argon2.verify(user.passwordHash, dto.oldPassword);
|
||||
if (!ok) {
|
||||
throw new ApiError(ERROR_CODES.INVALID_OLD_PASSWORD, 'Old password is incorrect', 401);
|
||||
}
|
||||
|
||||
try {
|
||||
validatePassword(dto.newPassword, this.config);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw new ApiError(ERROR_CODES.PASSWORD_POLICY, e.message, 400);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
user.passwordHash = await argon2.hash(dto.newPassword, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
|
||||
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
|
||||
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
|
||||
});
|
||||
await manager.save(user);
|
||||
|
||||
// Invalidate all existing refresh tokens for this user.
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.update(RefreshTokenEntity)
|
||||
.set({ revokedAt: new Date().toISOString() })
|
||||
.where('userId = :userId', { userId: user.id })
|
||||
.andWhere('revokedAt IS NULL')
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a fresh access JWT and a new refresh token row inside the supplied
|
||||
* transaction. The refresh token (raw) is returned alongside the access
|
||||
|
||||
@@ -37,4 +37,25 @@ export interface LoginResponse {
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
user: { id: string; username: string; role: 'admin' | 'player' };
|
||||
}
|
||||
}
|
||||
|
||||
export const ChangePasswordDtoSchema = z
|
||||
.object({
|
||||
oldPassword: z.string().min(1).max(256),
|
||||
newPassword: z.string().min(1).max(256),
|
||||
confirmNewPassword: z.string().min(1).max(256),
|
||||
})
|
||||
.refine((d) => d.newPassword === d.confirmNewPassword, {
|
||||
path: ['confirmNewPassword'],
|
||||
message: 'Passwords do not match',
|
||||
});
|
||||
export type ChangePasswordDto = z.infer<typeof ChangePasswordDtoSchema>;
|
||||
|
||||
export const MeResponseDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
role: z.enum(['admin', 'player']),
|
||||
rank: z.number().int().nullable(),
|
||||
points: z.number().int(),
|
||||
});
|
||||
export type MeResponseDto = z.infer<typeof MeResponseDtoSchema>;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Controller, Get, Sse, MessageEvent } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { interval, map, merge, mergeMap, Observable, startWith, defer } from 'rxjs';
|
||||
import { interval, map, merge, mergeMap, Observable, startWith, defer, distinctUntilChanged } from 'rxjs';
|
||||
import { Public } from '../../common/decorators/public.decorator';
|
||||
import { SystemService } from './system.service';
|
||||
import { EventStatusService } from '../../common/services/event-status.service';
|
||||
import { SseHubService } from '../../common/services/sse-hub.service';
|
||||
import { SettingsService } from '../settings/settings.module';
|
||||
|
||||
@ApiTags('system')
|
||||
@Controller('api/v1')
|
||||
@@ -13,6 +14,7 @@ export class SystemController {
|
||||
private readonly system: SystemService,
|
||||
private readonly statusSvc: EventStatusService,
|
||||
private readonly hub: SseHubService,
|
||||
private readonly settings: SettingsService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -29,6 +31,15 @@ export class SystemController {
|
||||
return this.statusSvc.getStatus();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('settings/event')
|
||||
@ApiOperation({ summary: 'Public-readable event window timestamps (UTC)' })
|
||||
async settingsEvent(): Promise<{ eventStartUtc: string | null; eventEndUtc: string | null }> {
|
||||
const eventStartUtc = (await this.settings.get('eventStartUtc')) || null;
|
||||
const eventEndUtc = (await this.settings.get('eventEndUtc')) || null;
|
||||
return { eventStartUtc, eventEndUtc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Global event status + countdown stream.
|
||||
* Emits a FLATTENED object every second (and whenever the hub pushes).
|
||||
@@ -62,6 +73,45 @@ export class SystemController {
|
||||
return merge(tick$, hub$).pipe(map((src) => ({ data: src }) as MessageEvent));
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated event status SSE.
|
||||
* Path: GET /api/v1/events/status (note the plural 'events').
|
||||
* Emits an initial state immediately on connect, then re-emits on transition
|
||||
* and on a minimum 60s tick while in 'countdown' state.
|
||||
* Protected by the global JwtAuthGuard (so unauthenticated clients get 401).
|
||||
*/
|
||||
@Sse('events/status')
|
||||
eventsStatus(): Observable<MessageEvent> {
|
||||
const flat = (s: any) => ({
|
||||
state: s?.state,
|
||||
serverNowUtc: s?.serverNowUtc,
|
||||
eventStartUtc: s?.eventStartUtc,
|
||||
eventEndUtc: s?.eventEndUtc,
|
||||
secondsToStart: s?.secondsToStart,
|
||||
secondsToEnd: s?.secondsToEnd,
|
||||
});
|
||||
|
||||
const initial$ = defer(() =>
|
||||
this.statusSvc.getState().then((s) => flat(s)),
|
||||
);
|
||||
|
||||
const tick$ = interval(60_000).pipe(
|
||||
startWith(0),
|
||||
mergeMap(() =>
|
||||
defer(() =>
|
||||
this.statusSvc.getState().then((s) => flat(s)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const hub$ = this.hub.event$().pipe(map((p) => flat(p)));
|
||||
|
||||
return merge(initial$, tick$, hub$).pipe(
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
map((src) => ({ data: src }) as MessageEvent),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live solves stream.
|
||||
* Emits a FLATTENED solve payload whenever a new solve is published.
|
||||
|
||||
@@ -4,9 +4,10 @@ import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { SettingEntity } from '../../database/entities/setting.entity';
|
||||
import { SystemController } from './system.controller';
|
||||
import { SystemService } from './system.service';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity, SettingEntity])],
|
||||
imports: [TypeOrmModule.forFeature([UserEntity, SettingEntity]), SettingsModule],
|
||||
controllers: [SystemController],
|
||||
providers: [SystemService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { SolveEntity } from '../../database/entities/solve.entity';
|
||||
|
||||
export interface RankInfo {
|
||||
rank: number | null;
|
||||
points: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersRankService {
|
||||
async rankOfUser(manager: EntityManager, userId: string): Promise<RankInfo> {
|
||||
const solveRepo = manager.getRepository(SolveEntity);
|
||||
const pointsRow = await solveRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('COALESCE(SUM(s.pointsAwarded), 0)', 'total')
|
||||
.where('s.userId = :userId', { userId })
|
||||
.getRawOne<{ total: string | number | null }>();
|
||||
const points = Number(pointsRow?.total ?? 0);
|
||||
|
||||
if (points <= 0) {
|
||||
return { rank: null, points: 0 };
|
||||
}
|
||||
|
||||
const ahead = await solveRepo
|
||||
.createQueryBuilder('s')
|
||||
.select('s.userId', 'userId')
|
||||
.addSelect('SUM(s.pointsAwarded)', 'total')
|
||||
.groupBy('s.userId')
|
||||
.having('SUM(s.pointsAwarded) > :points', { points })
|
||||
.getRawMany<{ userId: string }>();
|
||||
return { rank: ahead.length + 1, points };
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserEntity } from '../../database/entities/user.entity';
|
||||
import { SolveEntity } from '../../database/entities/solve.entity';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersRankService } from './users-rank.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule],
|
||||
providers: [UsersService],
|
||||
imports: [TypeOrmModule.forFeature([UserEntity, SolveEntity]), forwardRef(() => AuthModule)],
|
||||
providers: [UsersService, UsersRankService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
exports: [UsersService, UsersRankService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
+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.
|
||||
|
||||
@@ -22,6 +22,21 @@ export const APP_ROUTES: Routes = [
|
||||
loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent),
|
||||
canActivate: [authGuard],
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'challenges' },
|
||||
{
|
||||
path: 'challenges',
|
||||
loadComponent: () =>
|
||||
import('./features/challenges/challenges.page').then((m) => m.ChallengesPage),
|
||||
},
|
||||
{
|
||||
path: 'scoreboard',
|
||||
loadComponent: () =>
|
||||
import('./features/scoreboard/scoreboard.page').then((m) => m.ScoreboardPage),
|
||||
},
|
||||
{
|
||||
path: 'blog',
|
||||
loadComponent: () => import('./features/blog/blog.page').then((m) => m.BlogPage),
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
canActivate: [adminGuard],
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface CurrentUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'player';
|
||||
rank?: number | null;
|
||||
points?: number;
|
||||
}
|
||||
|
||||
interface RefreshResponse {
|
||||
@@ -20,6 +22,14 @@ interface RefreshResponse {
|
||||
user: CurrentUser;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'player';
|
||||
rank: number | null;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface LoginSuccess {
|
||||
ok: true;
|
||||
accessToken: string;
|
||||
@@ -52,6 +62,19 @@ export interface RegisterFailure {
|
||||
|
||||
export type RegisterResult = RegisterSuccess | RegisterFailure;
|
||||
|
||||
export interface ChangePasswordSuccess {
|
||||
ok: true;
|
||||
}
|
||||
|
||||
export interface ChangePasswordFailure {
|
||||
ok: false;
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private http: HttpClient;
|
||||
@@ -126,6 +149,49 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.ensureCsrf();
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.http.post('/api/v1/auth/logout', {}, { withCredentials: true, observe: 'response' }),
|
||||
);
|
||||
} catch {
|
||||
// ignore network/auth errors; we still clear local state
|
||||
}
|
||||
this.clear();
|
||||
}
|
||||
|
||||
async me(): Promise<MeResponse> {
|
||||
const res = await firstValueFrom(
|
||||
this.http.get<MeResponse>('/api/v1/auth/me', { withCredentials: true }),
|
||||
);
|
||||
const current = this.user();
|
||||
if (current && current.id === res.id) {
|
||||
this.user.set({ ...current, rank: res.rank, points: res.points });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async changePassword(dto: {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmNewPassword: string;
|
||||
}): Promise<ChangePasswordResult> {
|
||||
await this.ensureCsrf();
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.http.post(
|
||||
'/api/v1/auth/change-password',
|
||||
dto,
|
||||
{ withCredentials: true, observe: 'response' },
|
||||
),
|
||||
);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
return mapAuthError(e as HttpErrorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
setSession(token: string, user: CurrentUser): void {
|
||||
this.accessToken.set(token);
|
||||
this.user.set(user);
|
||||
@@ -204,4 +270,4 @@ function mapAuthError(err: HttpErrorResponse): LoginFailure {
|
||||
code: body?.code ?? 'INTERNAL',
|
||||
message: body?.message ?? err.message ?? 'Authentication failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export type EventState = 'running' | 'countdown' | 'stopped' | 'unconfigured';
|
||||
|
||||
export interface EventStatePayload {
|
||||
state: EventState;
|
||||
serverNowUtc: string;
|
||||
eventStartUtc: string | null;
|
||||
eventEndUtc: string | null;
|
||||
secondsToStart: number | null;
|
||||
secondsToEnd: number | null;
|
||||
}
|
||||
|
||||
export interface EventSourceLike {
|
||||
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function formatDdHhMm(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00';
|
||||
const s = Math.floor(totalSeconds);
|
||||
const days = Math.floor(s / 86400);
|
||||
const hours = Math.floor((s % 86400) / 3600);
|
||||
const minutes = Math.floor((s % 3600) / 60);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${pad(days)}:${pad(hours)}:${pad(minutes)}`;
|
||||
}
|
||||
|
||||
export function deriveCountdownText(
|
||||
state: EventState,
|
||||
secondsToStart: number | null,
|
||||
secondsToEnd: number | null,
|
||||
): string {
|
||||
if (state === 'unconfigured') return '';
|
||||
if (state === 'stopped') return 'Event ended';
|
||||
if (state === 'countdown') {
|
||||
return secondsToStart == null ? '00:00:00' : formatDdHhMm(secondsToStart);
|
||||
}
|
||||
return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { DestroyRef, Injectable, computed, inject, signal } from '@angular/core';
|
||||
import {
|
||||
deriveCountdownText,
|
||||
EventSourceLike,
|
||||
EventState,
|
||||
EventStatePayload,
|
||||
} from './event-status.pure';
|
||||
|
||||
export { EventState, EventStatePayload, EventSourceLike, deriveCountdownText, formatDdHhMm } from './event-status.pure';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class EventStatusStore {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly state = signal<EventState>('unconfigured');
|
||||
readonly serverNowUtc = signal<string | null>(null);
|
||||
readonly eventStartUtc = signal<string | null>(null);
|
||||
readonly eventEndUtc = signal<string | null>(null);
|
||||
private readonly anchorDeltaMs = signal<number>(0);
|
||||
private readonly tick = signal<number>(0);
|
||||
|
||||
private readonly secondsToStart = computed<number | null>(() => {
|
||||
void this.tick();
|
||||
const s = this.eventStartUtc();
|
||||
if (!s) return null;
|
||||
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
|
||||
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||
return diff > 0 ? diff : 0;
|
||||
});
|
||||
|
||||
private readonly secondsToEnd = computed<number | null>(() => {
|
||||
void this.tick();
|
||||
const s = this.eventEndUtc();
|
||||
if (!s) return null;
|
||||
const anchor = new Date(this.serverNowUtc() ?? Date.now()).getTime() + this.anchorDeltaMs();
|
||||
const diff = Math.floor((new Date(s).getTime() - anchor) / 1000);
|
||||
return diff > 0 ? diff : 0;
|
||||
});
|
||||
|
||||
readonly countdownText = computed<string>(() => {
|
||||
return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd());
|
||||
});
|
||||
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private source: EventSourceLike | null = null;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.stop());
|
||||
}
|
||||
|
||||
applyServerStatus(payload: EventStatePayload): void {
|
||||
this.state.set(payload.state);
|
||||
this.serverNowUtc.set(payload.serverNowUtc);
|
||||
this.eventStartUtc.set(payload.eventStartUtc);
|
||||
this.eventEndUtc.set(payload.eventEndUtc);
|
||||
this.anchorDeltaMs.set(Date.now() - new Date(payload.serverNowUtc).getTime());
|
||||
}
|
||||
|
||||
start(createSource: () => EventSourceLike): void {
|
||||
this.stop();
|
||||
const src = createSource();
|
||||
this.source = src;
|
||||
src.addEventListener('message', (ev) => {
|
||||
const me = ev as MessageEvent;
|
||||
try {
|
||||
const data = typeof me.data === 'string' ? JSON.parse(me.data) : me.data;
|
||||
if (data && typeof data === 'object') {
|
||||
this.applyServerStatus(data as EventStatePayload);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frame
|
||||
}
|
||||
});
|
||||
if (!this.intervalId) {
|
||||
this.intervalId = setInterval(() => this.tick.update((v) => v + 1), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.source) {
|
||||
try {
|
||||
this.source.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.source = null;
|
||||
}
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { AuthService, CurrentUser, MeResponse } from './auth.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserStore {
|
||||
private readonly auth = inject(AuthService);
|
||||
|
||||
readonly user = signal<CurrentUser | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
readonly role = computed(() => this.user()?.role);
|
||||
readonly isAdmin = computed(() => this.role() === 'admin');
|
||||
readonly rankText = computed<string>(() => {
|
||||
const u = this.user();
|
||||
if (!u) return '';
|
||||
const r = u.rank ?? '-';
|
||||
const p = u.points ?? 0;
|
||||
return `Rank: ${r} - Points: ${p}`;
|
||||
});
|
||||
|
||||
hydrateFromAuth(): void {
|
||||
this.user.set(this.auth.currentUser());
|
||||
}
|
||||
|
||||
async loadMe(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const res: MeResponse = await this.auth.me();
|
||||
const current = this.auth.currentUser();
|
||||
if (current) {
|
||||
this.user.set({ ...current, rank: res.rank, points: res.points });
|
||||
} else {
|
||||
this.user.set({
|
||||
id: res.id,
|
||||
username: res.username,
|
||||
role: res.role,
|
||||
rank: res.rank,
|
||||
points: res.points,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load user');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.user.set(null);
|
||||
this.loading.set(false);
|
||||
this.error.set(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page-blog">
|
||||
<h2>Blog</h2>
|
||||
<p>Blog posts will appear here.</p>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class BlogPage {}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-challenges-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page-challenges">
|
||||
<h2>Challenges</h2>
|
||||
<p>Challenge list will appear here.</p>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ChallengesPage {}
|
||||
@@ -1,47 +1,198 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { shouldShowAdminNav } from './home.shell';
|
||||
import { EventStatusStore } from '../../core/services/event-status.store';
|
||||
import { UserStore } from '../../core/services/user.store';
|
||||
import { ShellHeaderComponent } from '../shell/header/shell-header.component';
|
||||
import {
|
||||
ChangePasswordModalComponent,
|
||||
ChangePasswordSubmitPayload,
|
||||
} from '../shell/change-password/change-password-modal.component';
|
||||
import { ChangePasswordFailure, ChangePasswordResult } from '../../core/services/auth.service';
|
||||
import { QuickTabsComponent, ShellTab } from '../shell/tabs/quick-tabs.component';
|
||||
import { deriveActiveSectionFromUrl, shouldShowAdminNav } from './home.shell';
|
||||
import { formatChangePasswordError } from '../shell/change-password/password-feedback';
|
||||
|
||||
export { shouldShowAdminNav } from './home.shell';
|
||||
const TABS: ShellTab[] = [
|
||||
{ id: 'challenges', label: 'Challenges' },
|
||||
{ id: 'scoreboard', label: 'Scoreboard' },
|
||||
{ id: 'blog', label: 'Blog' },
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterOutlet],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterOutlet,
|
||||
ShellHeaderComponent,
|
||||
ChangePasswordModalComponent,
|
||||
QuickTabsComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="container">
|
||||
<header class="shell-header">
|
||||
<h1 class="shell-title">{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}</h1>
|
||||
<div class="shell-user" *ngIf="auth.isAuthenticated()">
|
||||
<span>Signed in as <b>{{ auth.currentUser()?.username }}</b> ({{ auth.currentUser()?.role }})</span>
|
||||
</div>
|
||||
</header>
|
||||
<app-shell-header
|
||||
[pageTitle]="bootstrap.payload()?.pageTitle ?? 'HIPCTF'"
|
||||
[activeSection]="activeSection()"
|
||||
[eventState]="eventStatus.state()"
|
||||
[countdownText]="eventStatus.countdownText()"
|
||||
[username]="userStore.user()?.username ?? ''"
|
||||
[rankText]="userStore.rankText()"
|
||||
[canAccessAdmin]="canAccessAdmin()"
|
||||
[userMenuOpen]="userMenuOpen()"
|
||||
(titleClick)="goHome()"
|
||||
(usernameMenuToggle)="toggleUserMenu()"
|
||||
(rankClick)="navigateToSection('scoreboard')"
|
||||
(changePasswordClick)="openChangePassword()"
|
||||
(adminClick)="goAdmin()"
|
||||
(logoutClick)="onLogout()"
|
||||
/>
|
||||
|
||||
<nav class="shell-nav" *ngIf="showAdminNav()">
|
||||
<a routerLink="/admin" data-testid="nav-admin">Admin</a>
|
||||
</nav>
|
||||
<app-quick-tabs
|
||||
[tabs]="TABS"
|
||||
[active]="activeTabId()"
|
||||
(tabChange)="navigateToSection($event.id)"
|
||||
/>
|
||||
|
||||
<section class="shell-body">
|
||||
<div *ngIf="!auth.isAuthenticated()">
|
||||
<p>{{ bootstrap.payload()?.welcomeMarkdown }}</p>
|
||||
<a href="/login">Sign in</a>
|
||||
</div>
|
||||
<router-outlet></router-outlet>
|
||||
</section>
|
||||
|
||||
<app-change-password-modal
|
||||
[open]="changePasswordOpen()"
|
||||
[mode]="'self'"
|
||||
[policy]="bootstrap.passwordPolicy()"
|
||||
[errorMessage]="changePasswordError()"
|
||||
[submitting]="changePasswordSubmitting()"
|
||||
(cancel)="closeChangePassword()"
|
||||
(submit)="submitChangePassword($event)"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class HomeComponent {
|
||||
bootstrap = inject(BootstrapService);
|
||||
auth = inject(AuthService);
|
||||
export class HomeComponent implements OnInit, OnDestroy {
|
||||
readonly TABS = TABS;
|
||||
readonly bootstrap = inject(BootstrapService);
|
||||
readonly auth = inject(AuthService);
|
||||
readonly eventStatus = inject(EventStatusStore);
|
||||
readonly userStore = inject(UserStore);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly showAdminNav = computed(() =>
|
||||
readonly userMenuOpen = signal(false);
|
||||
readonly changePasswordOpen = signal(false);
|
||||
readonly changePasswordSubmitting = signal(false);
|
||||
readonly changePasswordError = signal<string | null>(null);
|
||||
|
||||
private readonly currentUrl = signal<string>(this.router.url || '/');
|
||||
|
||||
readonly activeSection = computed(() => deriveActiveSectionFromUrl(this.currentUrl()));
|
||||
readonly activeTabId = computed(() => {
|
||||
const url = this.currentUrl();
|
||||
if (url.startsWith('/scoreboard')) return 'scoreboard';
|
||||
if (url.startsWith('/blog')) return 'blog';
|
||||
if (url.startsWith('/admin')) return 'admin';
|
||||
return 'challenges';
|
||||
});
|
||||
|
||||
readonly canAccessAdmin = computed(() =>
|
||||
shouldShowAdminNav({
|
||||
isAuthenticated: this.auth.isAuthenticated(),
|
||||
role: this.auth.currentUser()?.role,
|
||||
}),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
this.router.events
|
||||
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
||||
.subscribe((e) => this.currentUrl.set(e.urlAfterRedirects || e.url));
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.eventStatus.stop();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userStore.hydrateFromAuth();
|
||||
void this.userStore.loadMe();
|
||||
if (typeof window !== 'undefined' && typeof (window as any).EventSource !== 'undefined') {
|
||||
const Ctor = (window as any).EventSource as new (url: string, init?: any) => any;
|
||||
this.eventStatus.start(() => {
|
||||
return new Ctor('/api/v1/events/status', { withCredentials: true }) as any;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.eventStatus.stop();
|
||||
}
|
||||
|
||||
goHome(): void {
|
||||
void this.router.navigateByUrl('/challenges');
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
goAdmin(): void {
|
||||
void this.router.navigateByUrl('/admin');
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
toggleUserMenu(): void {
|
||||
this.userMenuOpen.update((v) => !v);
|
||||
}
|
||||
|
||||
navigateToSection(id: string): void {
|
||||
if (id === 'admin') {
|
||||
void this.router.navigateByUrl('/admin');
|
||||
} else {
|
||||
void this.router.navigateByUrl('/' + id);
|
||||
}
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
openChangePassword(): void {
|
||||
this.changePasswordError.set(null);
|
||||
this.changePasswordOpen.set(true);
|
||||
this.userMenuOpen.set(false);
|
||||
}
|
||||
|
||||
closeChangePassword(): void {
|
||||
if (this.changePasswordSubmitting()) return;
|
||||
this.changePasswordOpen.set(false);
|
||||
this.changePasswordError.set(null);
|
||||
}
|
||||
|
||||
async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise<void> {
|
||||
this.changePasswordSubmitting.set(true);
|
||||
this.changePasswordError.set(null);
|
||||
const result: ChangePasswordResult = await this.auth.changePassword(payload);
|
||||
this.changePasswordSubmitting.set(false);
|
||||
if (result.ok === true) {
|
||||
this.changePasswordOpen.set(false);
|
||||
return;
|
||||
}
|
||||
const failure = result as ChangePasswordFailure;
|
||||
this.changePasswordError.set(
|
||||
formatChangePasswordError({ code: failure.code, message: failure.message }),
|
||||
);
|
||||
}
|
||||
|
||||
async onLogout(): Promise<void> {
|
||||
await this.auth.logout();
|
||||
this.userMenuOpen.set(false);
|
||||
this.userStore.reset();
|
||||
await this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export function deriveActiveSectionFromUrl(url: string): string {
|
||||
const trimmed = (url || '').split('?')[0].split('#')[0];
|
||||
if (trimmed.startsWith('/scoreboard')) return 'Scoreboard';
|
||||
if (trimmed.startsWith('/blog')) return 'Blog';
|
||||
if (trimmed.startsWith('/admin')) return 'Admin';
|
||||
return 'Challenges';
|
||||
}
|
||||
|
||||
export function shouldShowAdminNav(input: {
|
||||
isAuthenticated: boolean;
|
||||
role: 'admin' | 'player' | undefined;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-scoreboard-page',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page-scoreboard">
|
||||
<h2>Scoreboard</h2>
|
||||
<p>Live scoreboard will appear here.</p>
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class ScoreboardPage {}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import {
|
||||
passwordMatchValidator,
|
||||
PasswordPolicy,
|
||||
} from './change-password-modal.validators';
|
||||
|
||||
export type ChangePasswordMode = 'self' | 'admin-reset';
|
||||
|
||||
export interface ChangePasswordSubmitPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmNewPassword: string;
|
||||
}
|
||||
|
||||
const DEFAULT_POLICY: PasswordPolicy = {
|
||||
minLength: 12,
|
||||
requireMixed: true,
|
||||
description: 'At least 12 characters with upper, lower, digit and symbol',
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password-modal',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
template: `
|
||||
@if (open()) {
|
||||
<div class="modal-overlay" data-testid="change-password-overlay" (click)="onCancel()">
|
||||
<div
|
||||
class="modal-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cp-title"
|
||||
data-testid="change-password-modal"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<h2 id="cp-title">Change password</h2>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="onSubmit()">
|
||||
@if (mode() === 'self') {
|
||||
<label class="field">
|
||||
<span>Old password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
formControlName="oldPassword"
|
||||
data-testid="cp-old"
|
||||
/>
|
||||
</label>
|
||||
}
|
||||
|
||||
<label class="field">
|
||||
<span>New password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="newPassword"
|
||||
data-testid="cp-new"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Confirm new password</span>
|
||||
<input
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="confirmNewPassword"
|
||||
data-testid="cp-confirm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<p class="error" data-testid="cp-error">{{ errorMessage() }}</p>
|
||||
}
|
||||
@if (formError()) {
|
||||
<p class="error" data-testid="cp-form-error">{{ formError() }}</p>
|
||||
}
|
||||
<p class="hint" data-testid="cp-policy">{{ policy().description }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="cp-cancel"
|
||||
(click)="onCancel()"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="cp-ok"
|
||||
[disabled]="submitting()"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ChangePasswordModalComponent {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
readonly open = input<boolean>(false);
|
||||
readonly mode = input<ChangePasswordMode>('self');
|
||||
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
|
||||
readonly errorMessage = input<string | null>(null);
|
||||
readonly submitting = input<boolean>(false);
|
||||
|
||||
readonly cancel = output<void>();
|
||||
readonly submit = output<ChangePasswordSubmitPayload>();
|
||||
|
||||
readonly form = this.fb.nonNullable.group(
|
||||
{
|
||||
oldPassword: this.fb.nonNullable.control(''),
|
||||
newPassword: this.fb.nonNullable.control('', [Validators.required]),
|
||||
confirmNewPassword: this.fb.nonNullable.control('', [Validators.required]),
|
||||
},
|
||||
{ validators: passwordMatchValidator },
|
||||
);
|
||||
|
||||
readonly formError = computed<string | null>(() => {
|
||||
const errs = this.form.errors;
|
||||
if (errs?.['passwordMismatch']) return 'Passwords do not match';
|
||||
return null;
|
||||
});
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscape(): void {
|
||||
if (this.open()) this.onCancel();
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.submitting()) return;
|
||||
const v = this.form.getRawValue();
|
||||
if (this.mode() === 'self' && !v.oldPassword) return;
|
||||
if (!v.newPassword || !v.confirmNewPassword) return;
|
||||
if (this.form.invalid) return;
|
||||
this.submit.emit({
|
||||
oldPassword: v.oldPassword,
|
||||
newPassword: v.newPassword,
|
||||
confirmNewPassword: v.confirmNewPassword,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PasswordPolicySchema = z.object({
|
||||
minLength: z.number().int().nonnegative(),
|
||||
requireMixed: z.boolean(),
|
||||
description: z.string(),
|
||||
});
|
||||
export type PasswordPolicy = z.infer<typeof PasswordPolicySchema>;
|
||||
|
||||
export function passwordMatchValidator(group: { get: (k: string) => any; errors?: any }): { passwordMismatch?: boolean } | null {
|
||||
const newP = group.get('newPassword')?.value;
|
||||
const conf = group.get('confirmNewPassword')?.value;
|
||||
if (!newP || !conf) return null;
|
||||
return newP === conf ? null : { passwordMismatch: true };
|
||||
}
|
||||
|
||||
export function buildPasswordPolicyMessage(policy: PasswordPolicy): string {
|
||||
return policy.description;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function formatChangePasswordError(input: {
|
||||
code: string | null | undefined;
|
||||
message?: string | null;
|
||||
}): string {
|
||||
const code = (input.code ?? '').toString();
|
||||
const message = (input.message ?? '').toString();
|
||||
switch (code) {
|
||||
case 'INVALID_OLD_PASSWORD':
|
||||
return 'Old password is incorrect';
|
||||
case 'PASSWORDS_DO_NOT_MATCH':
|
||||
return 'New password and confirmation do not match';
|
||||
case 'PASSWORD_POLICY':
|
||||
return message || 'New password does not meet the policy requirements';
|
||||
case 'UNAUTHORIZED':
|
||||
return 'You must be signed in to change your password';
|
||||
default:
|
||||
return message || 'Failed to change password';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { EventState } from '../../../core/services/event-status.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-header',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<header class="shell-header" data-testid="shell-header">
|
||||
<button
|
||||
type="button"
|
||||
class="shell-title"
|
||||
data-testid="shell-title"
|
||||
(click)="titleClick.emit()"
|
||||
>
|
||||
{{ pageTitle() }}
|
||||
</button>
|
||||
|
||||
<div class="shell-section" data-testid="shell-active-section">
|
||||
{{ activeSection() }}
|
||||
</div>
|
||||
|
||||
<div class="shell-right">
|
||||
<span
|
||||
class="led"
|
||||
[class.led-running]="eventState() === 'running'"
|
||||
[class.led-countdown]="eventState() === 'countdown'"
|
||||
[class.led-stopped]="eventState() === 'stopped'"
|
||||
[class.led-unconfigured]="eventState() === 'unconfigured'"
|
||||
data-testid="shell-led"
|
||||
[attr.aria-label]="'Event status: ' + eventState()"
|
||||
></span>
|
||||
<span class="countdown" data-testid="shell-countdown">{{ countdownText() }}</span>
|
||||
|
||||
<div class="user-menu-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-trigger"
|
||||
data-testid="user-menu-trigger"
|
||||
(click)="usernameMenuToggle.emit()"
|
||||
>
|
||||
{{ username() }} <span aria-hidden="true">▾</span>
|
||||
</button>
|
||||
|
||||
@if (userMenuOpen()) {
|
||||
<div class="user-menu" role="menu" data-testid="user-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-rank"
|
||||
(click)="rankClick.emit()"
|
||||
>
|
||||
{{ rankText() }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-change-password"
|
||||
(click)="changePasswordClick.emit()"
|
||||
>
|
||||
Change password
|
||||
</button>
|
||||
@if (canAccessAdmin()) {
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-admin"
|
||||
(click)="adminClick.emit()"
|
||||
>
|
||||
Admin area
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="user-menu-item"
|
||||
data-testid="user-menu-logout"
|
||||
(click)="logoutClick.emit()"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
})
|
||||
export class ShellHeaderComponent {
|
||||
readonly pageTitle = input<string>('HIPCTF');
|
||||
readonly activeSection = input<string>('Challenges');
|
||||
readonly eventState = input<EventState>('unconfigured');
|
||||
readonly countdownText = input<string>('');
|
||||
readonly username = input<string>('');
|
||||
readonly rankText = input<string>('');
|
||||
readonly canAccessAdmin = input<boolean>(false);
|
||||
readonly userMenuOpen = input<boolean>(false);
|
||||
|
||||
readonly titleClick = output<void>();
|
||||
readonly usernameMenuToggle = output<void>();
|
||||
readonly rankClick = output<void>();
|
||||
readonly changePasswordClick = output<void>();
|
||||
readonly adminClick = output<void>();
|
||||
readonly logoutClick = output<void>();
|
||||
|
||||
readonly ledClass = computed(() => `led led-${this.eventState()}`);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
export interface ShellTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-quick-tabs',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav class="quick-tabs" data-testid="quick-tabs">
|
||||
@for (tab of tabs(); track tab.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="quick-tab"
|
||||
[class.active]="active() === tab.id"
|
||||
[attr.data-testid]="'quick-tab-' + tab.id"
|
||||
(click)="onTabClick(tab)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class QuickTabsComponent {
|
||||
readonly tabs = input.required<ShellTab[]>();
|
||||
readonly active = input.required<string>();
|
||||
|
||||
readonly tabChange = output<{ id: string }>();
|
||||
|
||||
onTabClick(tab: ShellTab): void {
|
||||
this.tabChange.emit({ id: tab.id });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('POST /api/v1/auth/change-password', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function buildLoggedInAgent(): Promise<any> {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
|
||||
const csrf = cookies.find((c: any) => c.name === 'csrf');
|
||||
const res = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
(agent as any).accessToken = res.body.accessToken as string;
|
||||
return agent;
|
||||
}
|
||||
|
||||
it('rejects unauthenticated requests (CSRF or auth guard)', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/change-password')
|
||||
.send({ oldPassword: 'x', newPassword: 'Abcdefg1!Xyz', confirmNewPassword: 'Abcdefg1!Xyz' });
|
||||
expect([401, 403]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('rejects when new and confirm do not match (VALIDATION_FAILED or PASSWORDS_DO_NOT_MATCH)', async () => {
|
||||
const agent = await buildLoggedInAgent();
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
const res = await agent
|
||||
.post('/api/v1/auth/change-password')
|
||||
.set('Authorization', `Bearer ${agent.accessToken}`)
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ oldPassword: 'Sup3rSecret!Pass', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'Different1!Pass' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(['PASSWORDS_DO_NOT_MATCH', 'VALIDATION_FAILED']).toContain(res.body.code);
|
||||
});
|
||||
|
||||
it('rejects when old password is wrong (INVALID_OLD_PASSWORD)', async () => {
|
||||
const agent = await buildLoggedInAgent();
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
await agent
|
||||
.post('/api/v1/auth/change-password')
|
||||
.set('Authorization', `Bearer ${agent.accessToken}`)
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ oldPassword: 'WrongOld!Pass1', newPassword: 'NewSecret!Pass1', confirmNewPassword: 'NewSecret!Pass1' })
|
||||
.expect(401)
|
||||
.expect((r) => {
|
||||
expect(r.body.code).toBe('INVALID_OLD_PASSWORD');
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when new password is too weak (PASSWORD_POLICY)', async () => {
|
||||
const agent = await buildLoggedInAgent();
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
await agent
|
||||
.post('/api/v1/auth/change-password')
|
||||
.set('Authorization', `Bearer ${agent.accessToken}`)
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ oldPassword: 'Sup3rSecret!Pass', newPassword: 'short', confirmNewPassword: 'short' })
|
||||
.expect(400)
|
||||
.expect((r) => {
|
||||
expect(r.body.code).toBe('PASSWORD_POLICY');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EventStatusService } from '../../backend/src/common/services/event-status.service';
|
||||
|
||||
describe('EventStatusService.getState', () => {
|
||||
const settings = { get: jest.fn() } as any;
|
||||
|
||||
beforeEach(() => {
|
||||
settings.get.mockReset();
|
||||
});
|
||||
|
||||
it('returns countdown with positive secondsToStart before start', async () => {
|
||||
const start = new Date(Date.now() + 60_000).toISOString();
|
||||
const end = new Date(Date.now() + 120_000).toISOString();
|
||||
settings.get.mockImplementation(async (k: string) => (k === 'eventStartUtc' ? start : end));
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('countdown');
|
||||
expect(s.secondsToStart).not.toBeNull();
|
||||
expect(s.secondsToStart!).toBeGreaterThan(0);
|
||||
expect(s.secondsToEnd).toBeNull();
|
||||
});
|
||||
|
||||
it('returns running with secondsToEnd inside window', async () => {
|
||||
const start = new Date(Date.now() - 30_000).toISOString();
|
||||
const end = new Date(Date.now() + 60_000).toISOString();
|
||||
settings.get.mockImplementation(async (k: string) => (k === 'eventStartUtc' ? start : end));
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('running');
|
||||
expect(s.secondsToEnd).not.toBeNull();
|
||||
expect(s.secondsToStart).toBeNull();
|
||||
});
|
||||
|
||||
it('returns stopped after end', async () => {
|
||||
const start = new Date(Date.now() - 120_000).toISOString();
|
||||
const end = new Date(Date.now() - 60_000).toISOString();
|
||||
settings.get.mockImplementation(async (k: string) => (k === 'eventStartUtc' ? start : end));
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('stopped');
|
||||
expect(s.secondsToStart).toBeNull();
|
||||
expect(s.secondsToEnd).toBeNull();
|
||||
});
|
||||
|
||||
it('returns unconfigured when settings are missing', async () => {
|
||||
settings.get.mockResolvedValue('');
|
||||
const svc = new EventStatusService(settings);
|
||||
const s = await svc.getState();
|
||||
expect(s.state).toBe('unconfigured');
|
||||
expect(s.secondsToStart).toBeNull();
|
||||
expect(s.secondsToEnd).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('GET /api/v1/events/status (authenticated SSE)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects unauthenticated SSE with 401', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/events/status').expect(401);
|
||||
});
|
||||
|
||||
it('emits initial status with state + serverNowUtc + eventStartUtc + eventEndUtc when authenticated', (done) => {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
(async () => {
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
const login = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const token = login.body.accessToken as string;
|
||||
|
||||
const req = request(server)
|
||||
.get('/api/v1/events/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
let received = false;
|
||||
req
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
if (received) return;
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const match = /data: ({.*?})\n/.exec(text);
|
||||
if (match) {
|
||||
try {
|
||||
const payload = JSON.parse(match[1]);
|
||||
expect(payload).toHaveProperty('state');
|
||||
expect(payload).toHaveProperty('serverNowUtc');
|
||||
expect(payload).toHaveProperty('eventStartUtc');
|
||||
expect(payload).toHaveProperty('eventEndUtc');
|
||||
expect(['countdown', 'running', 'stopped', 'unconfigured']).toContain(payload.state);
|
||||
received = true;
|
||||
(res as any).destroy();
|
||||
done();
|
||||
} catch (e) {
|
||||
done(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
res.on('error', (err) => cb(err, null));
|
||||
})
|
||||
.end(() => {});
|
||||
})().catch(done);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
process.env.DATABASE_PATH = ':memory:';
|
||||
process.env.THEMES_DIR = './themes';
|
||||
process.env.FRONTEND_DIST = './frontend/dist';
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import * as express from 'express';
|
||||
import request from 'supertest';
|
||||
import { CookieAccessInfo } from 'cookiejar';
|
||||
import { AppModule } from '../../backend/src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { initDb } from './db-helper';
|
||||
|
||||
describe('GET /api/v1/auth/me', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||
await app.init();
|
||||
await initDb(app);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/register-first-admin')
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
await request(app.getHttpServer()).get('/api/v1/auth/me').expect(401);
|
||||
});
|
||||
|
||||
it('returns id, username, role, rank, points when authenticated', async () => {
|
||||
const server = app.getHttpServer();
|
||||
const agent = request.agent(server);
|
||||
await agent.get('/api/v1/auth/csrf');
|
||||
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||
const login = await agent
|
||||
.post('/api/v1/auth/login')
|
||||
.set('X-CSRF-Token', csrf.value)
|
||||
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
|
||||
.expect(201);
|
||||
const token = login.body.accessToken as string;
|
||||
|
||||
const res = await agent
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(res.body.username).toBe('admin');
|
||||
expect(res.body.role).toBe('admin');
|
||||
expect(typeof res.body.id).toBe('string');
|
||||
expect(res.body.points).toBe(0);
|
||||
expect(res.body.rank === null || typeof res.body.rank === 'number').toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { UsersRankService } from '../../backend/src/modules/users/users-rank.service';
|
||||
|
||||
function buildMockManager(opts: { pointsByUser?: Map<string, number>; usersAhead?: number }): any {
|
||||
const pointsByUser = opts.pointsByUser ?? new Map();
|
||||
const usersAhead = opts.usersAhead ?? 0;
|
||||
const repo: any = {
|
||||
createQueryBuilder: jest.fn().mockImplementation((alias: string) => {
|
||||
const builder: any = {};
|
||||
builder.select = jest.fn().mockReturnThis();
|
||||
builder.addSelect = jest.fn().mockReturnThis();
|
||||
builder.where = jest.fn().mockReturnThis();
|
||||
builder.groupBy = jest.fn().mockReturnThis();
|
||||
builder.having = jest.fn().mockReturnThis();
|
||||
builder.getRawOne = jest.fn().mockImplementation(async () => {
|
||||
return { total: pointsByUser.get(alias === 's' ? '_self_' : '_self_') ?? 0 };
|
||||
});
|
||||
builder.getRawOneForUser = jest.fn().mockImplementation(async (userId: string) => {
|
||||
return { total: pointsByUser.get(userId) ?? 0 };
|
||||
});
|
||||
builder.getRawMany = jest.fn().mockImplementation(async () => {
|
||||
return Array.from({ length: usersAhead }, (_, i) => ({ userId: 'other_' + i }));
|
||||
});
|
||||
return builder;
|
||||
}),
|
||||
};
|
||||
// Wire the user's id into the "self" lookup.
|
||||
return {
|
||||
getRepository: () => ({
|
||||
createQueryBuilder: () => {
|
||||
const b: any = {};
|
||||
b.select = jest.fn().mockReturnThis();
|
||||
b.addSelect = jest.fn().mockReturnThis();
|
||||
b.where = jest.fn((q: string, p: any) => {
|
||||
b._userId = p?.userId;
|
||||
return b;
|
||||
});
|
||||
b.groupBy = jest.fn().mockReturnThis();
|
||||
b.having = jest.fn().mockReturnThis();
|
||||
b.getRawOne = jest.fn().mockImplementation(async () => {
|
||||
return { total: pointsByUser.get(b._userId) ?? 0 };
|
||||
});
|
||||
b.getRawMany = jest.fn().mockImplementation(async () => {
|
||||
return Array.from({ length: usersAhead }, (_, i) => ({ userId: 'other_' + i }));
|
||||
});
|
||||
return b;
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('UsersRankService.rankOfUser', () => {
|
||||
it('returns null rank and 0 points for a user with no solves', async () => {
|
||||
const svc = new UsersRankService();
|
||||
const r = await svc.rankOfUser(buildMockManager({}) as any, 'u1');
|
||||
expect(r.rank).toBeNull();
|
||||
expect(r.points).toBe(0);
|
||||
});
|
||||
|
||||
it('returns rank 1 when no users are ahead', async () => {
|
||||
const svc = new UsersRankService();
|
||||
const r = await svc.rankOfUser(buildMockManager({ pointsByUser: new Map([['u1', 100]]), usersAhead: 0 }) as any, 'u1');
|
||||
expect(r.rank).toBe(1);
|
||||
expect(r.points).toBe(100);
|
||||
});
|
||||
|
||||
it('returns rank = ahead+1', async () => {
|
||||
const svc = new UsersRankService();
|
||||
const r = await svc.rankOfUser(buildMockManager({ pointsByUser: new Map([['u1', 100]]), usersAhead: 2 }) as any, 'u1');
|
||||
expect(r.rank).toBe(3);
|
||||
expect(r.points).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
passwordMatchValidator,
|
||||
buildPasswordPolicyMessage,
|
||||
} from '../../frontend/src/app/features/shell/change-password/change-password-modal.validators';
|
||||
import { formatChangePasswordError } from '../../frontend/src/app/features/shell/change-password/password-feedback';
|
||||
|
||||
describe('passwordMatchValidator', () => {
|
||||
const grp = (newP: string, conf: string) => ({
|
||||
get: (k: string) =>
|
||||
k === 'newPassword' ? { value: newP } : k === 'confirmNewPassword' ? { value: conf } : { value: '' },
|
||||
});
|
||||
|
||||
it('returns null when both inputs are equal', () => {
|
||||
expect(passwordMatchValidator(grp('Abc123!Xyz', 'Abc123!Xyz'))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns {passwordMismatch:true} when inputs differ', () => {
|
||||
expect(passwordMatchValidator(grp('Abc123!Xyz', 'Other1!Pass'))).toEqual({ passwordMismatch: true });
|
||||
});
|
||||
|
||||
it('returns null when either input is empty (handled by required validators)', () => {
|
||||
expect(passwordMatchValidator(grp('', ''))).toBeNull();
|
||||
expect(passwordMatchValidator(grp('Abc', ''))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPasswordPolicyMessage', () => {
|
||||
it('returns the policy description verbatim', () => {
|
||||
expect(buildPasswordPolicyMessage({ minLength: 12, requireMixed: true, description: 'foo bar' })).toBe('foo bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatChangePasswordError', () => {
|
||||
it('maps INVALID_OLD_PASSWORD to a clear message', () => {
|
||||
expect(formatChangePasswordError({ code: 'INVALID_OLD_PASSWORD' })).toBe('Old password is incorrect');
|
||||
});
|
||||
|
||||
it('maps PASSWORDS_DO_NOT_MATCH', () => {
|
||||
expect(formatChangePasswordError({ code: 'PASSWORDS_DO_NOT_MATCH' })).toBe('New password and confirmation do not match');
|
||||
});
|
||||
|
||||
it('maps PASSWORD_POLICY and surfaces server message if present', () => {
|
||||
expect(formatChangePasswordError({ code: 'PASSWORD_POLICY', message: 'too short' })).toBe('too short');
|
||||
expect(formatChangePasswordError({ code: 'PASSWORD_POLICY' })).toBe('New password does not meet the policy requirements');
|
||||
});
|
||||
|
||||
it('maps UNAUTHORIZED', () => {
|
||||
expect(formatChangePasswordError({ code: 'UNAUTHORIZED' })).toBe('You must be signed in to change your password');
|
||||
});
|
||||
|
||||
it('falls back to message or default for unknown codes', () => {
|
||||
expect(formatChangePasswordError({ code: 'EXOTIC', message: 'Quux' })).toBe('Quux');
|
||||
expect(formatChangePasswordError({ code: 'EXOTIC' })).toBe('Failed to change password');
|
||||
});
|
||||
|
||||
it('handles null/undefined code and message', () => {
|
||||
expect(formatChangePasswordError({ code: null, message: null })).toBe('Failed to change password');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
formatDdHhMm,
|
||||
deriveCountdownText,
|
||||
EventState,
|
||||
} from '../../frontend/src/app/core/services/event-status.pure';
|
||||
|
||||
describe('formatDdHhMm', () => {
|
||||
it('formats 90_061 seconds as 01:01:01', () => {
|
||||
expect(formatDdHhMm(90_061)).toBe('01:01:01');
|
||||
});
|
||||
|
||||
it('formats 800 seconds as 00:00:13', () => {
|
||||
expect(formatDdHhMm(800)).toBe('00:00:13');
|
||||
});
|
||||
|
||||
it('formats 0 seconds as 00:00:00', () => {
|
||||
expect(formatDdHhMm(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
it('returns 00:00:00 for negative or non-finite values', () => {
|
||||
expect(formatDdHhMm(-5)).toBe('00:00:00');
|
||||
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveCountdownText', () => {
|
||||
const states: EventState[] = ['countdown', 'running', 'stopped', 'unconfigured'];
|
||||
states.forEach((state) => {
|
||||
it(`returns empty string for ${state} when respective seconds are null`, () => {
|
||||
if (state === 'stopped' || state === 'unconfigured') {
|
||||
expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended');
|
||||
} else {
|
||||
expect(deriveCountdownText(state, null, null)).toBe('00:00:00');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns DD:HH:mm to start when countdown and secondsToStart provided', () => {
|
||||
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00');
|
||||
});
|
||||
|
||||
it('returns DD:HH:mm to end when running and secondsToEnd provided', () => {
|
||||
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13');
|
||||
});
|
||||
|
||||
it('returns "Event ended" when stopped', () => {
|
||||
expect(deriveCountdownText('stopped', 0, 0)).toBe('Event ended');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { deriveActiveSectionFromUrl } from '../../frontend/src/app/features/home/home.shell';
|
||||
|
||||
describe('deriveActiveSectionFromUrl', () => {
|
||||
it('returns Scoreboard for /scoreboard', () => {
|
||||
expect(deriveActiveSectionFromUrl('/scoreboard')).toBe('Scoreboard');
|
||||
});
|
||||
|
||||
it('returns Blog for /blog', () => {
|
||||
expect(deriveActiveSectionFromUrl('/blog')).toBe('Blog');
|
||||
});
|
||||
|
||||
it('returns Admin for /admin', () => {
|
||||
expect(deriveActiveSectionFromUrl('/admin')).toBe('Admin');
|
||||
});
|
||||
|
||||
it('returns Challenges for / or /challenges', () => {
|
||||
expect(deriveActiveSectionFromUrl('/')).toBe('Challenges');
|
||||
expect(deriveActiveSectionFromUrl('/challenges')).toBe('Challenges');
|
||||
});
|
||||
|
||||
it('ignores query string and hash', () => {
|
||||
expect(deriveActiveSectionFromUrl('/scoreboard?x=1')).toBe('Scoreboard');
|
||||
expect(deriveActiveSectionFromUrl('/blog#top')).toBe('Blog');
|
||||
});
|
||||
|
||||
it('falls back to Challenges for empty url', () => {
|
||||
expect(deriveActiveSectionFromUrl('')).toBe('Challenges');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user