7.8 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| guide | Landing Page | 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. |
|
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:
- Logo —
<img data-testid="landing-logo">rendered fromBootstrapService.payload().logo(empty string → the<img>is not rendered). - Page title —
<h1 data-testid="landing-title">rendered fromBootstrapService.payload().pageTitle(defaults toHIPCTF). - Welcome card —
<article data-testid="landing-welcome">whoseinnerHTMLis bound to the computed signalwelcomeHtml(), i.e. the sanitized HTML render ofBootstrapService.payload().welcomeMarkdown(viaMarkdownService.render→renderMarkdownToHtml→marked+DOMPurify). The binding must invoke the signal (welcomeHtml()), not the signal object — see Key Files Index for thelanding.component.htmlentry that pins this. - Login button —
<button data-testid="landing-open-login">. - Blog list —
<section data-testid="landing-blog">listing every row returned byGET /api/v1/blog/posts(onlystatus='published'rows), or the textNo announcements yet.when the list is empty. Each row is rendered by the sharedBlogPresenterComponentwith 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:
submitLogin()→AuthService.login({ username, password }).- On
ok: true,AuthService.setSession(...)is called and the user is navigated to/. - On
ok: false, the modal renders the mapped failure text and a(Ns remaining)hint whencode === '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:
LandingComponent.ngOnInit()callsBootstrapService.refresh()so the public landing page always fetches the latest bootstrap payload before rendering the modal. This covers users who reach/loginvia a hard refresh or fresh tab and have not previously subscribed to the SSE channel.BootstrapEventService(started byAppComponent.ngOnInit) opens the publicGET /api/v1/event/streamSSE channel and listens for{ topic: 'general' }frames. Whenever the admin saves a setting viaPUT /api/v1/admin/general/settings, the backend emits ageneralhub event withthemeKeyandregistrationsEnabled; the public SSE forwards it on/api/v1/event/streamand the SPA callsBootstrapService.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.