docs: update documentation to OKF v0.1 format
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -53,9 +53,17 @@ 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 }))`
|
||||
`HomeComponent.ngOnInit()` through `AuthenticatedEventSourceService.open()` and
|
||||
`EventStatusStore.start(...)`
|
||||
(`frontend/src/app/features/home/home.component.ts`,
|
||||
`frontend/src/app/core/services/event-status.store.ts`). The store:
|
||||
`frontend/src/app/core/services/authenticated-event-source.service.ts`,
|
||||
`frontend/src/app/core/services/event-status.store.ts`). The transport uses a
|
||||
fetch stream rather than the browser `EventSource` API because it must attach the
|
||||
JWT `Authorization` header. It sends `Accept: text/event-stream`, includes
|
||||
credentials, parses streamed SSE records, buffers early frames, and aborts the
|
||||
request on `close()`.
|
||||
|
||||
The store:
|
||||
|
||||
1. Calls `applyServerStatus(payload)` on every `message` frame (parses
|
||||
JSON, sets `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`,
|
||||
|
||||
Reference in New Issue
Block a user