AI Implementation feature(823): Landing Page and Login/Register Modal (#11)
This commit was merged in pull request #11.
This commit is contained in:
@@ -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)
|
||||
@@ -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` | 3–32 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user