---
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** — ` ` rendered from
`BootstrapService.payload().logo` (empty string → the ` ` is not
rendered).
2. **Page title** — `
` rendered from
`BootstrapService.payload().pageTitle` (defaults to `HIPCTF`).
3. **Welcome card** — `` 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** — ``.
5. **Blog list** — `` 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` | 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.
# 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)