diff --git a/.kilo/plans/857.md b/.kilo/plans/857.md new file mode 100644 index 0000000..5662749 --- /dev/null +++ b/.kilo/plans/857.md @@ -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//.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: - Points:

"`. +* `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 `. +* **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` 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` 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 { + 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. diff --git a/.kilo/plans/873.md b/.kilo/plans/873.md deleted file mode 100644 index a098cc5..0000000 --- a/.kilo/plans/873.md +++ /dev/null @@ -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. diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 21dfc4f..dd8d8b9 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; diff --git a/backend/src/common/services/event-status.service.ts b/backend/src/common/services/event-status.service.ts index ff1cad7..5bdd7e7 100644 --- a/backend/src/common/services/event-status.service.ts +++ b/backend/src/common/services/event-status.service.ts @@ -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 { - 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, }; } -} \ No newline at end of file + + async getState(now: Date = new Date()): Promise { + 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, + }; + } +} diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 0c17bbf..4726b0a 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -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 { + 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)' }) diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 0efec58..c620e67 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -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], diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 1f0d8a9..249e2a1 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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('ARGON2_MEMORY_COST'), + timeCost: this.config.get('ARGON2_TIME_COST'), + parallelism: this.config.get('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 diff --git a/backend/src/modules/auth/dto/auth.dto.ts b/backend/src/modules/auth/dto/auth.dto.ts index 9195aff..830f1a9 100644 --- a/backend/src/modules/auth/dto/auth.dto.ts +++ b/backend/src/modules/auth/dto/auth.dto.ts @@ -37,4 +37,25 @@ export interface LoginResponse { refreshToken: string; expiresIn: number; user: { id: string; username: string; role: 'admin' | 'player' }; -} \ No newline at end of file +} + +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; + +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; \ No newline at end of file diff --git a/backend/src/modules/system/system.controller.ts b/backend/src/modules/system/system.controller.ts index 8dace9e..c322b8d 100644 --- a/backend/src/modules/system/system.controller.ts +++ b/backend/src/modules/system/system.controller.ts @@ -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 { + 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. diff --git a/backend/src/modules/system/system.module.ts b/backend/src/modules/system/system.module.ts index ede8ac3..4c3e7ff 100644 --- a/backend/src/modules/system/system.module.ts +++ b/backend/src/modules/system/system.module.ts @@ -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], }) diff --git a/backend/src/modules/users/users-rank.service.ts b/backend/src/modules/users/users-rank.service.ts new file mode 100644 index 0000000..ac353c5 --- /dev/null +++ b/backend/src/modules/users/users-rank.service.ts @@ -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 { + 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 }; + } +} diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts index 0660942..62ff448 100644 --- a/backend/src/modules/users/users.module.ts +++ b/backend/src/modules/users/users.module.ts @@ -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 {} \ No newline at end of file diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 3a12f27..9f68a3c 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -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], diff --git a/frontend/src/app/core/services/auth.service.ts b/frontend/src/app/core/services/auth.service.ts index c22691f..61fe496 100644 --- a/frontend/src/app/core/services/auth.service.ts +++ b/frontend/src/app/core/services/auth.service.ts @@ -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 { + 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 { + const res = await firstValueFrom( + this.http.get('/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 { + 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', }; -} \ No newline at end of file +} diff --git a/frontend/src/app/core/services/event-status.pure.ts b/frontend/src/app/core/services/event-status.pure.ts new file mode 100644 index 0000000..78d627e --- /dev/null +++ b/frontend/src/app/core/services/event-status.pure.ts @@ -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); +} diff --git a/frontend/src/app/core/services/event-status.store.ts b/frontend/src/app/core/services/event-status.store.ts new file mode 100644 index 0000000..bbe9687 --- /dev/null +++ b/frontend/src/app/core/services/event-status.store.ts @@ -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('unconfigured'); + readonly serverNowUtc = signal(null); + readonly eventStartUtc = signal(null); + readonly eventEndUtc = signal(null); + private readonly anchorDeltaMs = signal(0); + private readonly tick = signal(0); + + private readonly secondsToStart = computed(() => { + 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(() => { + 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(() => { + return deriveCountdownText(this.state(), this.secondsToStart(), this.secondsToEnd()); + }); + + private intervalId: ReturnType | 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; + } + } +} diff --git a/frontend/src/app/core/services/user.store.ts b/frontend/src/app/core/services/user.store.ts new file mode 100644 index 0000000..592ab22 --- /dev/null +++ b/frontend/src/app/core/services/user.store.ts @@ -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(null); + readonly loading = signal(false); + readonly error = signal(null); + + readonly role = computed(() => this.user()?.role); + readonly isAdmin = computed(() => this.role() === 'admin'); + readonly rankText = computed(() => { + 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 { + 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); + } +} diff --git a/frontend/src/app/features/blog/blog.page.ts b/frontend/src/app/features/blog/blog.page.ts new file mode 100644 index 0000000..e07a6df --- /dev/null +++ b/frontend/src/app/features/blog/blog.page.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'app-blog-page', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

+

Blog

+

Blog posts will appear here.

+
+ `, +}) +export class BlogPage {} diff --git a/frontend/src/app/features/challenges/challenges.page.ts b/frontend/src/app/features/challenges/challenges.page.ts new file mode 100644 index 0000000..23b2550 --- /dev/null +++ b/frontend/src/app/features/challenges/challenges.page.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'app-challenges-page', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Challenges

+

Challenge list will appear here.

+
+ `, +}) +export class ChallengesPage {} diff --git a/frontend/src/app/features/home/home.component.ts b/frontend/src/app/features/home/home.component.ts index b8dbc82..62f9d4a 100644 --- a/frontend/src/app/features/home/home.component.ts +++ b/frontend/src/app/features/home/home.component.ts @@ -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: `
-
-

{{ bootstrap.payload()?.pageTitle ?? 'HIPCTF' }}

-
- Signed in as {{ auth.currentUser()?.username }} ({{ auth.currentUser()?.role }}) -
-
+ - +
-
-

{{ bootstrap.payload()?.welcomeMarkdown }}

- Sign in -
+ +
`, }) -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(null); + + private readonly currentUrl = signal(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 { + 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 { + await this.auth.logout(); + this.userMenuOpen.set(false); + this.userStore.reset(); + await this.router.navigateByUrl('/login'); + } } diff --git a/frontend/src/app/features/home/home.shell.ts b/frontend/src/app/features/home/home.shell.ts index 77839bb..30b74c5 100644 --- a/frontend/src/app/features/home/home.shell.ts +++ b/frontend/src/app/features/home/home.shell.ts @@ -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; diff --git a/frontend/src/app/features/scoreboard/scoreboard.page.ts b/frontend/src/app/features/scoreboard/scoreboard.page.ts new file mode 100644 index 0000000..11eaad8 --- /dev/null +++ b/frontend/src/app/features/scoreboard/scoreboard.page.ts @@ -0,0 +1,14 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'app-scoreboard-page', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Scoreboard

+

Live scoreboard will appear here.

+
+ `, +}) +export class ScoreboardPage {} diff --git a/frontend/src/app/features/shell/change-password/change-password-modal.component.ts b/frontend/src/app/features/shell/change-password/change-password-modal.component.ts new file mode 100644 index 0000000..d34c9b1 --- /dev/null +++ b/frontend/src/app/features/shell/change-password/change-password-modal.component.ts @@ -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()) { + + } + `, +}) +export class ChangePasswordModalComponent { + private readonly fb = inject(FormBuilder); + + readonly open = input(false); + readonly mode = input('self'); + readonly policy = input(DEFAULT_POLICY); + readonly errorMessage = input(null); + readonly submitting = input(false); + + readonly cancel = output(); + readonly submit = output(); + + 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(() => { + 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, + }); + } +} diff --git a/frontend/src/app/features/shell/change-password/change-password-modal.validators.ts b/frontend/src/app/features/shell/change-password/change-password-modal.validators.ts new file mode 100644 index 0000000..e2a3f45 --- /dev/null +++ b/frontend/src/app/features/shell/change-password/change-password-modal.validators.ts @@ -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; + +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; +} diff --git a/frontend/src/app/features/shell/change-password/password-feedback.ts b/frontend/src/app/features/shell/change-password/password-feedback.ts new file mode 100644 index 0000000..33f66ef --- /dev/null +++ b/frontend/src/app/features/shell/change-password/password-feedback.ts @@ -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'; + } +} diff --git a/frontend/src/app/features/shell/header/shell-header.component.ts b/frontend/src/app/features/shell/header/shell-header.component.ts new file mode 100644 index 0000000..f48af34 --- /dev/null +++ b/frontend/src/app/features/shell/header/shell-header.component.ts @@ -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: ` +
+ + +
+ {{ activeSection() }} +
+ +
+ + {{ countdownText() }} + +
+ + + @if (userMenuOpen()) { + + } +
+
+
+ `, +}) +export class ShellHeaderComponent { + readonly pageTitle = input('HIPCTF'); + readonly activeSection = input('Challenges'); + readonly eventState = input('unconfigured'); + readonly countdownText = input(''); + readonly username = input(''); + readonly rankText = input(''); + readonly canAccessAdmin = input(false); + readonly userMenuOpen = input(false); + + readonly titleClick = output(); + readonly usernameMenuToggle = output(); + readonly rankClick = output(); + readonly changePasswordClick = output(); + readonly adminClick = output(); + readonly logoutClick = output(); + + readonly ledClass = computed(() => `led led-${this.eventState()}`); +} diff --git a/frontend/src/app/features/shell/tabs/quick-tabs.component.ts b/frontend/src/app/features/shell/tabs/quick-tabs.component.ts new file mode 100644 index 0000000..2126d96 --- /dev/null +++ b/frontend/src/app/features/shell/tabs/quick-tabs.component.ts @@ -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: ` + + `, +}) +export class QuickTabsComponent { + readonly tabs = input.required(); + readonly active = input.required(); + + readonly tabChange = output<{ id: string }>(); + + onTabClick(tab: ShellTab): void { + this.tabChange.emit({ id: tab.id }); + } +} diff --git a/tests/backend/change-password.spec.ts b/tests/backend/change-password.spec.ts new file mode 100644 index 0000000..4f37165 --- /dev/null +++ b/tests/backend/change-password.spec.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/tests/backend/event-status-state.spec.ts b/tests/backend/event-status-state.spec.ts new file mode 100644 index 0000000..d10d99d --- /dev/null +++ b/tests/backend/event-status-state.spec.ts @@ -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(); + }); +}); diff --git a/tests/backend/events-status-sse.spec.ts b/tests/backend/events-status-sse.spec.ts new file mode 100644 index 0000000..913cb78 --- /dev/null +++ b/tests/backend/events-status-sse.spec.ts @@ -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); +}); diff --git a/tests/backend/me-endpoint.spec.ts b/tests/backend/me-endpoint.spec.ts new file mode 100644 index 0000000..357621d --- /dev/null +++ b/tests/backend/me-endpoint.spec.ts @@ -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); + }); +}); diff --git a/tests/backend/rank-points.spec.ts b/tests/backend/rank-points.spec.ts new file mode 100644 index 0000000..46c6879 --- /dev/null +++ b/tests/backend/rank-points.spec.ts @@ -0,0 +1,72 @@ +import { UsersRankService } from '../../backend/src/modules/users/users-rank.service'; + +function buildMockManager(opts: { pointsByUser?: Map; 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); + }); +}); diff --git a/tests/frontend/change-password-modal.spec.ts b/tests/frontend/change-password-modal.spec.ts new file mode 100644 index 0000000..eca6966 --- /dev/null +++ b/tests/frontend/change-password-modal.spec.ts @@ -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'); + }); +}); diff --git a/tests/frontend/event-status.store.spec.ts b/tests/frontend/event-status.store.spec.ts new file mode 100644 index 0000000..6b51113 --- /dev/null +++ b/tests/frontend/event-status.store.spec.ts @@ -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'); + }); +}); diff --git a/tests/frontend/shell-active-section.spec.ts b/tests/frontend/shell-active-section.spec.ts new file mode 100644 index 0000000..656ce84 --- /dev/null +++ b/tests/frontend/shell-active-section.spec.ts @@ -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'); + }); +});