28 KiB
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:
grepacross the repo finds noLanding/LandingComponent/landing-page assets.- No public blog-post controller:
BlogPostEntityexists atbackend/src/database/entities/blog-post.entity.ts, but is only referenced by the migration (1700000000000-InitSchema.ts) and seed/docs. NoBlogController/BlogServiceexists. - 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 (manualfetch('/api/v1/auth/csrf')). - Backend
AuthController.loginalready implements backoff (LoginBackoffService) and emitsRATE_LIMITED429 with a wait-time message, but there is noPOST /api/v1/auth/registerfor player self-registration.AuthService.registerFirstAdminis 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/loginand/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 viaZodValidationPipe,ApiError→GlobalExceptionFilterenvelope,@Public()decorator + globalJwtAuthGuard, CSRF double-submit viaCsrfMiddleware+setOrGetCsrfToken. - Frontend: Angular 17 standalone components,
ChangeDetectionStrategy.OnPush, signals (signal/computed/effect), Reactive Forms withFormBuilder.nonNullable, control-flow@if/@for(persetup-create-admin.component.html), typed discriminated-union service results (seeCreateAdminResult). - Cookies:
rt(refresh,HttpOnly,sameSite=lax/strict) andcsrf(readable by JS for thecsrfInterceptor). - Session restore on hard-refresh already exists (
AuthService.restoreSession()→POST /api/v1/auth/refresh).
- Backend: NestJS 10 standalone modules, TypeORM with
- Data Layer: SQLite + TypeORM.
blog_posttable already exists (statusindexidx_blog_status_published (status, published_at)).usertable supportsrole='player'andstatus='enabled'.settingkeyregistrationsEnabledalready exists and is read bySystemService.bootstrap. - Test Framework & Structure: Jest 29 with ts-jest, two projects (
backend— node,frontend— jsdom) configured intests/jest.config.js. Run all tests withnpm testfrom repo root. New tests MUST live undertests/backend/*.spec.tsortests/frontend/*.spec.ts. - Required Tools & Dependencies:
- Frontend:
marked(small, widely used Markdown → HTML) +dompurify(isomorphic-dompurifyworks 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.jsonworkspaces will getfrontenddeps installed automatically bynpm ci. Nosetup.shchange needed beyond ensuringnpm ciruns.
- Frontend:
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→ callsBlogService.listPublished()which queriesBlogPostEntityWHERE status = 'published'ordered bypublished_at DESC.- Response shape:
{ posts: Array<{ id: string; title: string; publishedAt: string; bodyHtml?: string }> }. - Frontend will render Markdown bodies; backend returns raw
bodyMd(bodyMdfield 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 rawbodyMd; sanitization is documented at the FE Markdown service. Alternative: render to sanitized HTML on backend (viamarked+dompurifyserver-side) so the API stays a stable contract and Markdown is rendered once. Choose backend rendering for this Job sincemarked+isomorphic-dompurifyare 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-dompurifytobackend/package.json(server-side only, no DOM).
- Decision: render on backend (smaller payload, simpler FE, consistent with Job acceptance criterion "rendered Markdown"). Add
@Public()ensuresJwtAuthGuarddoes not block unauthenticated visitors. CSRF is only enforced on unsafe methods;GETis 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: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):validatePassword(dto.password, this.config)→WEAK_PASSWORD400 if policy violated.- Re-check
dto.password === dto.passwordConfirm(defense in depth beyond the refine). - Read
registrationsEnabledfromSettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false'). If!== 'true'→ throwApiError(ERROR_CODES.FORBIDDEN, 'Registrations are disabled', 403)withcode: REGISTRATIONS_DISABLED(add new code). this.registrationRateLimit.isAllowed(ip)→ if false, throwRATE_LIMITED429 with message'Too many registration attempts; try again later.'.- Inside
dataSource.transaction:manager.findOne(UserEntity, { where: { username } })→ if found, throwApiError.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 }.
- Controller sets
rtcookie via existingsetRefreshCookie(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/registerpreviously? 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'fromSKIP_PATH_PREFIXESincsrf.middleware.ts. Keep:/api/v1/auth/register-first-admin(legacy bootstrap, pre-cookie)/api/v1/setup/create-admin(bootstrap)
/api/v1/auth/registeris NEW and is NOT added to skip-list (Job mandates CSRF on register)./api/v1/auth/refreshand/api/v1/auth/logoutwere already outside the skip-list.- Login UX: frontend already calls
GET /api/v1/auth/csrfonce to seed the cookie, thencsrfInterceptorattaches the header. Update the newLandingComponentto callcsrfpriming before the first login attempt (the existingLoginComponentdid this manually — replicate it in the new modal service).
3.2.4 Add new error code
- Add
REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED'toERROR_CODESinbackend/src/common/errors/error-codes.ts. Used whenregistrationsEnabled=false.
3.2.5 No changes needed
LoginBackoffServiceandRegistrationRateLimitServicealready provide the exact behavior the Job describes. Login already throwsRATE_LIMITED429 with the remaining wait-time in the message.mintSessionalready produces the{ accessToken, refreshToken, expiresIn, user }envelope; register reuses it.
3.3 Frontend UI Integration
3.3.1 Routing
- Keep
/loginpath but repoint it to the newLandingComponent:{ path: 'login', loadComponent: () => import('./features/landing/landing.component').then(m => m.LandingComponent), canActivate: [landingGuard] } - Add
landingGuard(functionalCanActivateFn) that:await bootstrap.ready()andawait auth.waitUntilHydrated()decideLandingGuard({ initialized, isAuthenticated }, router)returns eithertrueorcreateUrlTree(['/bootstrap'])(if not initialized) /createUrlTree(['/'])(if authenticated).
- Keep
/bootstraproute unchanged (fresh-install Create-admin modal still wins). - The existing
authGuardalready handles/→ unauthenticated users land at/login→ LandingComponent.
3.3.2 LandingComponent (smart component)
Layout (centered, single-column):
<header>—<img [src]="logo()">(empty string → hidden),<h1>{{ pageTitle() }}</h1>.<section class="welcome">— Markdown render ofwelcomeMarkdown()viaMarkdownService.render().<button class="primary" (click)="openModal('login')">Login</button>.<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> }. Ifposts()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 existingpasswordMatchValidatorfromsetup-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):
if (this.submitting()) return;this.submitting.set(true); this.error.set(null); this.errorCode.set(null);await ensureCsrf()(mirrorsSetupCreateAdminService.ensureCsrf).- Call
auth.login({ username, password })→ returnsLoginResult. - On success:
auth.setSession(...),this.mode.set('closed'),await router.navigateByUrl('/'). - On failure: surface message via
buildLoginFailureMessage({ code, message, retryAfterMs }). ForRATE_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." ForINVALID_CREDENTIALS: "Invalid username or password." 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
@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
@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-securityskill: 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
SafeHtmlfor use with[innerHTML].
3.3.5 AuthService.login addition
Add a typed helper:
async login(creds: { username: string; password: string }): Promise<LoginResult>
where
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)
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)
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
csrfInterceptoralready addsX-CSRF-Tokenon POST. The only "first-call" issue is when nocsrfcookie exists yet. The new modal callsawait fetch('/api/v1/auth/csrf', { credentials: 'include' })(same pattern asSetupCreateAdminService.ensureCsrf) before each login attempt so a fresh visitor's first request still carries the header.- For all subsequent requests,
csrfInterceptorattaches the cookie automatically.
3.4 Compliance with Existing Conventions
- Uses
@Public()on the new controller (matchesSystemController,SetupController). - Uses zod DTOs and
ZodValidationPipe(matches every other controller). - Uses
ApiError+ERROR_CODES(matchesAuthService.login). - Frontend uses standalone
OnPush, signals,ReactiveFormsModule(matchesSetupCreateAdminComponent). - Frontend uses
@if/@forcontrol flow (matchessetup-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.tswithinitDb(app)andcsrfClient(app);tests/frontend/*.spec.tswith jsdom.
4. Test Strategy
4.1 Backend tests
tests/backend/blog-public.spec.ts (new)
- Spins up
AppModule+initDb. - Seeds two
blog_postrows directly via TypeORM repository: onestatus='published', onestatus='draft', onestatus='published'with laterpublished_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/draftsdoes 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, setsregistrationsEnabled='true'viaSettingsService.set. - Happy path:
POST /api/v1/auth/registerwith{username:'alice', password:'Sup3rSecret!Pass', passwordConfirm:'Sup3rSecret!Pass'}→ 201, returns{accessToken, user:{role:'player'}},rtcookie setHttpOnly. - Validation:
passwordConfirm !== password→ 400VALIDATION_FAILED. - Weak password → 400
WEAK_PASSWORD. - Username taken → 409
USERNAME_TAKEN. - Registrations disabled (
registrationsEnabled='false') → 403REGISTRATIONS_DISABLED. - Rate limit: prime
RegistrationRateLimitServiceto 10 entries; next call → 429RATE_LIMITED. - CSRF: omit
X-CSRF-Token→ 403CSRF_INVALID.
tests/backend/csrf-protected-routes.spec.ts (new)
- After the skip-list change:
POST /api/v1/auth/loginwithoutX-CSRF-Token→ 403CSRF_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 thejavascript: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.tsstyle.
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
AppModuleagainst:memory:SQLite (matchesauth-refresh.spec.ts,setup-create-admin.spec.ts,registration-rate-limit.spec.ts). CSRF is registered explicitly the same way.RegistrationRateLimitServiceis 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, noHttpClient, noTestBed.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 existingusersunique-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)
- Backend: new error code +
BlogModule+BlogController/BlogService+ tests. - Backend:
AuthService.registerPlayer+ DTO + controller method + tests. - Backend: tighten CSRF skip-list + test.
- Frontend: add
marked+dompurifytofrontend/package.json. - Frontend:
MarkdownService+ tests. - Frontend:
decideLandingGuard+ tests. - Frontend:
LandingService,buildLoginFailureMessage+ tests. - Frontend:
AuthService.logindiscriminated helper. - Frontend:
LandingComponent(template + css + ts). - Frontend: route wiring (
/login→ landing component, guarded). - Run
npm testfrom repo root; ensure both backend and frontend projects pass. - Run
npm run buildto confirm both workspaces compile.