docs: update documentation to OKF v0.1 format

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