AI Implementation feature(876): Authenticated Shell, Quick Tabs and Change Password 1.01 (#16)
This commit was merged in pull request #16.
This commit is contained in:
@@ -1,152 +1,66 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Frontend Structure
|
||||
description: Angular routes, components, services, guards, and interceptors.
|
||||
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
|
||||
tags: [architecture, frontend, angular, shell, sse]
|
||||
timestamp: 2026-07-21T22:19:08Z
|
||||
timestamp: 2026-07-21T23:25:00Z
|
||||
---
|
||||
|
||||
# Routes
|
||||
|
||||
Routes live in `frontend/src/app/app.routes.ts`:
|
||||
|
||||
| Path | Component | Guard | Notes |
|
||||
|-----------------|----------------------------------|---------------|----------------------------------------|
|
||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. |
|
||||
| `/login` | `LandingComponent` | `landingGuard`| Public landing page that hosts the login (and registration, when enabled) modal. The `landingGuard` redirects to `/bootstrap` until the instance is initialized and to `/` if the user is already authenticated. |
|
||||
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts the header (LED + countdown + user menu), quick-jump tabs (Challenges/Scoreboard/Blog), and a `<router-outlet>` for child routes. Default child route redirects to `/challenges`. |
|
||||
| `/challenges` | `ChallengesPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the challenges Job lands. |
|
||||
| `/scoreboard` | `ScoreboardPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the scoreboard Job lands. |
|
||||
| `/blog` | `BlogPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the blog Job lands. |
|
||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
||||
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
||||
| Path | Component | Guard | Notes |
|
||||
|---|---|---|---|
|
||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | First-admin creation flow. |
|
||||
| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
|
||||
| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
|
||||
| `/challenges` | `ChallengesPage` | inherited | Placeholder challenges page. |
|
||||
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
|
||||
| `/blog` | `BlogPage` | inherited | Placeholder blog page. |
|
||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
|
||||
|
||||
# Components
|
||||
# Components and services
|
||||
|
||||
All components are standalone (no NgModules). Each component imports
|
||||
`FormsModule` directly and uses Angular signals for state.
|
||||
| Symbol | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, and event-stream lifecycle. |
|
||||
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu. |
|
||||
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. |
|
||||
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text. |
|
||||
| `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events. |
|
||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport. |
|
||||
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
|
||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart authenticated shell. Hosts the `ShellHeaderComponent`, `QuickTabsComponent`, the `ChangePasswordModalComponent`, and the `<router-outlet>` for child routes. Owns the active section / active tab signals and the SSE lifecycle for `EventStatusStore`. |
|
||||
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb presentational header. Inputs: `pageTitle`, `activeSection`, `eventState`, `countdownText`, `username`, `rankText`, `canAccessAdmin`, `userMenuOpen`. Outputs: `titleClick`, `usernameMenuToggle`, `rankClick`, `changePasswordClick`, `adminClick`, `logoutClick`. Renders the LED (state class), `DD:HH:mm` countdown, and an inline user menu dropdown (`Change password`, optional `Admin area`, `Logout`). |
|
||||
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb presentational tab strip. Inputs: `tabs: ShellTab[]`, `active: string`. Output: `tabChange: {id}`. |
|
||||
| `ChangePasswordModalComponent` | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal with three fields (`oldPassword` only in `mode='self'`), `passwordMatchValidator` group validation, policy hint from `BootstrapService.passwordPolicy()`, and `errorMessage`/`submitting` inputs. Emits `submit(payload)` and `cancel()`. Closes on Escape and on overlay click. |
|
||||
| `ChallengesPage` | `frontend/src/app/features/challenges/challenges.page.ts` | Placeholder child route. |
|
||||
| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Placeholder child route. |
|
||||
| `BlogPage` | `frontend/src/app/features/blog/blog.page.ts` | Placeholder child route. |
|
||||
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
||||
| `LandingComponent` | `frontend/src/app/features/landing/landing.component.{ts,html,css}` | Public landing page: logo, page title, rendered `welcomeMarkdown`, "Login" button, blog post list, and the modal that contains both the login and registration forms (mode toggled by a single signal). |
|
||||
| `LandingService` | `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals, `refresh()`. |
|
||||
| `LoginModalService` (pure helpers) | `frontend/src/app/features/landing/login-modal.service.ts` | Pure `buildLoginFailureMessage` that maps an API error envelope into user-facing copy (and extracts a `retryAfterSeconds` for `RATE_LIMITED`). |
|
||||
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. |
|
||||
| `HomeShell` (pure helpers) | `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` + `deriveActiveSectionFromUrl(url)` predicates (unit-tested). |
|
||||
# Authenticated SSE wiring
|
||||
|
||||
# Services
|
||||
`HomeComponent.ngOnInit()` calls `EventStatusStore.start()` with a factory for
|
||||
`AuthenticatedEventSourceService.open('/api/v1/events/status')`. The transport:
|
||||
|
||||
| Service | Path | Purpose |
|
||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage`, exposes `restoreSession()` + `waitUntilHydrated()`, plus `login()` / `register()` / `logout()` / `me()` / `changePassword()` (each calls `ensureCsrf()` first). Returns discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult` for component-level error handling. |
|
||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS, exposes a `passwordPolicy` computed signal derived from the bootstrap payload; `load()` / `ready()` deduplicate the in-flight fetch. |
|
||||
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window. Owns `state`/`serverNowUtc`/`eventStartUtc`/`eventEndUtc`, derives a `countdownText` computed signal (`DD:HH:mm` or `"Event ended"`), and exposes `start(createSource)` / `stop()` so `HomeComponent` can drive a 1-second tick that re-derives the seconds-to-start/end locally between SSE pushes. |
|
||||
| `EventStatusPure` | `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers consumed by the store and by tests: `deriveCountdownText(state, sStart, sEnd)`, `formatDdHhMm(totalSeconds)`, `EventSourceLike` interface, and the `EventState`/`EventStatePayload` types. |
|
||||
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (id/username/role/rank/points + loading/error). `hydrateFromAuth()` mirrors `AuthService.currentUser()`; `loadMe()` calls `AuthService.me()` and merges rank/points. Exposes `rankText` (`"Rank: <r> - Points: <p>"`). |
|
||||
| `MarkdownService` | `frontend/src/app/core/services/markdown.service.ts` | Wraps `DomSanitizer.bypassSecurityTrustHtml` around the pure `renderMarkdownToHtml` helper. Used to render `welcomeMarkdown` and blog post bodies. |
|
||||
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
||||
| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. |
|
||||
| `ChangePassword` (pure helpers) | `frontend/src/app/features/shell/change-password/password-feedback.ts` | `formatChangePasswordError({code, message})` maps the API error code (`INVALID_OLD_PASSWORD` / `PASSWORDS_DO_NOT_MATCH` / `PASSWORD_POLICY` / `UNAUTHORIZED`) to user-facing copy. |
|
||||
| `ChangePassword` (validators) | `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` (group-level reactive-forms validator) + `PasswordPolicy` zod schema used by the modal. |
|
||||
1. Reads the access token from `AuthService`.
|
||||
2. Sends `Accept: text/event-stream`, optional `Authorization: Bearer <token>`,
|
||||
`credentials: 'include'`, and an `AbortSignal`.
|
||||
3. Reads the response body with a stream reader and decodes UTF-8 chunks.
|
||||
4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and
|
||||
dispatches `MessageEvent` objects.
|
||||
5. Buffers records received before the message listener is attached.
|
||||
6. Aborts and clears listeners when `close()` is called.
|
||||
|
||||
# Guards and interceptors
|
||||
`EventStatusStore` parses each message as `EventStatePayload`, updates its signals,
|
||||
and runs a one-second local timer. `HomeComponent` stops the store on destruction.
|
||||
|
||||
| Symbol | Path | Purpose |
|
||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits `BootstrapService.ready()` and `AuthService.waitUntilHydrated()`, then delegates to `decideAuthRedirect`. |
|
||||
| `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
|
||||
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
||||
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
||||
| `landingGuard` | `frontend/src/app/core/guards/landing.guard.ts` | Async `CanActivateFn` for `/login`; awaits bootstrap + auth hydration, then delegates to the pure `decideLandingGuard`. Redirects to `/bootstrap` (not initialized) or `/` (already authenticated). |
|
||||
| `decideLandingGuard`| `frontend/src/app/core/guards/landing.guard.decision.ts` | Pure decision function: returns `true` to render the landing page, or `UrlTree` to `/bootstrap` / `/`. |
|
||||
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
||||
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
||||
# Key integration files
|
||||
|
||||
Both interceptors are wired in `frontend/src/main.ts` via
|
||||
`provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))`
|
||||
(notably CSRF runs first so the token is attached before the auth header).
|
||||
|
||||
# Bootstrap flow
|
||||
|
||||
1. `AppComponent.ngOnInit()` → `BootstrapService.load()` then
|
||||
`AuthService.restoreSession()`. Both are deduplicated so concurrent
|
||||
callers share a single in-flight promise (`loadPromise` /
|
||||
`hydrateWaiters`).
|
||||
2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with
|
||||
`credentials: 'include'`).
|
||||
3. The payload sets `initialized` (true iff any admin user exists),
|
||||
`pageTitle`, `logo`, `welcomeMarkdown`, the active `theme`, the
|
||||
`defaultChallengeIp`, the `registrationsEnabled` flag, and the
|
||||
`passwordPolicy` descriptor consumed by the change-password modal.
|
||||
4. Theme tokens are written to CSS custom properties on
|
||||
`document.documentElement` (`--color-primary`, `--font-family`,
|
||||
`--radius-*`, etc.) consumed by `frontend/src/styles.css`.
|
||||
5. `AuthService.restoreSession()` rehydrates the in-memory signals from
|
||||
`sessionStorage` (key `hipctf.auth.v1`) and POSTs
|
||||
`/api/v1/auth/refresh` with `withCredentials: true` to mint a fresh
|
||||
access token. On success it persists the new token; on failure it
|
||||
clears storage. Either way it flips `hydrated()` so the guards can
|
||||
unblock.
|
||||
6. The `authGuard` (and `adminGuard`) `await` `BootstrapService.ready()`
|
||||
and `AuthService.waitUntilHydrated()` before reading `initialized()`
|
||||
/ `isAuthenticated()`, which prevents a flash of `/bootstrap` or
|
||||
`/login` on a hard refresh of a deep link.
|
||||
|
||||
# Authenticated shell flow
|
||||
|
||||
After successful auth, `HomeComponent` renders a full shell layout:
|
||||
|
||||
1. **`ShellHeaderComponent`** is fed (signal inputs) the page title
|
||||
from bootstrap, the active section label (computed from the current
|
||||
router URL), the `EventStatusStore.state()` (used to colour the
|
||||
LED — `running` / `countdown` / `stopped` / `unconfigured`), and the
|
||||
`countdownText()` (`DD:HH:mm` or `"Event ended"`). The user menu
|
||||
trigger opens an inline dropdown with `Rank: <r> - Points: <p>`,
|
||||
`Change password`, optional `Admin area` (visible only when
|
||||
`canAccessAdmin()` is true), and `Logout`.
|
||||
2. **`QuickTabsComponent`** renders `Challenges / Scoreboard / Blog`
|
||||
tabs. The `active` input is computed from the current URL
|
||||
(`HomeComponent.activeTabId()`). Clicking a tab emits `tabChange`
|
||||
and the parent calls `router.navigateByUrl('/' + id)` (or `/admin`
|
||||
for `id === 'admin'`).
|
||||
3. The `<router-outlet>` renders whichever child route is active
|
||||
(`ChallengesPage`, `ScoreboardPage`, `BlogPage`, or `AdminUsersComponent`).
|
||||
4. On `ngOnInit`, `HomeComponent` calls
|
||||
`UserStore.hydrateFromAuth()` + `UserStore.loadMe()` (which calls
|
||||
`AuthService.me()` → `GET /api/v1/auth/me`) and starts the SSE
|
||||
consumer via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`.
|
||||
The store parses each `message` frame, applies the state via
|
||||
`applyServerStatus(payload)`, and ticks the local
|
||||
`secondsToStart` / `secondsToEnd` signals every second.
|
||||
5. On `ngOnDestroy` (and via `DestroyRef.onDestroy` for safety),
|
||||
`HomeComponent` calls `EventStatusStore.stop()` to close the SSE
|
||||
source and clear the tick interval.
|
||||
6. Clicking `Change password` in the header opens the
|
||||
`ChangePasswordModalComponent` (mode `'self'`). Submitting posts
|
||||
`/api/v1/auth/change-password` via `AuthService.changePassword()`;
|
||||
on success the modal closes and the user's existing refresh tokens
|
||||
are revoked server-side. See the
|
||||
[Change Password Guide](/guides/change-password.md) for the full
|
||||
tester flow.
|
||||
7. Clicking `Logout` calls `AuthService.logout()` (which POSTs
|
||||
`/api/v1/auth/logout`), resets the `UserStore`, and navigates to
|
||||
`/login`.
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `frontend/src/app/app.routes.ts` | Registers shell and child routes. |
|
||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Adds Bearer authentication to Angular HTTP requests. |
|
||||
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. |
|
||||
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
|
||||
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
|
||||
|
||||
# See also
|
||||
|
||||
- [System Overview](/architecture/overview.md)
|
||||
- [Backend Module Map](/architecture/backend-modules.md)
|
||||
- [Key Files Index](/architecture/key-files.md)
|
||||
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||
- [Admin Shell & User Management](/guides/admin-shell.md)
|
||||
- [Authenticated Shell](/guides/authenticated-shell.md)
|
||||
- [Change Password](/guides/change-password.md)
|
||||
- [Event Window](/guides/event-window.md)
|
||||
- [System Overview](/architecture/overview.md)
|
||||
|
||||
Reference in New Issue
Block a user