AI Implementation feature(845): First-Start Create Admin Modal 1.04 #7

Merged
m0rph3us1987 merged 2 commits from feature-845-1784652096971 into dev 2026-07-21 16:49:50 +00:00
12 changed files with 393 additions and 244 deletions
-217
View File
@@ -1,217 +0,0 @@
# Implementation Plan: Job 844 — First-Start Create Admin Modal 1.03
## Status
**[ALREADY_IMPLEMENTED]**
The "first-start create admin modal" feature described in this Job is already
fully implemented in the repository. The "Frontend not built." response at
`/bootstrap` observed by the operator is an environment artifact, not a missing
feature. No application code changes are required.
The remaining sections of this plan therefore serve as a **verification
guide** so the implementer can confirm the existing implementation works as
the Job expects, plus a minimal test to pin the validation behavior of the
empty-form submission (the only piece that is not already covered by
`tests/frontend/setup-create-admin.spec.ts`).
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Monorepo (npm workspaces) with two packages: `backend/` (NestJS 10 +
TypeORM + better-sqlite3) and `frontend/` (Angular 17 standalone
components, reactive forms, signals, OnPush change detection).
- Backend uses zod DTOs validated via a custom `ZodValidationPipe` and a
`GlobalExceptionFilter` that returns a standard error envelope.
- Frontend uses Angular signals for state, `data-testid` hooks for
e2e/test selectors, and `OnPush` change detection throughout.
- All components are standalone (no NgModules); routing is in
`frontend/src/app/app.routes.ts`.
- **Data Layer:**
- SQLite via TypeORM with `better-sqlite3` driver.
- Single `user` table (`backend/src/database/entities/user.entity.ts`)
with `role` (`'admin' | 'player'`) and `status`
(`'enabled' | 'disabled'`) columns. Unique index on `username`.
- Persistent named volume mounted at `/data/hipctf/` (db file:
`/data/hipctf/db.sqlite`; uploads dir: `/data/hipctf/uploads`).
- Migrations live in
`backend/src/database/migrations/1700000000000-InitSchema.ts` and
`1700000000100-SeedSystemData.ts`; applied by
`DatabaseInitService.init()` at boot.
- **Test Framework & Structure:**
- Jest 29 with `ts-jest`, configured in `tests/jest.config.js`.
- Two projects: `backend` (node env) and `frontend` (jsdom env).
- All tests live under `tests/` (never co-located with source).
- Single command from repo root: `npm test` (also exposed as
`npm run test:backend` and `npm run test:frontend`).
- **Required Tools & Dependencies:** No new tools required. The Job does not
require adding any package, CLI utility, or system binary. Existing
toolchain (Node, npm, Angular CLI invoked via `npm --workspace`, NestJS
build via `npm --workspace backend run build`) is sufficient.
## 2. Impacted Files
Because the feature is already implemented, **no files need to be modified
or created**. The mapping below proves where each requirement lives so the
implementer (and the reviewer) can audit the existing implementation:
| Job requirement | Already implemented at |
|---|---|
| Modal rendered at `/bootstrap` when uninitialized | `frontend/src/app/app.routes.ts` (route `/bootstrap``SetupCreateAdminComponent`) + `frontend/src/app/core/guards/auth.guard.ts` + `auth.guard.decision.ts` (redirects to `/bootstrap` when `initialized() === false`) |
| Bootstrap payload exposes `initialized` flag | `backend/src/modules/system/system.controller.ts` (`GET /api/v1/bootstrap`) + `backend/src/modules/system/system.service.ts` (`initialized: adminCount > 0`) |
| SPA fallback for client routes | `backend/src/frontend/spa.controller.ts` (`SpaFallbackMiddleware`) + `backend/src/main.ts` lines 96102 (`express.static(frontendDist)` + `express.static(browserDir)`) |
| Modal overlay markup + three fields | `frontend/src/app/features/setup/setup-create-admin.component.html` (`[data-testid="setup-username"]`, `setup-password`, `setup-password-confirm`, `setup-submit`, `setup-username-error`, `setup-confirm-error`, `setup-server-error`, `setup-retry`) |
| Reactive form with required + length + pattern + match validators | `frontend/src/app/features/setup/setup-create-admin.component.ts` lines 4860 + `frontend/src/app/features/setup/setup-create-admin.validators.ts` (`passwordMatchValidator`) |
| Empty / partial submission is rejected client-side without firing the request (modal stays open) | `frontend/src/app/features/setup/setup-create-admin.component.ts` lines 99102 (`this.form.markAllAsTouched(); if (this.form.invalid) return;`) |
| Submit button enabled while invalid (only disabled while in-flight) | `setup-create-admin.component.html` lines 5466 (`[disabled]="submitting()"`) |
| Non-dismissible modal (backdrop click swallowed, Esc swallowed) | `setup-create-admin.component.ts` lines 8997 (`swallowEscape` HostListener + `swallowBackdrop` no-op) |
| Backend endpoint + zod validation (incl. `password === passwordConfirm`) | `backend/src/modules/setup/setup.controller.ts` + `backend/src/modules/setup/setup.service.ts` + `backend/src/modules/setup/dto/create-admin.dto.ts` |
| Returns 404 once an admin exists (route hidden) | `backend/src/modules/setup/setup.service.ts` (`SetupService.createAdmin` throws `NOT_FOUND` when admin row exists) |
## 3. Proposed Changes
### 3.1 Application code
**None.** The Job's requirements are entirely satisfied by existing code.
### 3.2 Why the operator saw "Frontend not built."
`SpaFallbackMiddleware` (`backend/src/frontend/spa.controller.ts` lines 1727)
serves the SPA only when an `index.html` is found inside either
`<FRONTEND_DIST>/index.html` or `<FRONTEND_DIST>/browser/index.html`. The
Angular 17 CLI emits the production bundle under `frontend/dist/browser/`,
which is exactly the second candidate. Therefore the "Frontend not built."
stub is only emitted when **neither candidate exists** — i.e. when the
frontend was never built (or was wiped) before `npm start`.
In the current workspace:
- `frontend/dist/browser/index.html` exists (built, 1143 bytes).
- `frontend/dist/3rdpartylicenses.txt` exists.
- All chunk JS files (`chunk-*.js`, `main-*.js`, `polyfills-*.js`,
`styles-*.css`) are present.
So a fresh `npm start` after `bash setup.sh` will serve the Angular SPA,
the SPA's `BootstrapService.load()` will hit `/api/v1/bootstrap`, and the
SPA will:
1. Read `initialized`. If `false`, the `authGuard` keeps the user on
`/bootstrap`, which renders `SetupCreateAdminComponent` (the modal).
2. If `true`, the user is redirected to `/login` (or `/`).
The shared `/data/hipctf/db.sqlite` volume currently contains one admin
row, so `initialized === true` and the modal does not appear against this
exact DB. To exercise the modal, the implementer must point the backend at
a **fresh** database (see §3.3).
### 3.3 Operator run-book (for verification only — no code change)
To confirm the existing implementation matches the Job's "expected
behavior" against a fresh DB without destroying the persistent `/data`
volume:
1. Build (idempotent — `setup.sh` already runs both builds):
```
bash setup.sh
```
2. Start the backend against an **isolated** DB:
```
DATABASE_PATH=/tmp/hipctf-fresh.db \
FRONTEND_DIST=$(pwd)/frontend/dist \
THEMES_DIR=$(pwd)/backend/themes \
node backend/dist/main.js
```
3. `curl -i http://localhost:3000/bootstrap` should return
`200 text/html` with the Angular `index.html` shell
(`<app-root></app-root>`), not the "Frontend not built." stub.
4. `curl -s http://localhost:3000/api/v1/bootstrap | jq .initialized`
should return `false`.
5. In a browser, visit `http://localhost:3000/`. The app redirects to
`/bootstrap` and renders the modal.
6. Click **Create admin** with all three fields empty:
- Modal stays open.
- Username field shows `Username is required`
(`data-testid="setup-username-error"`).
- Confirm field shows `Please confirm your password`
(`data-testid="setup-confirm-error"`).
- No `POST /api/v1/setup/create-admin` request is fired.
7. Fill the username with a value that violates the pattern (e.g.
`"bad name!"`) and submit: modal stays open, the username error
reads `Username may only contain letters, digits, dot, dash and underscore`.
## 4. Test Strategy
### 4.1 Existing coverage (do not duplicate)
- `tests/backend/bootstrap.integration.spec.ts` exercises
`GET /api/v1/bootstrap` (uninitialized → `initialized=false`,
initialized → `true`).
- `tests/backend/setup-create-admin.spec.ts` covers
`POST /api/v1/setup/create-admin` success, 404 once initialized, weak
password (`WEAK_PASSWORD`), `passwordConfirm` mismatch
(`VALIDATION_FAILED`), invalid username characters
(`VALIDATION_FAILED`).
- `tests/frontend/setup-create-admin.spec.ts` covers
`passwordMatchValidator` (null when either empty, null on match,
`passwordMismatch: true` on differ) and the username pattern/length
rules.
### 4.2 One new, minimal frontend test (the only gap)
The existing frontend test does not exercise `SetupCreateAdminComponent`
directly; it only tests the standalone validator and pattern rules. Add
one focused component test that pins the "empty submission is rejected
client-side without firing the request, and the modal stays open"
behavior the Job explicitly calls out. Keep it minimal — no mock backend,
no extra deps, no jsdom extensions beyond what Jest provides.
**Target file:** `tests/frontend/setup-create-admin.component.spec.ts`
(new file).
**Mocking strategy:**
- Use `TestBed.configureTestingModule` with declarations/providers for
`SetupCreateAdminComponent` only.
- Stub `SetupCreateAdminService` with a Jest mock exposing
`create = jest.fn()`. Assert it is **not** called when the form is
empty. This is sufficient to prove the client-side gate prevents the
network round-trip — no real HTTP, no `HttpClientTestingModule`.
- Stub `AuthService` with `{ setSession: jest.fn(), isAuthenticated: () => false }`.
- Stub `BootstrapService` with `{ initialized: () => false, markInitialized: jest.fn(), passwordPolicy: () => ({ minLength: 12, requireMixed: true, description: '...' }) }`.
- Stub `Router` with `{ navigateByUrl: jest.fn() }`.
- Do not mock `ReactiveFormsModule` — use the real module so the
validators run end-to-end (this is the whole point of the test).
**Assertions (single `it`):**
1. Create the component fixture, detect changes.
2. Click the submit button
(`[data-testid="setup-submit"]`) without filling any field.
3. Expect `service.create` to have been called **0** times.
4. Expect the username error element
(`[data-testid="setup-username-error"]`) to contain `Username is required`.
5. Expect the confirm error element
(`[data-testid="setup-confirm-error"]`) to contain `Please confirm your password`.
6. Expect `router.navigateByUrl` to have been called **0** times (modal
remains on `/bootstrap`).
This is the minimum surface that proves the Job's "rejects empty or
missing required fields with field validation while keeping it open"
expectation and runs in <100 ms with no infrastructure setup.
### 4.3 Verification commands
From the repo root:
```
npm test # full suite
npm run test:frontend # frontend-only
npm run test:backend # backend-only
```
All existing tests must continue to pass, and the new
`tests/frontend/setup-create-admin.component.spec.ts` must pass alongside
them.
+147
View File
@@ -0,0 +1,147 @@
# Implementation Plan: Job 845 — First-Start Create Admin Modal 1.04 (surface field validation)
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- NestJS API + Angular 17 SPA monorepo (workspaces in `package.json`).
- Frontend: **Standalone components**, `ChangeDetectionStrategy.OnPush`, Angular **signals** (`signal`/`computed`), no NgModules. Style guide mandates `OnPush` and signals for state.
- Reactive Forms via `ReactiveFormsModule` with `fb.nonNullable.group(...)` (non-nullable typed controls).
- All services are `providedIn: 'root'`. Two HTTP interceptors wired in `frontend/src/main.ts` (`csrfInterceptor`, then `authInterceptor`).
- Frontend tests are **pure-logic / no TestBed**: `tests/frontend/**/*.spec.ts` import pure functions (validators, session-storage helpers, shell decision helpers) and rely on `jest` (`testEnvironment: 'jsdom'`) runnable with the root command `npm test` (which uses `jest --config tests/jest.config.js --selectProjects frontend`).
- Strictly-typed reactive forms per `angular-forms` skill (`FormGroup<T>`, `fb.nonNullable`).
- Data attributes / `data-testid` selectors are the stable DOM hooks (see `docs/guides/bootstrap.md`).
- **Data Layer:** N/A for this Job — purely a frontend UI/validation correctness fix. The backend already returns a structured `400 VALIDATION_FAILED` envelope; the bug is solely that the SPA short-circuits on `form.invalid` without ever surfacing per-field error messages.
- **Test Framework & Structure:**
- Jest with `ts-jest`, jsdom environment for frontend.
- Tests live **only** in `tests/frontend/` (never co-located with source).
- Root command: `npm test` (runs all projects via `jest --config tests/jest.config.js`).
- Existing precedent at `tests/frontend/setup-create-admin.spec.ts` is a pure-logic suite (mirrors component logic in standalone helper functions). **We will follow that pattern** — no TestBed, no DOM rendering required, no visual confirmation.
- **Required Tools & Dependencies:** No new dependencies, system tools, or `setup.sh` changes needed. The fix is a localized source-code change in the standalone component + its template + a pure-helper module that mirrors the per-field error messages (for parity testing). The npm workspace already has `@angular/forms ^17.3` and `jest`.
## 2. Impacted Files
### To Modify
- **`frontend/src/app/features/setup/setup-create-admin.component.ts`** — fix the reactivity of the per-field error calculations and expose `passwordErrors()`. Convert the `computed(...)`s whose bodies read `FormControl` properties (which are NOT signals in Angular 17) into plain **methods** so the template re-evaluates them on every change-detection cycle (cheap, OnPush-friendly, no extra subscriptions). Alternatively keep them as computed but reflect `touched` into a local `signal()` driven by `statusChanges`/`valueChanges` + an effect; **plain methods are the minimal, idiomatic fix** and are used elsewhere in the same component style. Keep all existing public surface (`form`, `submitting`, `serverError`, `serverErrorCode`, `canRetry`, `policyDescription`, `submit`, `swallowBackdrop`, `swallowEscape`) intact.
- **`frontend/src/app/features/setup/setup-create-admin.component.html`** — render the new Password field-error block, mirror `aria-invalid` on the Password and Confirm Password inputs (Username already has it), and keep the `data-testid` hooks the bootstrap guide documents (`setup-username-error`, add `setup-password-error`, keep `setup-confirm-error`).
- **`frontend/src/app/features/setup/setup-create-admin.component.css`** — no functional change required; `.field-error` styling already exists.
- **`tests/frontend/setup-create-admin.spec.ts`** — extend the existing pure-logic suite to cover the new password-error helper and to assert that, given an all-empty (or partially-filled) post-submit state, the per-field error strings the template renders are correct.
### To Create
- **`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`** — pure helper functions that return the per-field error string (or `null`) for a given `(value, touched)` snapshot. Keeping them as pure functions (no Angular DI, no signals) is deliberate: it lets the component call them from methods **and** lets the pure-logic test suite assert them without spinning up Angular. Mirrors the pattern already used by `setup-create-admin.validators.ts` and tested in `setup-create-admin.spec.ts`.
## 3. Proposed Changes
### Root cause
Angular 17 `FormControl.touched`, `.valid`, and `.errors` are plain class properties, **not** signals. The existing `computed(() => … usernameErrors …)` (component file lines 7078 and 8087) reads those plain properties and so registers **zero signal dependencies**. Even after `submit()` calls `form.markAllAsTouched()`, the computed is not re-evaluated — its cached `null` is returned and the `@if (usernameErrors()) { … }` block stays hidden. The same applies to `confirmErrors()` for the `passwordConfirm` control. There is also no `passwordErrors()` at all and no `aria-invalid` binding on the Password/Confirm inputs, which matches the user's observation.
### 3.1 New pure helper module
`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`:
```ts
export type FieldErrorKey =
| 'required'
| 'minlength'
| 'maxlength'
| 'pattern'
| 'passwordMismatch';
export interface FieldErrorContext {
value: string;
touched: boolean;
errors: Partial<Record<FieldErrorKey, true>> | null;
groupMismatch?: boolean;
}
export function usernameError(ctx: FieldErrorContext): string | null { }
export function passwordError(ctx: FieldErrorContext): string | null { } // currently required-only; password POLICY is server-enforced
export function confirmError(ctx: FieldErrorContext): string | null { }
```
Rules encoded (single source of truth, identical to current component intent):
- **Username:**
- not touched or no errors → `null`
- `required``'Username is required'`
- `minlength``'Username must be at least 3 characters'`
- `maxlength``'Username must be at most 32 characters'`
- `pattern``'Username may only contain letters, digits, dot, dash and underscore'`
- else → `'Invalid username'`
- **Password:**
- not touched or no errors → `null`
- `required``'Password is required'`
- else → `null` (policy is intentionally server-side only; this component never surfaces a "weak password" field error pre-submit)
- **Confirm:**
- not touched and no `passwordMismatch` group error → `null`
- `required``'Please confirm your password'`
- `passwordMismatch` (group-level) → `'Passwords do not match'`
- else → `'Invalid value'`
These signatures accept the small slice of state they need so the component can pass `c.value`, `c.touched`, and `c.errors` (cast through `Partial<Record<…, true>>`) and so the test suite can call them directly without touching Angular.
### 3.2 Component changes
`setup-create-admin.component.ts`:
1. **Replace the two `computed(...)`s with plain methods** so the template re-renders them on every CD pass:
- `usernameError(): string | null { return usernameError({ value: this.form.controls.username.value, touched: this.form.controls.username.touched, errors: this.form.controls.username.errors }); }`
- `passwordError(): string | null { return passwordError({ value: this.form.controls.password.value, touched: this.form.controls.password.touched, errors: this.form.controls.password.errors }); }`**new**
- `confirmError(): string | null { return confirmError({ value: this.form.controls.passwordConfirm.value, touched: this.form.controls.passwordConfirm.touched, errors: this.form.controls.passwordConfirm.errors, groupMismatch: !!this.form.errors?.['passwordMismatch'] }); }`
2. **Delete the orphaned `usernameErrors` and `confirmErrors` signal properties** (template rewires to methods).
3. Leave `submit()` exactly as-is: `markAllAsTouched()` then early-return on invalid. The early return is now correct and observable (because `…Error()` is re-evaluated by the next CD cycle that runs after `markAllAsTouched` synchronously mutates `touched` on every control). No new `effect()` needed; this is the minimal fix.
4. Keep OnPush (`ChangeDetectionStrategy.OnPush`), keep all existing imports/decorators/HostListener for `escape` and `swallowBackdrop`. The host binding keyboard handler stays so the modal remains non-dismissible.
> Optional, NOT recommended for this Job (kept out of scope to honor "minimal changes"): a reactive alternative would be to subscribe to `statusChanges` / `valueChanges` via `toSignal()` in the constructor and gate rendering on those signals. The plain-method approach above is the minimum-surface fix that fully resolves the bug and matches the component's current style.
### 3.3 Template changes
`setup-create-admin.component.html`:
1. Username `<input>` — keep existing `[attr.aria-invalid]="!!usernameError()"` and existing `<small class="field-error">` block, but rebind to the new method name.
2. Password `<label class="field">` — add `aria-invalid` and add the missing error block below the policy hint:
```html
<input … formControlName="password"
[attr.aria-invalid]="!!passwordError()"
data-testid="setup-password" />
<small class="hint">{{ policyDescription() }}</small>
@if (passwordError()) {
<small class="field-error" data-testid="setup-password-error">{{ passwordError() }}</small>
}
```
3. Confirm `<input>` — add `aria-invalid`:
```html
<input … formControlName="passwordConfirm"
[attr.aria-invalid]="!!confirmError()"
data-testid="setup-password-confirm" />
@if (confirmError()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmError() }}</small>
}
```
4. Submit button — **unchanged**. The Job accepts either "visible per-field error messages" **or** "disabled submit button until valid" — we keep the click-to-trigger-feedback behavior (already used by `markAllAsTouched`) and now surface real errors, which is the original design intent. We deliberately do **not** disable the button on invalid (preserves parity with the existing guide which says the button is "disabled only while submitting").
### 3.4 CSS
No changes. `.field-error` color/size is already defined.
### 3.5 No backend changes
The backend's `SetupController.createAdmin` already responds with `400 VALIDATION_FAILED` and a `details` array; that contract is unchanged. The client never reached it before, and after the fix the client still won't for an empty form (which is correct — the per-field client-side errors are now visible; the server is reachable when the client actually submits valid input).
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/setup-create-admin.spec.ts` (extend the existing pure-logic file; do **not** create a co-located spec under `frontend/`).
- The existing file already exports nothing new — it lives entirely in `tests/frontend/` per project convention and runs with the root command `npm test` (Jest, jsdom).
- **Mocking Strategy:**
- **No TestBed, no HTTP, no DOM rendering.** The new helpers (`usernameError`, `passwordError`, `confirmError`) are pure functions in `setup-create-admin.field-errors.ts` — they are imported and called directly with hand-built `FieldErrorContext` literals, mirroring the existing `usernameErrors`/`isFormReadyForSubmit` helpers in the same file.
- The existing `passwordMatchValidator` tests and username rules act as the de-facto model; we extend the "Composed submit gate" suite to also assert the rendered error strings.
- **New test cases (≤ 8, focused on the bug paths):**
1. `usernameError({ value: '', touched: true, errors: { required: true } })` returns `'Username is required'`.
2. `usernameError({ value: 'ab', touched: true, errors: { minlength: true } })` returns the minlength message.
3. `passwordError({ value: '', touched: true, errors: { required: true } })` returns `'Password is required'`.
4. `passwordError({ value: 'x', touched: true, errors: null })` returns `null` (policy is server-side only; no pre-submit hint).
5. `confirmError({ value: '', touched: true, errors: { required: true } })` returns `'Please confirm your password'`.
6. `confirmError({ value: 'a', touched: true, errors: null, groupMismatch: true })` returns `'Passwords do not match'`.
7. `confirmError({ value: 'a', touched: false, errors: null })` returns `null` (no spurious visibility pre-touch).
8. `usernameError({ value: '', touched: false, errors: { required: true } })` returns `null` (no spurious visibility pre-touch).
- **How to run:** `npm test` (root). Selectors: `npm run test:frontend` for fast feedback. No browser, no screenshot, no Visual regression.
- **Out of scope (explicitly):** TestBed rendering, HTTP mock for `SetupCreateAdminService.create`, fixture-based aria assertion, and end-to-end visual confirmation — all forbidden by the test rules.
+2 -1
View File
@@ -3,7 +3,7 @@ type: api
title: Setup Endpoint
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
tags: [api, setup, bootstrap, first-admin]
timestamp: 2026-07-21T16:23:40Z
timestamp: 2026-07-21T16:48:20Z
---
# Endpoint
@@ -76,5 +76,6 @@ non-retryable inline error and offer a Retry button for everything else.
# See also
- [First-Run Bootstrap Guide](/guides/bootstrap.md)
- [Setup Form Field Errors](/guides/setup-field-errors.md)
- [Backend Module Map](/architecture/backend-modules.md)
- [Bootstrap Service](/architecture/frontend-structure.md)
+2 -2
View File
@@ -3,7 +3,7 @@ type: architecture
title: Frontend Structure
description: Angular routes, components, services, guards, and interceptors.
tags: [architecture, frontend, angular]
timestamp: 2026-07-21T15:05:00Z
timestamp: 2026-07-21T16:48:20Z
---
# Routes
@@ -29,7 +29,7 @@ All components are standalone (no NgModules). Each component imports
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, validation messages, retry handling, session initialization, and redirect to `/admin`. |
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. |
# Services
+3 -1
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index
description: One-line responsibility for every important source file in the repository.
tags: [architecture, index, key-files]
timestamp: 2026-07-21T15:05:00Z
timestamp: 2026-07-21T16:48:20Z
---
# Backend
@@ -123,6 +123,7 @@ timestamp: 2026-07-21T15:05:00Z
| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). |
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. |
| `frontend/src/app/features/setup/setup-create-admin.field-errors.ts` | Pure `usernameError` / `passwordError` / `confirmError` helpers that turn reactive-form state into the inline validation messages rendered next to each input. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell: header + conditional admin nav + `<router-outlet>`. |
| `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` predicate (unit-tested). |
@@ -135,6 +136,7 @@ timestamp: 2026-07-21T15:05:00Z
| `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. |
| `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. |
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
| `tests/frontend/setup-create-admin.spec.ts` | Pure-Jest tests for `passwordMatchValidator` and the three field-error helpers (`usernameError`, `passwordError`, `confirmError`). |
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
+3 -2
View File
@@ -3,7 +3,7 @@ type: guide
title: First-Run Bootstrap
description: How a fresh HIPCTF instance is initialized by the very first administrator.
tags: [guide, bootstrap, first-admin, onboarding, tester]
timestamp: 2026-07-21T16:23:40Z
timestamp: 2026-07-21T16:48:20Z
---
# When this flow runs
@@ -70,7 +70,8 @@ The modal **cannot** be dismissed by the user:
| Modal overlay | `.overlay` | Full-screen backdrop. |
| Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. |
| Username input | `[data-testid="setup-username"]` | Reactive control with length/pattern rules. |
| Password input | `[data-testid="setup-password"]` | Reactive control. |
| Password input | `[data-testid="setup-password"]` | Reactive control; shows inline error on touch when empty. |
| Password error | `[data-testid="setup-password-error"]` | Renders `passwordError()` (currently `required`). |
| Confirm input | `[data-testid="setup-password-confirm"]` | Reactive control. |
| Submit button | `[data-testid="setup-submit"]` | Triggers validation even when invalid; disabled only while submitting. |
| Server error alert | `[data-testid="setup-server-error"]` | Displays `serverError()`. |
+95
View File
@@ -0,0 +1,95 @@
---
type: guide
title: Setup Form Field Errors
description: Pure error-computation functions that drive the inline validation messages on the first-admin modal.
tags: [setup, validation, forms, frontend]
timestamp: 2026-07-21T16:48:20Z
---
# Purpose
The three pure helpers exported by
`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`
turn the raw Angular reactive-form control state into the user-facing
strings shown beside each input on the first-admin modal. Keeping them
as standalone functions (instead of `computed()` signals inside the
component) makes them trivially unit-testable in isolation — see
`tests/frontend/setup-create-admin.spec.ts`.
# Helpers
| Function | Returns | Reads |
|-----------------|---------------------------------------------------------|------------------------------------------------------------------------|
| `usernameError` | `string \| null` | `value`, `touched`, plus `errors` keys `required`/`minlength`/`maxlength`/`pattern`. |
| `passwordError` | `string \| null` | `value`, `touched`, plus `errors.required`. |
| `confirmError` | `string \| null` | `value`, `touched`, `errors.required`, and the group-level `groupMismatch` flag from [the password-match validator](/architecture/key-files.md). |
All three return `null` when the field is not yet `touched` (or, for
`confirmError`, when no group-level mismatch has been raised), so the
UI stays clean until the user has interacted with the form.
# Inputs
```ts
export interface FieldErrorContext {
value: string;
touched: boolean;
errors: Partial<Record<FieldErrorKey, true>> | null;
groupMismatch?: boolean;
}
export type FieldErrorKey =
| 'required'
| 'minlength'
| 'maxlength'
| 'pattern'
| 'passwordMismatch';
```
# Examples
```ts
usernameError({
value: 'ab',
touched: true,
errors: { minlength: true },
});
// -> 'Username must be at least 3 characters'
confirmError({
value: 'hunter2',
touched: true,
errors: null,
groupMismatch: true,
});
// -> 'Passwords do not match'
passwordError({
value: '',
touched: true,
errors: { required: true },
});
// -> 'Password is required'
```
# Wiring
* `SetupCreateAdminComponent` (`frontend/src/app/features/setup/setup-create-admin.component.ts`)
binds the three helpers to template method calls (`usernameError()`,
`passwordError()`, `confirmError()`) that read the corresponding
`FormControl` and pass its current state.
* The template
(`frontend/src/app/features/setup/setup-create-admin.component.html`)
renders the returned string inside a `<small class="field-error">`
with `data-testid="setup-{username|password|confirm}-error"`, and
mirrors the result on the input via `[attr.aria-invalid]`.
* `tests/frontend/setup-create-admin.spec.ts` exercises every code path
of the three helpers directly — no Angular TestBed is required.
# See also
- [First-Run Bootstrap](/guides/bootstrap.md) — how the helpers surface
to the operator.
- [Setup Endpoint](/api/setup.md) — server-side validation that
mirrors these messages.
- [Key Files Index](/architecture/key-files.md)
+4 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls.
The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-21T16:23:40Z.
they need. Last regenerated 2026-07-21T16:48:20Z.
# Architecture
@@ -52,6 +52,9 @@ they need. Last regenerated 2026-07-21T16:23:40Z.
* [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
initialized by the very first admin via the non-dismissible modal.
* [Setup Form Field Errors](/guides/setup-field-errors.md) - Pure
helpers that translate reactive-form state into the inline validation
messages on the first-admin modal.
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins
navigate the post-login shell and reach the user-management area.
* [Session Restoration on Reload](/guides/session-restoration.md) - How
@@ -10,11 +10,11 @@
type="text"
autocomplete="username"
formControlName="username"
[attr.aria-invalid]="!!usernameErrors()"
[attr.aria-invalid]="!!usernameError()"
data-testid="setup-username"
/>
@if (usernameErrors()) {
<small class="field-error" data-testid="setup-username-error">{{ usernameErrors() }}</small>
@if (usernameError()) {
<small class="field-error" data-testid="setup-username-error">{{ usernameError() }}</small>
}
</label>
@@ -24,9 +24,13 @@
type="password"
autocomplete="new-password"
formControlName="password"
[attr.aria-invalid]="!!passwordError()"
data-testid="setup-password"
/>
<small class="hint">{{ policyDescription() }}</small>
@if (passwordError()) {
<small class="field-error" data-testid="setup-password-error">{{ passwordError() }}</small>
}
</label>
<label class="field">
@@ -35,10 +39,11 @@
type="password"
autocomplete="new-password"
formControlName="passwordConfirm"
[attr.aria-invalid]="!!confirmError()"
data-testid="setup-password-confirm"
/>
@if (confirmErrors()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmErrors() }}</small>
@if (confirmError()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmError() }}</small>
}
</label>
@@ -17,6 +17,11 @@ import { AuthService } from '../../core/services/auth.service';
import { BootstrapService } from '../../core/services/bootstrap.service';
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
import { passwordMatchValidator } from './setup-create-admin.validators';
import {
usernameError as computeUsernameError,
passwordError as computePasswordError,
confirmError as computeConfirmError,
} from './setup-create-admin.field-errors';
@Component({
selector: 'app-setup-create-admin',
@@ -67,24 +72,33 @@ export class SetupCreateAdminComponent {
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
);
readonly usernameErrors = computed(() => {
usernameError(): string | null {
const c = this.form.controls.username;
if (!c.touched || c.valid) return null;
if (c.errors?.['required']) return 'Username is required';
if (c.errors?.['minlength']) return 'Username must be at least 3 characters';
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters';
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore';
return 'Invalid username';
});
return computeUsernameError({
value: c.value,
touched: c.touched,
errors: (c.errors as { required?: true; minlength?: true; maxlength?: true; pattern?: true } | null) ?? null,
});
}
readonly confirmErrors = computed(() => {
passwordError(): string | null {
const c = this.form.controls.password;
return computePasswordError({
value: c.value,
touched: c.touched,
errors: (c.errors as { required?: true } | null) ?? null,
});
}
confirmError(): string | null {
const c = this.form.controls.passwordConfirm;
const groupMismatch = this.form.errors?.['passwordMismatch'];
if ((!c.touched || c.valid) && !groupMismatch) return null;
if (c.errors?.['required']) return 'Please confirm your password';
if (groupMismatch) return 'Passwords do not match';
return 'Invalid value';
});
return computeConfirmError({
value: c.value,
touched: c.touched,
errors: (c.errors as { required?: true } | null) ?? null,
groupMismatch: !!this.form.errors?.['passwordMismatch'],
});
}
@HostListener('document:keydown.escape', ['$event'])
swallowEscape(event: KeyboardEvent): void {
@@ -0,0 +1,38 @@
export type FieldErrorKey =
| 'required'
| 'minlength'
| 'maxlength'
| 'pattern'
| 'passwordMismatch';
export interface FieldErrorContext {
value: string;
touched: boolean;
errors: Partial<Record<FieldErrorKey, true>> | null;
groupMismatch?: boolean;
}
export function usernameError(ctx: FieldErrorContext): string | null {
const { touched, errors } = ctx;
if (!touched || !errors) return null;
if (errors.required) return 'Username is required';
if (errors.minlength) return 'Username must be at least 3 characters';
if (errors.maxlength) return 'Username must be at most 32 characters';
if (errors.pattern) return 'Username may only contain letters, digits, dot, dash and underscore';
return 'Invalid username';
}
export function passwordError(ctx: FieldErrorContext): string | null {
const { touched, errors } = ctx;
if (!touched || !errors) return null;
if (errors.required) return 'Password is required';
return null;
}
export function confirmError(ctx: FieldErrorContext): string | null {
const { touched, errors, groupMismatch } = ctx;
if (!touched && !groupMismatch) return null;
if (errors?.required) return 'Please confirm your password';
if (groupMismatch) return 'Passwords do not match';
return 'Invalid value';
}
+60
View File
@@ -2,6 +2,11 @@ import {
passwordMatchValidator,
PasswordMatchGroupLike,
} from '../../frontend/src/app/features/setup/setup-create-admin.validators';
import {
usernameError,
passwordError,
confirmError,
} from '../../frontend/src/app/features/setup/setup-create-admin.field-errors';
function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike {
return {
@@ -111,3 +116,58 @@ describe('Composed submit gate (mirrors SetupCreateAdminComponent.submit)', () =
expect(isFormReadyForSubmit('first.admin', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass')).toBe(true);
});
});
describe('Field-error helpers (mirror the template render)', () => {
it('usernameError returns "Username is required" when touched + required', () => {
expect(
usernameError({ value: '', touched: true, errors: { required: true } }),
).toBe('Username is required');
});
it('usernameError returns the minlength message when too short', () => {
expect(
usernameError({ value: 'ab', touched: true, errors: { minlength: true } }),
).toBe('Username must be at least 3 characters');
});
it('usernameError returns null when not touched even if errors are present', () => {
expect(
usernameError({ value: '', touched: false, errors: { required: true } }),
).toBeNull();
});
it('passwordError returns "Password is required" when touched + required', () => {
expect(
passwordError({ value: '', touched: true, errors: { required: true } }),
).toBe('Password is required');
});
it('passwordError returns null when touched with no errors (policy is server-side)', () => {
expect(
passwordError({ value: 'x', touched: true, errors: null }),
).toBeNull();
});
it('confirmError returns "Please confirm your password" when touched + required', () => {
expect(
confirmError({ value: '', touched: true, errors: { required: true } }),
).toBe('Please confirm your password');
});
it('confirmError returns "Passwords do not match" when group mismatch is set', () => {
expect(
confirmError({
value: 'a',
touched: true,
errors: null,
groupMismatch: true,
}),
).toBe('Passwords do not match');
});
it('confirmError returns null when not touched and no group mismatch', () => {
expect(
confirmError({ value: 'a', touched: false, errors: null }),
).toBeNull();
});
});