Files
HIPCTF2/docs/guides/authenticated-shell.md
T
2026-07-21 22:26:44 +00:00

167 lines
11 KiB
Markdown

---
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)