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