AI Implementation feature(823): Landing Page and Login/Register Modal (#11)

This commit was merged in pull request #11.
This commit is contained in:
2026-07-21 18:34:45 +00:00
parent 8476e4a59f
commit 685a8bca84
51 changed files with 2354 additions and 226 deletions
+45
View File
@@ -0,0 +1,45 @@
---
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
---
# Configuration
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. |
Both are seeded by `SeedSystemData1700000000100` and editable through
`SettingsService`.
# State computation
`EventStatusService.getStatus(now)` (`backend/src/common/services/event-status.service.ts`)
returns one of three branches:
| Condition | `status` | `countdownMs` |
|-------------------------|----------|----------------------|
| `now < eventStartUtc` | `Stopped` (countdown) | `eventStartUtc - now` |
| `now ∈ [start, end]` | `Running` | `eventEndUtc - now` |
| `now >= eventEndUtc` | `Stopped` (ended) | `0` |
# 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.
# See also
- [Auth and Settings Tables](/database/auth-settings.md)
- [System Endpoints](/api/system.md)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
+118
View File
@@ -0,0 +1,118 @@
---
type: guide
title: Landing Page
description: How the public landing page renders, how the login + registration modal is opened, and what each form does.
tags: [guide, landing, login, register, ui]
timestamp: 2026-07-21T18:28:00Z
---
# What you see
`LandingComponent` (`frontend/src/app/features/landing/landing.component.{ts,html,css}`)
is the route mounted at `/login` (the path is historic — the page is now
the public landing page, not a bare login form).
On a fresh visit the page shows, in order:
1. **Logo**`<img data-testid="landing-logo">` rendered from
`BootstrapService.payload().logo` (empty string → the `<img>` is not
rendered).
2. **Page title**`<h1 data-testid="landing-title">` rendered from
`BootstrapService.payload().pageTitle` (defaults to `HIPCTF`).
3. **Welcome card**`<article data-testid="landing-welcome">` whose
`innerHTML` is the sanitized render of
`BootstrapService.payload().welcomeMarkdown` (via
`MarkdownService.render``marked` + `DOMPurify`).
4. **Login button**`<button data-testid="landing-open-login">`.
5. **Blog list**`<section data-testid="landing-blog">` listing every
row returned by `GET /api/v1/blog/posts` (only `status='published'`
rows), or the text `No announcements yet.` when the list is empty.
# Guard
`/login` is gated by `landingGuard`
(`frontend/src/app/core/guards/landing.guard.ts`):
| `initialized` | `isAuthenticated` | Result |
|---------------|-------------------|-----------------------------------|
| `false` | any | Redirect to `/bootstrap` (first-admin modal). |
| `true` | `true` | Redirect to `/` (already signed in). |
| `true` | `false` | Render the landing page. |
# Login modal
Clicking the **Login** button sets `mode = 'login'`, which renders an
overlay containing:
| Field | Selector (`data-testid`) | Notes |
|------------|--------------------------|-----------------------------------------|
| Username | `login-username` | `autocomplete="username"` |
| Password | `login-password` | `autocomplete="current-password"` |
| Field error| `login-field-error` | Inline `required` message. |
| Server error | `login-server-error` | Mapped via `buildLoginFailureMessage`. |
| Submit | `login-submit` | Disabled while `submitting()` is true. |
| Switch | `login-switch-btn` | Visible only when `registrationsEnabled` is true; opens the registration modal. |
Submission flow:
1. `submitLogin()``AuthService.login({ username, password })`.
2. On `ok: true`, `AuthService.setSession(...)` is called and the user
is navigated to `/`.
3. On `ok: false`, the modal renders the mapped failure text and a
`(Ns remaining)` hint when `code === 'RATE_LIMITED'` and the server
message contains a wait-seconds value.
# Registration modal
When `bootstrap.payload().registrationsEnabled === true`, the **Login**
modal shows a "Register here" link (`login-switch-btn`) that swaps the
form to `mode = 'register'`. The registration form contains:
| Field | Selector (`data-testid`) | Notes |
|-----------------|--------------------------|------------------------------------------------------|
| Username | `register-username` | 332 chars, regex `^[a-zA-Z0-9_.-]+$`. |
| Password | `register-password` | Argon2id policy enforced server-side. |
| Confirm password| `register-confirm` | Must match (group-level `passwordMatchValidator`). |
| Field error | `register-field-error` | Inline message covering required / minLength / maxLength / pattern / mismatch. |
| Server error | `register-server-error` | Warn-styled when `code` is `RATE_LIMITED` or `REGISTRATIONS_DISABLED`. |
| Submit | `register-submit` | Disabled while submitting. |
| Switch | `register-switch-btn` | Goes back to the login form. |
Submission calls `AuthService.register(...)` which posts to
`POST /api/v1/auth/register`. On `ok: true` the user is signed in
immediately and navigated to `/`.
The modal is dismissible via the **×** button (`landing-modal-close`),
clicking the overlay backdrop, or pressing `Escape`. Submit-in-flight
dismissal is suppressed.
# Server error code → user copy
`buildLoginFailureMessage` (`frontend/src/app/features/landing/login-modal.service.ts`)
maps the API error envelope into modal copy:
| Code | Rendered text |
|----------------------------|------------------------------------------------------------|
| `INVALID_CREDENTIALS` | `Invalid username or password.` |
| `RATE_LIMITED` | `Please wait N seconds before trying again.` + `(Ns remaining)` hint when `N` is found in the server message. |
| `CSRF_INVALID` | `Session expired. Please reload the page and try again.` |
| `USERNAME_TAKEN` | `Username already exists.` |
| `WEAK_PASSWORD` | `Password does not meet the security policy.` |
| `REGISTRATIONS_DISABLED` | `Registrations are currently disabled.` |
| `VALIDATION_FAILED` | `Please check the form fields and try again.` |
| other / default | Raw `message` (or `Something went wrong. Please try again.` if empty). |
# CSRF
`AuthService.ensureCsrf()` (`frontend/src/app/core/services/auth.service.ts`)
calls `GET /api/v1/auth/csrf` before both `login()` and `register()` to
materialise the `csrf` cookie if it isn't already set. The call swallows
errors because the CSRF middleware also mints the cookie on the
response to the unsafe call itself.
# See also
- [Auth Endpoints](/api/auth.md)
- [Database Schema Overview](/database/schema.md) (`blog_post`)
- [Frontend Structure](/architecture/frontend-structure.md)
- [First-Run Bootstrap](/guides/bootstrap.md)
+37
View File
@@ -0,0 +1,37 @@
---
type: guide
title: Scoreboard Stream
description: How live solves are broadcast to the SPA over Server-Sent Events.
tags: [guide, scoreboard, sse, stream]
timestamp: 2026-07-21T18:28:00Z
---
# Endpoint
`GET /api/v1/scoreboard/stream` (`@Sse()` handler in
`backend/src/modules/system/system.controller.ts`) opens an SSE stream
that pushes the public scoreboard projection every time a new solve is
recorded.
# Push path
1. The challenge solve path (admin validation, player submit) updates the
`solve` table.
2. The same code path calls `SseHubService.publish('scoreboard', payload)`
(`backend/src/common/services/sse-hub.service.ts`).
3. Every subscriber to `/api/v1/scoreboard/stream` receives the payload.
The hub is in-process; multi-replica deployments need a shared pub/sub
to fan out across pods (not in scope for this repo).
# Frontend consumer
The authenticated SPA shell subscribes on mount and updates the scoreboard
view whenever a new event arrives. The exact rendering lives inside the
authenticated shell's scoreboard component (see
[Frontend Structure](/architecture/frontend-structure.md)).
# See also
- [Event Window](/guides/event-window.md)
- [System Endpoints](/api/system.md)
+50
View File
@@ -0,0 +1,50 @@
---
type: guide
title: Theming
description: How the 10 canonical themes are loaded, validated, and applied to the SPA via CSS custom properties.
tags: [guide, theming, design]
timestamp: 2026-07-21T18:28:00Z
---
# Theme catalog
HIPCTF ships 10 canonical themes defined as JSON files in
`backend/themes/` (`01-classic.json``10-monochrome.json`). The full
list lives in `backend/src/common/types/theme-ids.ts`:
`classic`, `midnight`, `sunset`, `forest`, `cyber`, `paper`, `crimson`,
`ocean`, `neon`, `monochrome`.
Each theme is a flat map of CSS custom properties (colors, radii, font
family) consumed by `frontend/src/styles.css`.
# Backend loading
`ThemeLoaderService` (`backend/src/common/utils/theme-loader.service.ts`)
runs on module init:
1. Reads every `*.json` file in `backend/themes/`.
2. Validates each against the `Theme` interface
(`backend/src/common/types/theme.ts`) — unknown theme ids, malformed
JSON, or missing tokens throw `ApiError(THEME_INVALID)`.
3. Backfills any of the 10 canonical themes that are missing from disk
(safety net so a partial checkout still boots).
4. Reads `SETTINGS_KEYS.THEME_KEY` (default `'classic'`). If the value is
not in `THEME_IDS`, the loader falls back to `classic`, logs a
warning, and persists `'classic'` back to settings so the next request
is self-healing.
The resolved theme object is sent to the SPA in the
`GET /api/v1/bootstrap` payload (see [System Endpoints](/api/system.md)).
# Frontend application
`BootstrapService` (`frontend/src/app/core/services/bootstrap.service.ts`)
writes each theme token onto `document.documentElement` (e.g.
`--color-primary`, `--radius-md`, `--font-family`). Because `styles.css`
references the tokens via `var(--token)`, swapping themes is instant.
# See also
- [System Endpoints](/api/system.md)
- [Backend Module Map](/architecture/backend-modules.md)