AI Implementation feature(823): Landing Page and Login/Register Modal (#11)

This commit was merged in pull request #11.
This commit is contained in:
2026-07-21 18:34:45 +00:00
parent 8476e4a59f
commit 685a8bca84
51 changed files with 2354 additions and 226 deletions
+351
View File
@@ -0,0 +1,351 @@
# 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.
-148
View File
@@ -1,148 +0,0 @@
# Implementation Plan: Job 848 — First-Start Create Admin Modal 1.07
## Status
**[ALREADY_IMPLEMENTED]**
The First-Start Create Admin Modal feature is fully implemented end-to-end in
this repository. The user-facing symptom reported in the job ("HIPCTF heading +
'Frontend not built.' paragraph, no bootstrap dialog, no form controls, no
submit button") is produced by the SPA fallback in
`backend/src/frontend/spa.controller.ts:27` whenever
`frontend/dist/browser/index.html` does not exist on disk at the moment
the request is served.
That `dist/browser/index.html` is now present (rebuilt during the current
session), so navigating to `http://localhost:3000/` will serve the Angular
SPA, which redirects to `/bootstrap` and renders the create-admin modal.
No source code changes, no schema changes, and no new tests are required.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Monorepo with npm workspaces (`backend/`, `frontend/`).
- Backend: NestJS controllers + services, TypeORM repositories,
`@nestjs/config`, argon2 for password hashing, class-validator /
`ZodValidationPipe` for input, `ApiError` + `GlobalExceptionFilter`
for the uniform `{ code, message }` error envelope.
- Frontend: Angular 17+ standalone components, `ChangeDetectionStrategy.OnPush`,
Angular Signals, reactive forms (`ReactiveFormsModule`), HTTP via
`HttpClient` with `withCredentials: true`, separate
`BootstrapService` (initialised flag) and `AuthService` (session token).
- Persistence: SQLite via TypeORM, file path comes from
`process.env.DATABASE_PATH` (default `/data/hipctf/hipctf.db`),
`FRONTEND_DIST` env var names the built SPA directory.
- **Data Layer:** SQLite (TypeORM). No schema change is needed.
- **Test Framework & Structure:**
- Jest with two projects (`backend`, `frontend`) configured in
`tests/jest.config.js`.
- Backend tests use `ts-jest` against an in-memory SQLite DB
(`process.env.DATABASE_PATH = ':memory:'`) via
`tests/backend/db-helper.ts`.
- Frontend tests use `jest-environment-jsdom` with
`tests/frontend/jest.setup.ts` providing Angular `TestBed` +
`provideHttpClientTesting`.
- All tests live under `tests/` and run via a single root command:
`npm test` (alias for `jest --config tests/jest.config.js`).
- **Required Tools & Dependencies:** No new tools. Existing `npm run build`
in `setup.sh` rebuilds `frontend/dist/browser/index.html`, which is the
actual fix for the symptom. No new packages are required.
## 2. Impacted Files
- **To Modify:** None.
- **To Create:** None.
### Where the feature already lives (for reference / verification)
Backend:
- `backend/src/modules/setup/setup.module.ts`
- `backend/src/modules/setup/setup.controller.ts``POST /api/v1/setup/create-admin`.
- `backend/src/modules/setup/setup.service.ts:39-97``createAdmin()` inside a
TypeORM transaction with serialized bootstrap via `runBootstrap()`
(lines 99-130) — guarantees that two concurrent callers cannot both
observe an empty admin table; the loser gets
`409 SYSTEM_INITIALIZED`.
- `backend/src/modules/setup/dto/create-admin.dto.ts`
- `backend/src/common/errors/error-codes.ts``SYSTEM_INITIALIZED`,
`USERNAME_TAKEN`, `WEAK_PASSWORD`, `VALIDATION_FAILED`, `RATE_LIMITED`.
- `backend/src/frontend/spa.controller.ts:11-28` — SPA fallback that emits
the placeholder HTML **only** when no built `index.html` is found.
Frontend:
- `frontend/src/app/features/setup/setup-create-admin.component.ts` — the
modal component (`data-testid="setup-submit"`, `setup-retry`, etc.).
- `frontend/src/app/features/setup/setup-create-admin.component.html`
- `frontend/src/app/features/setup/setup-create-admin.component.css`
- `frontend/src/app/features/setup/setup-create-admin.service.ts`
`POST /api/v1/setup/create-admin` with CSRF preflight + error mapping.
- `frontend/src/app/features/setup/setup-create-admin.validators.ts`
`passwordMatchValidator`.
- `frontend/src/app/features/setup/setup-create-admin.field-errors.ts`
pure helpers `usernameError`, `passwordError`, `confirmError`.
- `frontend/src/app/core/services/bootstrap.service.ts` — exposes
`initialized()`, `markInitialized()`, and `passwordPolicy()`.
- `frontend/src/app/core/services/auth.service.ts``setSession()` used
on success.
- `frontend/src/app/app.routes.ts``/bootstrap` route, guarded by
`authGuard` which redirects to `/bootstrap` while `initialized() === false`.
Existing tests that already cover this Job:
- `tests/backend/setup-create-admin.spec.ts`
- `tests/backend/bootstrap.integration.spec.ts`
- `tests/backend/registration-rate-limit.spec.ts`
- `tests/backend/api-error.spec.ts`
- `tests/frontend/setup-create-admin.spec.ts`
- `tests/frontend/guard-bootstrap-race.spec.ts`
- `tests/frontend/auth-restore.spec.ts`
## 3. Proposed Changes
No code changes. The only operational fix is to keep
`frontend/dist/browser/index.html` in place.
If a fresh container runs without it, `setup.sh` already invokes
`npm --workspace frontend run build` (line 11) which produces the Angular
bundle under `frontend/dist/browser/index.html`. The
`SpaFallbackMiddleware` looks for the file in both
`frontend/dist/index.html` and `frontend/dist/browser/index.html`, so the
standard build output is resolved without any extra wiring.
## 4. Test Strategy
No new tests. The relevant behaviour is already covered:
- **Backend:** `tests/backend/setup-create-admin.spec.ts` exercises
`POST /api/v1/setup/create-admin` happy path + each documented error
(`SYSTEM_INITIALIZED`, `USERNAME_TAKEN`, `WEAK_PASSWORD`,
`VALIDATION_FAILED`, `RATE_LIMITED`).
- **Backend concurrency:** the `runBootstrap()` chain in
`SetupService` is what serializes concurrent first-admin requests;
`tests/backend/bootstrap.integration.spec.ts` exercises the bootstrap
endpoint end-to-end.
- **Frontend:** `tests/frontend/setup-create-admin.spec.ts` covers the
modal's reactive form, server-error states, retry button, and the
post-success navigation. `tests/frontend/guard-bootstrap-race.spec.ts`
covers the `authGuard` redirect race with `BootstrapService`.
Run the existing suite from the repo root with:
```
npm test
```
## 5. Why this is reported as "not built"
`SpaFallbackMiddleware` (`backend/src/frontend/spa.controller.ts:27`)
sends:
```
<!doctype html><html><body><h1>HIPCTF</h1><p>Frontend not built.</p></body></html>
```
when neither `frontend/dist/index.html` nor `frontend/dist/browser/index.html`
exists. The Angular CLI's `application` builder writes to
`dist/browser/index.html`, so after `npm --workspace frontend run build`
the file is present and the SPA is served. The reported symptom was a
snapshot taken before the build artefact existed; rebuilding resolves it.