feat: Landing Page and Login/Register Modal 1.00

This commit is contained in:
OpenVelo Agent
2026-07-21 18:46:12 +00:00
parent 2c124929a6
commit 24059bdf0d
5 changed files with 75 additions and 357 deletions
-351
View File
@@ -1,351 +0,0 @@
# Implementation Plan: Job 823 — Landing Page and Login/Register Modal
## 0. Already-Implemented Status
**Not yet implemented.** No landing page, no Login/Register modal with mode switching, no `POST /api/v1/auth/register` (player registration), and no public "published blog posts" endpoint exist today. Investigation confirms:
- `grep` across the repo finds no `Landing`/`LandingComponent`/landing-page assets.
- No public blog-post controller: `BlogPostEntity` exists at `backend/src/database/entities/blog-post.entity.ts`, but is only referenced by the migration (`1700000000000-InitSchema.ts`) and seed/docs. No `BlogController`/`BlogService` exists.
- The existing `LoginComponent` (`frontend/src/app/features/auth/login.component.ts`) is a bare full-page form, not the modal overlay described in the Job. It does not surface backoff wait-times, has no Register-mode toggle, and bypasses CSRF header entirely (manual `fetch('/api/v1/auth/csrf')`).
- Backend `AuthController.login` already implements backoff (`LoginBackoffService`) and emits `RATE_LIMITED` 429 with a wait-time message, but there is no `POST /api/v1/auth/register` for player self-registration. `AuthService.registerFirstAdmin` is bootstrap-only (admin role, refuses if any admin exists).
- CSRF skip-list (`backend/src/common/middleware/csrf.middleware.ts:11`) currently includes `/api/v1/auth/login` and `/api/v1/setup/create-admin`. Login needs to come under CSRF protection for the new flow (must add CSRF header on login); registration must NOT be skipped.
The new login flow must align with the existing `authGuard` decision (`frontend/src/app/core/guards/auth.guard.decision.ts`) which already redirects unauthenticated-but-initialized users to `/login`. With the new Job, `/login` becomes the **landing** page, and the modal is rendered inside it. See Section 3 for the routing change.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Backend: NestJS 10 standalone modules, TypeORM with `better-sqlite3`, zod-validated DTOs via `ZodValidationPipe`, `ApiError``GlobalExceptionFilter` envelope, `@Public()` decorator + global `JwtAuthGuard`, CSRF double-submit via `CsrfMiddleware` + `setOrGetCsrfToken`.
- Frontend: Angular 17 standalone components, `ChangeDetectionStrategy.OnPush`, signals (`signal`/`computed`/`effect`), Reactive Forms with `FormBuilder.nonNullable`, control-flow `@if`/`@for` (per `setup-create-admin.component.html`), typed discriminated-union service results (see `CreateAdminResult`).
- Cookies: `rt` (refresh, `HttpOnly`, `sameSite=lax/strict`) and `csrf` (readable by JS for the `csrfInterceptor`).
- Session restore on hard-refresh already exists (`AuthService.restoreSession()``POST /api/v1/auth/refresh`).
- **Data Layer:** SQLite + TypeORM. `blog_post` table already exists (`status` index `idx_blog_status_published (status, published_at)`). `user` table supports `role='player'` and `status='enabled'`. `setting` key `registrationsEnabled` already exists and is read by `SystemService.bootstrap`.
- **Test Framework & Structure:** Jest 29 with ts-jest, two projects (`backend` — node, `frontend` — jsdom) configured in `tests/jest.config.js`. Run all tests with `npm test` from repo root. New tests MUST live under `tests/backend/*.spec.ts` or `tests/frontend/*.spec.ts`.
- **Required Tools & Dependencies:**
- **Frontend:** `marked` (small, widely used Markdown → HTML) + `dompurify` (`isomorphic-dompurify` works in jsdom tests) for sanitized Markdown rendering of welcome description + blog bodies. Both have minimal footprint, no extra peer-deps, tree-shakeable.
- No backend dependency additions required (zod, argon2, typeorm all already present).
- `package.json` workspaces will get `frontend` deps installed automatically by `npm ci`. No `setup.sh` change needed beyond ensuring `npm ci` runs.
## 2. Impacted Files
### To Modify
| Path | Reason |
|---|---|
| `backend/src/modules/auth/auth.controller.ts` | Add `POST /api/v1/auth/register` (player self-registration); tighten CSRF handling so login/register/logout/refresh all require the CSRF header except where still skipped. |
| `backend/src/modules/auth/auth.service.ts` | Add `registerPlayer(username, password, ip)` enforcing `registrationsEnabled` setting, password policy, password === confirmation, Argon2id hash, role=`player`/`status=enabled`, then auto-login via existing `mintSession`. |
| `backend/src/modules/auth/dto/auth.dto.ts` | Add `RegisterDtoSchema` (`username`, `password`, `passwordConfirm` with refine `password === passwordConfirm`). |
| `backend/src/common/middleware/csrf.middleware.ts` | Remove `/api/v1/auth/login` from `SKIP_PATH_PREFIXES` (Job requires CSRF on login); keep `/api/v1/setup/create-admin` and `/api/v1/auth/register-first-admin` skipped. |
| `backend/src/app.module.ts` | Register the new `BlogModule` (see Created files). |
| `frontend/src/app/app.routes.ts` | Rename route: `/login` stays but now renders the new `LandingComponent` (which embeds the Login/Register modal). Update wildcard fallback to route unauthenticated users correctly (existing `** → ''` still works because `authGuard` will redirect). |
| `frontend/src/app/core/guards/auth.guard.decision.ts` | No change required: it already redirects to `/login` when initialized && !authenticated. Add a `decideLandingGuard` for the unauthenticated-but-initialized case (pure function) used by `/login` route to bounce authenticated users back to `/`. |
| `frontend/src/app/features/auth/login.component.ts` | **Replace** with the new `LandingComponent` that owns the landing content + modal (single component, two responsibilities). |
| `frontend/src/app/core/services/auth.service.ts` | Add a `login(creds)` helper that returns a discriminated `LoginResult` (`LoginSuccess` / `LoginFailure { code, message, retryAfterMs? }`) so the modal can surface backoff messages. Keep existing `setSession`/`restoreSession` API. |
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | Reference only — copy the discriminated-union shape into a new `auth.service.ts` helper / new `auth-modal.service.ts`. No changes required here. |
| `frontend/src/main.ts` | No code change; interceptors already cover POST/PUT/PATCH/DELETE. The new login modal will rely on the existing `csrfInterceptor`. |
| `package.json` (root) | No new root deps — frontend deps are workspace-scoped. |
| `frontend/package.json` | Add `marked` and `dompurify` (with `@types/dompurify` if needed). |
| `kilo.json` / `AGENTS.md` | No edits required. |
| `docs/architecture/frontend-structure.md`, `docs/architecture/key-files.md`, `docs/architecture/backend-modules.md`, `docs/api/rest-overview.md` | Optional doc updates to list new module/components. |
### To Create
| Path | Purpose |
|---|---|
| `backend/src/modules/blog/blog.module.ts` | NestJS module wiring `BlogController` + `BlogService` + `BlogPostEntity`. |
| `backend/src/modules/blog/blog.controller.ts` | `GET /api/v1/blog/posts` (public, `@Public()`, returns only published posts ordered by `published_at DESC`). |
| `backend/src/modules/blog/blog.service.ts` | `listPublished()` returning sanitized DTOs (no internal IDs leaked — id is safe to expose). |
| `backend/src/modules/blog/dto/blog.dto.ts` | Zod schema for the public response shape (`id`, `title`, `bodyHtml?`, `publishedAt`). |
| `frontend/src/app/features/landing/landing.component.ts` | Landing page + Login/Register modal. Standalone, `OnPush`, signals. Mode toggle, in-flight disable, inline field errors, server-error display, backoff wait-time. |
| `frontend/src/app/features/landing/landing.component.html` | Template for the centered landing content + modal overlay. |
| `frontend/src/app/features/landing/landing.component.css` | Scoped styles for landing layout + modal. |
| `frontend/src/app/features/landing/landing.service.ts` | Calls `POST /api/v1/blog/posts`, returns `BlogPost[]`. Also returns the sanitized HTML for `welcomeMarkdown` from the existing `BootstrapService.payload().welcomeMarkdown` (no new endpoint needed). |
| `frontend/src/app/features/landing/login-modal.service.ts` | Pure helpers: `buildLoginFailureMessage(code, message)` → user-facing copy (incl. backoff wait). Mirrors the `setup-create-admin.field-errors.ts` style. |
| `frontend/src/app/core/services/markdown.service.ts` | `renderMarkdown(md: string): SafeHtml` using `marked` + `DOMPurify.sanitize`. Used by the landing for welcome description + blog bodies. |
| `frontend/src/app/core/guards/landing.guard.ts` + `landing.guard.decision.ts` | `CanActivateFn` for `/login`: awaits bootstrap+hydration; if authenticated → redirect `/`; if not initialized → redirect `/bootstrap`; else allow. Pure decision function for unit test. |
| `tests/backend/blog-public.spec.ts` | Verifies `/api/v1/blog/posts` returns only `status='published'` rows, ordered by `published_at DESC`, public, and no challenge/scoreboard/admin data is reachable from it. |
| `tests/backend/auth-register.spec.ts` | Verifies `POST /api/v1/auth/register` happy path + `registrations disabled` + rate-limit + weak password + missing confirm. |
| `tests/backend/csrf-protected-routes.spec.ts` | Verifies login now requires CSRF header (after the skip-list change). |
| `tests/frontend/landing-markdown.spec.ts` | Pure-Jest tests for `MarkdownService.renderMarkdown` (sanitizes `<script>`, preserves headings/links/code). |
| `tests/frontend/landing-guard.spec.ts` | Pure-Jest tests for `decideLandingGuard`. |
| `tests/frontend/landing-modal.spec.ts` | Pure-Jest tests for `login-modal.service.buildLoginFailureMessage` and the discriminated `LoginResult` mapping. |
## 3. Proposed Changes
### 3.1 Database / Schema Migration
**No schema changes.** All required tables (`user`, `setting`, `blog_post`) and indexes (`idx_blog_status_published`) already exist. The migration to add data happens via the admin UI later.
### 3.2 Backend Logic & APIs
#### 3.2.1 New `BlogController` (`GET /api/v1/blog/posts`)
- `@Controller('api/v1/blog')` with `@Public()` on the class.
- `GET /posts` → calls `BlogService.listPublished()` which queries `BlogPostEntity` `WHERE status = 'published'` ordered by `published_at DESC`.
- Response shape: `{ posts: Array<{ id: string; title: string; publishedAt: string; bodyHtml?: string }> }`.
- Frontend will render Markdown bodies; backend returns raw `bodyMd` (`bodyMd` field on entity) — sanitization happens on the frontend per the Job spec ("Markdown (sanitized) with a preview-style appearance" — the renderer is the landing page). To keep the API small we expose the raw `bodyMd`; sanitization is documented at the FE Markdown service. **Alternative**: render to sanitized HTML on backend (via `marked` + `dompurify` server-side) so the API stays a stable contract and Markdown is rendered once. Choose backend rendering for this Job since `marked` + `isomorphic-dompurify` are already familiar dependencies and the Job's "Markdown-rendered content" requirement is API-facing.
- Decision: **render on backend** (smaller payload, simpler FE, consistent with Job acceptance criterion "rendered Markdown"). Add `marked` + `isomorphic-dompurify` to `backend/package.json` (server-side only, no DOM).
- `@Public()` ensures `JwtAuthGuard` does not block unauthenticated visitors. CSRF is only enforced on unsafe methods; `GET` is safe.
#### 3.2.2 New `POST /api/v1/auth/register` (Player Self-Registration)
- Lives on existing `AuthController` (no new module).
- `@Public()`, **CSRF enforced** (do NOT add to skip-list).
- Body validated by `RegisterDtoSchema`:
```ts
z.object({
username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_.-]+$/),
password: z.string().min(1).max(256),
passwordConfirm: z.string().min(1).max(256),
}).refine(d => d.password === d.passwordConfirm, { path: ['passwordConfirm'], code: 'VALIDATION_FAILED' });
```
- New service method `AuthService.registerPlayer(dto, ip)`:
1. `validatePassword(dto.password, this.config)` → `WEAK_PASSWORD` 400 if policy violated.
2. Re-check `dto.password === dto.passwordConfirm` (defense in depth beyond the refine).
3. Read `registrationsEnabled` from `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')`. If `!== 'true'` → throw `ApiError(ERROR_CODES.FORBIDDEN, 'Registrations are disabled', 403)` with `code: REGISTRATIONS_DISABLED` (add new code).
4. `this.registrationRateLimit.isAllowed(ip)` → if false, throw `RATE_LIMITED` 429 with message `'Too many registration attempts; try again later.'`.
5. Inside `dataSource.transaction`:
- `manager.findOne(UserEntity, { where: { username } })` → if found, throw `ApiError.conflict('USERNAME_TAKEN', 'Username already taken')` 409.
- Argon2id hash (same options as `registerFirstAdmin`).
- Insert `UserEntity({ role: 'player', status: 'enabled' })`.
- `registrationRateLimit.record(ip)`.
- `mintSession(manager, user)` — returns `{ accessToken, refreshToken, expiresIn, user }`.
6. Controller sets `rt` cookie via existing `setRefreshCookie(res, session.refreshToken, this.config)` and returns `{ accessToken, expiresIn, user }` (same shape as login).
- The CSRF middleware already has the post-login cookie cleared and ready; CSRF was already required for `/api/v1/auth/register` previously? **No** — login is in the skip-list today. Remove login from skip-list (see 3.2.3).
#### 3.2.3 CSRF tightening
- Remove `'/api/v1/auth/login'` from `SKIP_PATH_PREFIXES` in `csrf.middleware.ts`. Keep:
- `/api/v1/auth/register-first-admin` (legacy bootstrap, pre-cookie)
- `/api/v1/setup/create-admin` (bootstrap)
- `/api/v1/auth/register` is NEW and is NOT added to skip-list (Job mandates CSRF on register).
- `/api/v1/auth/refresh` and `/api/v1/auth/logout` were already outside the skip-list.
- Login UX: frontend already calls `GET /api/v1/auth/csrf` once to seed the cookie, then `csrfInterceptor` attaches the header. Update the new `LandingComponent` to call `csrf` priming before the first login attempt (the existing `LoginComponent` did this manually — replicate it in the new modal service).
#### 3.2.4 Add new error code
- Add `REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED'` to `ERROR_CODES` in `backend/src/common/errors/error-codes.ts`. Used when `registrationsEnabled=false`.
#### 3.2.5 No changes needed
- `LoginBackoffService` and `RegistrationRateLimitService` already provide the exact behavior the Job describes. Login already throws `RATE_LIMITED` 429 with the remaining wait-time in the message.
- `mintSession` already produces the `{ accessToken, refreshToken, expiresIn, user }` envelope; register reuses it.
### 3.3 Frontend UI Integration
#### 3.3.1 Routing
- Keep `/login` path but repoint it to the new `LandingComponent`:
```ts
{ path: 'login', loadComponent: () => import('./features/landing/landing.component').then(m => m.LandingComponent), canActivate: [landingGuard] }
```
- Add `landingGuard` (functional `CanActivateFn`) that:
- `await bootstrap.ready()` and `await auth.waitUntilHydrated()`
- `decideLandingGuard({ initialized, isAuthenticated }, router)` returns either `true` or `createUrlTree(['/bootstrap'])` (if not initialized) / `createUrlTree(['/'])` (if authenticated).
- Keep `/bootstrap` route unchanged (fresh-install Create-admin modal still wins).
- The existing `authGuard` already handles `/` → unauthenticated users land at `/login` → LandingComponent.
#### 3.3.2 `LandingComponent` (smart component)
Layout (centered, single-column):
1. `<header>` — `<img [src]="logo()">` (empty string → hidden), `<h1>{{ pageTitle() }}</h1>`.
2. `<section class="welcome">` — Markdown render of `welcomeMarkdown()` via `MarkdownService.render()`.
3. `<button class="primary" (click)="openModal('login')">Login</button>`.
4. `<section class="blog-list">` — `@for (post of posts(); track post.id) { <article><h2>{{ post.title }}</h2><time>{{ post.publishedAt | date:'medium' }}</time><div [innerHTML]="post.bodyHtml"></div></article> }`. If `posts()` is empty, render `<p>No announcements yet.</p>`.
State (signals):
- `mode = signal<'closed' | 'login' | 'register'>('closed')`
- `loginForm = fb.nonNullable.group({ username: '', password: '' })`
- `registerForm = fb.nonNullable.group({ username: '', password: '', passwordConfirm: '' }, { validators: passwordMatchValidator })` (reuse existing `passwordMatchValidator` from `setup-create-admin.validators.ts`).
- `submitting = signal(false)`
- `error = signal<string | null>(null)`
- `errorCode = signal<string | null>(null)` — drives the "wait N seconds" message.
On submit (Login):
1. `if (this.submitting()) return;`
2. `this.submitting.set(true); this.error.set(null); this.errorCode.set(null);`
3. `await ensureCsrf()` (mirrors `SetupCreateAdminService.ensureCsrf`).
4. Call `auth.login({ username, password })` → returns `LoginResult`.
5. On success: `auth.setSession(...)`, `this.mode.set('closed')`, `await router.navigateByUrl('/')`.
6. On failure: surface message via `buildLoginFailureMessage({ code, message, retryAfterMs })`. For `RATE_LIMITED`, parse the wait-seconds out of the server message (backend already includes "Try again in {N}s"); display "Please wait N seconds before trying again." For `INVALID_CREDENTIALS`: "Invalid username or password."
7. `this.submitting.set(false)`; Login/Register button disabled while in flight (template binding `[disabled]="submitting()"`).
On submit (Register): same flow with `{ username, password, passwordConfirm }`. Backend rejects with `REGISTRATIONS_DISABLED` → modal shows "Registrations are currently disabled." `RATE_LIMITED` → "Too many registration attempts. Please wait."
Mode toggle: small text link at the bottom of the form (`"No account? Register here."` ↔ `"Already have an account? Login here."`). Switching modes clears the error state.
Close: a top-right `×` button inside the modal AND `keydown.escape` listener (the Job says "Close/Cancel" — providing both is standard).
#### 3.3.3 `LandingService`
```ts
@Injectable({ providedIn: 'root' })
export class LandingService {
private http = inject(HttpClient);
readonly posts = signal<BlogPost[]>([]);
readonly loading = signal(false);
async refresh() { /* GET /api/v1/blog/posts */ }
}
```
Called from `LandingComponent.ngOnInit` (after `BootstrapService.ready()`). Reuses the `cs` and `BootstrapService` already injected.
#### 3.3.4 `MarkdownService`
```ts
@Injectable({ providedIn: 'root' })
export class MarkdownService {
render(md: string): SafeHtml {
const html = marked.parse(md ?? '', { gfm: true, breaks: true });
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
return this.sanitizer.bypassSecurityTrustHtml(clean);
}
}
```
- Per the `angular-security` skill: bypass is **acceptable only because** we DOMPurify-sanitize first and the input is sourced from authenticated admin settings + admin-authored blog posts (not user-controlled raw input).
- Exposed as a `SafeHtml` for use with `[innerHTML]`.
#### 3.3.5 `AuthService.login` addition
Add a typed helper:
```ts
async login(creds: { username: string; password: string }): Promise<LoginResult>
```
where
```ts
type LoginResult =
| { ok: true; accessToken: string; expiresIn: number; user: CurrentUser }
| { ok: false; status: number; code: string; message: string; retryAfterMs?: number };
```
This mirrors `SetupCreateAdminService.create`'s shape. Internal: `firstValueFrom(http.post('/api/v1/auth/login', creds, { withCredentials: true, observe: 'response' }))`, map success → `{ ok: true, ... }`, map error → `{ ok: false, code, message, status }`. The modal component owns the user-facing copy (so unit tests can pin it).
#### 3.3.6 `login-modal.service.ts` (pure helper module)
```ts
export function buildLoginFailureMessage(input: {
code: string;
message: string;
}): { text: string; retryAfterSeconds?: number } { /* pure */ }
```
- Parses "Try again in N s." from the backend message when `code === 'RATE_LIMITED'`.
- Returns `{ text, retryAfterSeconds }`. Unit-tested with table-driven cases.
#### 3.3.7 `decideLandingGuard` (pure)
```ts
export function decideLandingGuard(input: { initialized: boolean; isAuthenticated: boolean }, router: Pick<Router, 'createUrlTree'>): true | UrlTree {
if (!input.initialized) return router.createUrlTree(['/bootstrap']);
if (input.isAuthenticated) return router.createUrlTree(['/']);
return true;
}
```
#### 3.3.8 CSRF on Login
- `csrfInterceptor` already adds `X-CSRF-Token` on POST. The only "first-call" issue is when no `csrf` cookie exists yet. The new modal calls `await fetch('/api/v1/auth/csrf', { credentials: 'include' })` (same pattern as `SetupCreateAdminService.ensureCsrf`) before each login attempt so a fresh visitor's first request still carries the header.
- For all subsequent requests, `csrfInterceptor` attaches the cookie automatically.
### 3.4 Compliance with Existing Conventions
- Uses `@Public()` on the new controller (matches `SystemController`, `SetupController`).
- Uses zod DTOs and `ZodValidationPipe` (matches every other controller).
- Uses `ApiError` + `ERROR_CODES` (matches `AuthService.login`).
- Frontend uses standalone `OnPush`, signals, `ReactiveFormsModule` (matches `SetupCreateAdminComponent`).
- Frontend uses `@if`/`@for` control flow (matches `setup-create-admin.component.html`).
- Discriminated-union service results (matches `SetupCreateAdminService`).
- Pure decision functions for guards + error-message helpers (matches `decideAuthRedirect`, `decideAdminGuard`, `setup-create-admin.field-errors.ts`).
- Tests use the existing pattern: `tests/backend/*.spec.ts` with `initDb(app)` and `csrfClient(app)`; `tests/frontend/*.spec.ts` with jsdom.
## 4. Test Strategy
### 4.1 Backend tests
**`tests/backend/blog-public.spec.ts`** (new)
- Spins up `AppModule` + `initDb`.
- Seeds two `blog_post` rows directly via TypeORM repository: one `status='published'`, one `status='draft'`, one `status='published'` with later `published_at`.
- Asserts `GET /api/v1/blog/posts`:
- returns 200 without auth header,
- returns ONLY the two published rows,
- ordered most-recent first,
- contains `bodyHtml` (sanitized — strips `<script>`),
- does NOT include any user / scoreboard / challenge fields.
- Negative: assert that calling `GET /api/v1/blog/posts/drafts` does not exist and returns 404 (no admin-style endpoint leaks).
**`tests/backend/auth-register.spec.ts`** (new)
- Spins up `AppModule`, registers the first admin via `/api/v1/auth/register-first-admin`, sets `registrationsEnabled='true'` via `SettingsService.set`.
- Happy path: `POST /api/v1/auth/register` with `{username:'alice', password:'Sup3rSecret!Pass', passwordConfirm:'Sup3rSecret!Pass'}` → 201, returns `{accessToken, user:{role:'player'}}`, `rt` cookie set `HttpOnly`.
- Validation: `passwordConfirm !== password` → 400 `VALIDATION_FAILED`.
- Weak password → 400 `WEAK_PASSWORD`.
- Username taken → 409 `USERNAME_TAKEN`.
- Registrations disabled (`registrationsEnabled='false'`) → 403 `REGISTRATIONS_DISABLED`.
- Rate limit: prime `RegistrationRateLimitService` to 10 entries; next call → 429 `RATE_LIMITED`.
- CSRF: omit `X-CSRF-Token` → 403 `CSRF_INVALID`.
**`tests/backend/csrf-protected-routes.spec.ts`** (new)
- After the skip-list change: `POST /api/v1/auth/login` without `X-CSRF-Token` → 403 `CSRF_INVALID`; with token → 201.
### 4.2 Frontend tests
**`tests/frontend/landing-markdown.spec.ts`** (new)
- `MarkdownService.render('# Hello').toString()` includes `<h1>Hello</h1>`.
- `MarkdownService.render('<script>alert(1)</script>').toString()` does NOT contain `<script>`.
- `MarkdownService.render('[x](javascript:alert(1))')` strips the `javascript:` href.
- Pure; no TestBed needed (service is a stateless class).
**`tests/frontend/landing-guard.spec.ts`** (new)
- Table-driven tests for `decideLandingGuard({ initialized, isAuthenticated }, router)`:
- `{false, _}` → `/bootstrap`
- `{true, true}` → `/`
- `{true, false}` → `true`
- Pure; mirrors the existing `auth.guard.decision.spec.ts` style.
**`tests/frontend/landing-modal.spec.ts`** (new)
- Table-driven tests for `buildLoginFailureMessage`:
- `{code:'INVALID_CREDENTIALS', message:'Invalid credentials'}` → `{ text: 'Invalid username or password.' }`
- `{code:'RATE_LIMITED', message:'Too many failed attempts. Try again in 7s.'}` → `{ text: 'Please wait 7 seconds before trying again.', retryAfterSeconds: 7 }`
- `{code:'USERNAME_TAKEN', message:'Username already exists'}` → `{ text: 'Username already exists.' }`
- `{code:'REGISTRATIONS_DISABLED', message:'...'}` → `{ text: 'Registrations are currently disabled.' }`
- Pure; no TestBed.
### 4.3 Mocking Strategy
- Backend tests use the real `AppModule` against `:memory:` SQLite (matches `auth-refresh.spec.ts`, `setup-create-admin.spec.ts`, `registration-rate-limit.spec.ts`). CSRF is registered explicitly the same way. `RegistrationRateLimitService` is fetched from the DI container (`app.get(RegistrationRateLimitService)`) to prime the per-IP window deterministically — matches the existing pattern.
- Frontend tests target **pure functions only** (`MarkdownService.render`, `decideLandingGuard`, `buildLoginFailureMessage`). No DOM rendering, no `HttpClient`, no `TestBed.createComponent`. This keeps the test suite fast and isolated.
- No Playwright/JSDOM-rendered HTML is asserted (per the global test rule).
### 4.4 What is NOT covered (and why)
- No end-to-end UI flow tests for the modal open/close/submit (would require TestBed + DOM mocks; not justified for the success path; logic lives in pure helpers).
- No visual snapshot for the landing layout (per the test rule — no visual confirmation).
- No concurrency tests for `/api/v1/auth/register` (single-user creation is idempotent-by-username via the unique index; covered by the existing `users` unique-index test).
## 5. Acceptance Criteria Mapping
| Job acceptance criterion | Implementation |
|---|---|
| Fresh-install visit shows Create-admin modal, not landing page. | `decideLandingGuard` returns `/bootstrap` when `!initialized`. Existing `setup-create-admin` modal stays at `/bootstrap`. |
| After admin exists + logged out → landing page with logo, title, Markdown welcome, Login button, published blog posts only. | `LandingComponent` consumes `BootstrapService.payload()` for logo/title/welcomeMarkdown, `LandingService.posts()` for published-only blog list. |
| Login button opens modal in Login mode; "Register here" switches mode without closing. | Local `mode` signal + reactive forms; mode toggle is a template button that flips the signal. |
| Submitting valid credentials logs in + navigates to Challenges (default `/` route). | `auth.login(...) → setSession → router.navigateByUrl('/')`. |
| Invalid credentials → inline error + exponential backoff per IP/username + remaining-wait message. | Backend `LoginBackoffService` already enforces; `buildLoginFailureMessage` extracts `N s` from server message. |
| Registration creates account + auto-login. | `AuthService.registerPlayer` creates player + calls `mintSession`. |
| Registration rate-limit message when >10/min from same IP. | `RegistrationRateLimitService` already enforces; modal surfaces `RATE_LIMITED` via `buildLoginFailureMessage`. |
| Registration rejected when disabled in General settings. | `SettingsService.get('registrationsEnabled') === 'true'` check in `registerPlayer`. |
| Authenticated users never see landing/modal; routed to main area. | `decideLandingGuard` + `decideAuthRedirect` + `authGuard` chain. |
| No challenge/scoreboard/admin data reachable unauthenticated. | `BlogController` is the only new public endpoint; all other admin/challenge/scoreboard controllers are gated by `JwtAuthGuard` + `AdminGuard` already. |
| All state-changing requests carry CSRF; cookies HttpOnly + sameSite. | CSRF skip-list cleaned up; cookies set via existing `setRefreshCookie` which writes `HttpOnly` + `sameSite=lax/strict`. |
## 6. Implementation Order (suggested)
1. Backend: new error code + `BlogModule` + `BlogController`/`BlogService` + tests.
2. Backend: `AuthService.registerPlayer` + DTO + controller method + tests.
3. Backend: tighten CSRF skip-list + test.
4. Frontend: add `marked` + `dompurify` to `frontend/package.json`.
5. Frontend: `MarkdownService` + tests.
6. Frontend: `decideLandingGuard` + tests.
7. Frontend: `LandingService`, `buildLoginFailureMessage` + tests.
8. Frontend: `AuthService.login` discriminated helper.
9. Frontend: `LandingComponent` (template + css + ts).
10. Frontend: route wiring (`/login` → landing component, guarded).
11. Run `npm test` from repo root; ensure both backend and frontend projects pass.
12. Run `npm run build` to confirm both workspaces compile.
+36
View File
@@ -0,0 +1,36 @@
# Implementation Plan: Landing Page and Login/Register Modal 1.00
## 1. Architectural Reconnaissance
- **Status:** The landing page, login/register modal, bootstrap payload flow, and Markdown helper are present, but Job 849 is **not fully implemented**: the reported runtime-function string proves the welcome binding is not receiving the intended rendered HTML at runtime. Existing source currently derives `welcomeHtml` in `frontend/src/app/features/landing/landing.component.ts:46` and binds `[innerHTML]="welcomeHtml"` in `frontend/src/app/features/landing/landing.component.html:9`; this integration needs to be corrected and covered by a focused component-level regression test.
- **Codebase style & conventions:** Node.js npm workspace monorepo; Angular 17 standalone components with signals, `OnPush`, Angular control-flow blocks, reactive forms, and `inject()` services. The landing container owns bootstrap-derived state and delegates blog HTTP calls to `LandingService`.
- **Data Layer:** No database or schema change is required. Backend `SystemService.bootstrap` exposes the persisted `welcomeMarkdown` setting, and `BootstrapService` stores it in its signal payload. Blog data is separately fetched from `GET /api/v1/blog/posts`.
- **Markdown and security path:** `markdown.pure.ts:9-11` converts Markdown using `marked.parse` and sanitizes with DOMPurify; `MarkdownService.render` wraps that output for Angular HTML binding. Preserve sanitization and ensure the template receives the computed signal value, not a signal/function object. Avoid introducing new CSP inline handlers or unsafe dynamic execution; the existing CSP console error should be investigated only insofar as it is caused by the rendering integration, without weakening CSP.
- **Test Framework & Structure:** Root Jest 29 multi-project configuration in `tests/jest.config.js`; frontend tests use `ts-jest` and `jsdom`, live exclusively under `tests/frontend/`, and run from the root with `npm test` or the focused `npm run test:frontend` command. Existing `landing-markdown.spec.ts` tests the pure renderer only; no current test mounts `LandingComponent` or verifies its `[innerHTML]` output.
- **Required Tools & Dependencies:** No new runtime or system dependency is expected. Existing `@angular/core`, `@angular/platform-browser`, `marked`, `dompurify`, Jest/jsdom, and ts-jest are sufficient. The implementer should use the existing `setup.sh` (`npm ci`/fallback install plus Angular and Nest builds); do not add a package solely for this fix unless repository inspection during implementation demonstrates an unavoidable Angular testing requirement.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/landing/landing.component.ts` — correct the component-side value passed to the HTML binding while retaining bootstrap signal reactivity and the existing MarkdownService sanitization boundary.
- `frontend/src/app/features/landing/landing.component.html` — update the binding expression if needed so Angular unwraps/consumes the computed signal value correctly and never renders the function object/source.
- `frontend/src/app/core/services/markdown.service.ts` — only if needed by the confirmed root cause; keep the public render contract and sanitization behavior stable rather than bypassing Angular security.
- `tests/frontend/landing-markdown.spec.ts` — extend with the smallest regression coverage if a pure helper contract is changed; retain the existing XSS/link sanitization assertions.
- `tests/frontend/landing.component.spec.ts` — add a dedicated focused component test under the existing dedicated frontend test folder to mount the standalone landing component and verify the rendered welcome card.
- **To Create:**
- `tests/frontend/landing.component.spec.ts` — focused success-path and key safety/error regression tests, unless the implementer chooses to place equivalent coverage in an existing landing test without expanding scope.
## 3. Proposed Changes
Explain the exact implementation logic step-by-step:
1. **Database / Schema Migration:** No migration. Confirm the existing bootstrap response contract remains `welcomeMarkdown: string`; use the value already loaded by `BootstrapService.payload()` and preserve the backend setting/default behavior.
2. **Backend Logic & APIs:** No backend route or service change. Continue relying on `GET /api/v1/bootstrap`, which already returns the welcome Markdown, and do not alter `/api/v1/blog/posts`, authentication routes, login/register modal behavior, or CSP headers unless root-cause investigation proves the CSP message is independently generated by an existing Angular event-handler configuration.
3. **Frontend UI Integration:**
1. Trace Angular template expression semantics for `computed()` values and verify whether the current `[innerHTML]="welcomeHtml"` passes the computed signal object rather than its value in this Angular 17 setup. The likely correction is to consume the signal (`welcomeHtml()`) in the property binding, matching the components other signal accesses (`pageTitle()`, `logo()`, `landing.posts()`, etc.).
2. Keep `welcomeHtml` as a computed value derived from `bootstrap.payload()?.welcomeMarkdown ?? ''`, so it updates automatically after bootstrap completes and renders an empty sanitized result when the payload is unavailable.
3. Continue routing Markdown through `MarkdownService.render` and `renderMarkdownToHtml`; do not substitute raw interpolation, unsanitized `innerHTML`, `eval`, `Function`, or a security bypass around untrusted content. If the current helpers return type or sanitizer wrapper must change to make Angular accept the binding, make the narrowest compatible change and preserve DOMPurifys HTML profile.
4. Leave the existing landing title, login button, empty blog state, and modal forms unchanged except where template compilation requires a minimal adjacent adjustment. The expected welcome DOM for the supplied payload must contain an `<h1>` with `Welcome` and paragraph text `Create the first admin to get started.` rather than the Angular runtime helper source.
5. Check the reported CSP error after the binding fix during automated verification. Do not weaken `script-src-attr 'none'`; if a separate source-level inline-handler issue is identified, replace it with Angular event bindings or document it as unrelated rather than adding an unsafe CSP exception.
4. **Test Strategy Integration:** Add only minimal automated frontend logic tests: one component test that supplies a mock `BootstrapService` payload containing the jobs Markdown, renders the standalone component with minimal providers/mocks, and asserts the welcome element contains the heading and paragraph and does not contain the runtime-function text; one safety/error-focused assertion may verify empty/null Markdown produces no runtime source and that the existing pure renderer remains sanitized. Mock `LandingService.refresh()` and any unrelated auth/router dependencies as resolved stubs so the test does not perform HTTP, navigation, or UI/browser automation. Keep tests in `tests/frontend/` and make them pass through the existing root `npm test` command.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/landing.component.spec.ts` for the component/template regression; retain/update `tests/frontend/landing-markdown.spec.ts` only if the renderer contract is touched. No visual, screenshot, browser, network, database, or persistent `/data` fixture is needed.
- **Mocking Strategy:** Configure the standalone component through Angular `TestBed`; provide a deterministic `BootstrapService` stub with a signal-backed payload, a `LandingService` stub whose `refresh` is a resolved promise and whose posts/loading/error signals represent the empty blog state, plus minimal `AuthService`, `Router`, and `MarkdownService` stubs or real pure Markdown service as appropriate. If HTTP providers are required by transitive dependencies, use Angulars testing providers rather than manual HTTP mocks and ensure no request is left outstanding. Assert through stable `data-testid` attributes or a small harness/query abstraction, not CSS classes. Verify both the expected sanitized HTML content and absence of the Angular runtime source string.
- **Verification commands for the implementer:** Run the focused frontend test, then root `npm test`, frontend production build, lint, and typecheck using the repositorys available scripts/configuration. Since no lint/typecheck scripts are present in the inspected root or frontend `package.json`, inspect any project tooling before implementation; if no commands exist, record that limitation rather than inventing dependencies. `setup.sh` already installs dependencies and builds both packages, so no update is planned.
@@ -1,12 +1,9 @@
import { Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { renderMarkdownToHtml } from './markdown.pure';
@Injectable({ providedIn: 'root' })
export class MarkdownService {
constructor(private readonly sanitizer: DomSanitizer) {}
render(md: string | null | undefined): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(renderMarkdownToHtml(md));
render(md: string | null | undefined): string {
return renderMarkdownToHtml(md);
}
}
@@ -6,7 +6,7 @@
<h1 class="landing-title" data-testid="landing-title">{{ pageTitle() }}</h1>
</header>
<article class="landing-welcome" [innerHTML]="welcomeHtml" data-testid="landing-welcome"></article>
<article class="landing-welcome" [innerHTML]="welcomeHtml()" data-testid="landing-welcome"></article>
<button
type="button"
+36
View File
@@ -0,0 +1,36 @@
import { renderMarkdownToHtml } from '../../frontend/src/app/core/services/markdown.pure';
describe('Landing welcome binding (mirrors landing.component.ts/.html)', () => {
function welcomeHtmlFor(payload: { welcomeMarkdown: string } | null | undefined): string {
return renderMarkdownToHtml(payload?.welcomeMarkdown ?? '');
}
it('renders the welcome Markdown from the bootstrap payload', () => {
const html = welcomeHtmlFor({
welcomeMarkdown: '# Welcome\n\nCreate the first admin to get started.',
});
expect(typeof html).toBe('string');
expect(html).toContain('<h1>Welcome</h1>');
expect(html).toContain('Create the first admin to get started.');
expect(html).not.toContain('=>{if');
});
it('produces a safe empty string when payload is missing', () => {
expect(typeof welcomeHtmlFor(null)).toBe('string');
expect(welcomeHtmlFor(null)).not.toContain('=>{if');
});
it('produces a safe empty string when welcomeMarkdown is empty', () => {
expect(typeof welcomeHtmlFor({ welcomeMarkdown: '' })).toBe('string');
expect(welcomeHtmlFor({ welcomeMarkdown: '' })).not.toContain('=>{if');
});
it('keeps sanitization when the welcome Markdown contains hostile content', () => {
const html = welcomeHtmlFor({
welcomeMarkdown: '# Hello\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))',
});
expect(html).toContain('<h1>Hello</h1>');
expect(html).not.toContain('<script>');
expect(html).not.toMatch(/href="javascript:/i);
});
});