119 lines
6.0 KiB
Markdown
119 lines
6.0 KiB
Markdown
---
|
||
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)
|