docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-21 23:22:03 +00:00
parent 76c04f9ea0
commit 18af97cea5
6 changed files with 145 additions and 435 deletions
+54 -131
View File
@@ -1,166 +1,89 @@
---
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.
description: How a signed-in user experiences the post-login shell, including authenticated event streaming.
tags: [guide, shell, header, sse, navigation, tester]
timestamp: 2026-07-21T22:19:08Z
timestamp: 2026-07-21T23:25:00Z
---
# 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:
The authenticated shell is the post-login UI rendered by `HomeComponent`
(`frontend/src/app/features/home/home.component.ts`). It is gated by the client
`authGuard` and the JWT-protected `/api/v1/events/status` stream.
| 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.
A user who is not signed in, or whose session has expired, is sent to `/login`
before the shell mounts.
# 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**.
1. Ensure the instance is initialized. If not, follow [First-Run Bootstrap](/guides/bootstrap.md).
2. Sign in via `/login` as an admin or player.
3. Confirm the browser lands on `/challenges` and renders the shell.
# Expected behavior
## Header (`ShellHeaderComponent`)
## Header
| 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). |
| Element | Selector | Expected |
|---|---|---|
| Page title | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle`; clicking navigates to `/challenges`. |
| Active section | `[data-testid="shell-active-section"]` | Shows `Challenges`, `Scoreboard`, `Blog`, or `Admin` based on the URL. |
| Event LED | `[data-testid="shell-led"]` | Uses `led-running`, `led-countdown`, `led-stopped`, or `led-unconfigured`. |
| Countdown | `[data-testid="shell-countdown"]` | Displays `DD:HH:mm`, `Event ended`, or empty for an unconfigured event. |
| User menu | `[data-testid="user-menu-trigger"]` | Toggles rank, change-password, optional admin, and logout entries. |
## Quick tabs (`QuickTabsComponent`)
## Quick tabs
| 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 tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and
`Blog`. Clicking a tab navigates to its matching route; the active tab is marked
with the active class.
The default child route is `'' → challenges`, so visiting `/` lands on
the Challenges page with the `Challenges` tab highlighted.
# Live event window
## Body
1. `HomeComponent.ngOnInit()` starts `EventStatusStore` with
`AuthenticatedEventSourceService.open('/api/v1/events/status')`.
2. The transport uses `fetch`, sends `Accept: text/event-stream`, includes
`Authorization: Bearer <access-token>` when available, and sends credentials.
3. It parses SSE `event:` and `data:` lines, buffers frames until a message listener
is registered, and dispatches `MessageEvent` payloads.
4. The store applies each state frame and advances countdown values with a local
one-second tick between server pushes.
5. Leaving the shell closes the source, aborts the fetch, and clears the tick.
| 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. |
A failed HTTP response, missing stream body, or streaming failure dispatches an
error event. The shell retains the last event state until a later connection succeeds.
# 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`. |
| File | Responsibility |
|---|---|
| `frontend/src/app/features/home/home.component.ts` | Hosts the shell and starts/stops the authenticated event stream. |
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with abort and frame parsing. |
| `frontend/src/app/core/services/event-status.store.ts` | Applies event frames and derives the live countdown. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders event LED, countdown, navigation, and user menu. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Renders the main navigation tabs. |
| `tests/frontend/authenticated-event-source.spec.ts` | Verifies authorization headers, SSE frame delivery, and anonymous header omission. |
# 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.
1. Configure a future start time and confirm the countdown LED and text appear.
2. Move the start time into the past and the end time into the future; confirm the
LED changes to running and the countdown counts toward the end.
3. Move the end time into the past; confirm `Event ended` and the stopped LED.
4. Remove either setting; confirm the unconfigured LED and empty countdown.
## Change-password modal (happy path)
## Navigation and logout
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.
1. Click each quick tab and confirm the matching route and active styling.
2. Open the user menu and click **Logout**.
3. Confirm navigation to `/login` and that the event stream is closed.
# 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)
- [System Endpoints](/api/system.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Change Password](/guides/change-password.md)