Files
HIPCTF2/docs/guides/landing-page.md
T

151 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: guide
title: Landing Page
description: How the public landing page renders, how the login + registration modal is opened, what each form does, and how the modal stays in sync with admin `registrationsEnabled` changes via SSE.
tags: [guide, landing, login, register, ui, sse, bootstrap]
timestamp: 2026-07-23T10:12:24Z
---
# 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 bound to the computed signal `welcomeHtml()`, i.e. the
sanitized HTML render of
`BootstrapService.payload().welcomeMarkdown` (via
`MarkdownService.render``renderMarkdownToHtml``marked` +
`DOMPurify`). The binding **must** invoke the signal (`welcomeHtml()`),
not the signal object — see [Key Files Index](/architecture/key-files.md)
for the `landing.component.html` entry that pins this.
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.
Each row is rendered by the shared `BlogPresenterComponent` with its
title, publication date, and sanitized Markdown body, matching `/blog`.
# 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.
# Reacting to admin changes
The Login/Register modal reflects the value of
`bootstrap.payload().registrationsEnabled`, which is loaded once at app
boot by `BootstrapService.load()`. Two paths keep the modal consistent
with the API:
1. `LandingComponent.ngOnInit()` calls `BootstrapService.refresh()` so
the public landing page always fetches the latest bootstrap payload
before rendering the modal. This covers users who reach `/login`
via a hard refresh or fresh tab and have not previously subscribed to
the SSE channel.
2. `BootstrapEventService` (started by `AppComponent.ngOnInit`) opens
the public `GET /api/v1/event/stream` SSE channel and listens for
`{ topic: 'general' }` frames. Whenever the admin saves a setting via
`PUT /api/v1/admin/general/settings`, the backend emits a `general`
hub event with `themeKey` and `registrationsEnabled`; the public SSE
forwards it on `/api/v1/event/stream` and the SPA calls
`BootstrapService.refresh()` to re-apply the new flag (and theme).
The pure helper `landingModalShowsRegister(registrationsEnabled)` in
`frontend/src/app/features/landing/login-modal.service.ts` mirrors the
HTML predicate and is asserted in
`tests/frontend/landing-modal.spec.ts`.
# 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)
- [Blog Publishing and Reading](/guides/blog.md)
- [First-Run Bootstrap](/guides/bootstrap.md)