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

This commit was merged in pull request #7.
This commit is contained in:
2026-07-21 16:49:50 +00:00
parent 2957b14d38
commit cabd393288
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 title: Setup Endpoint
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists. description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
tags: [api, setup, bootstrap, first-admin] tags: [api, setup, bootstrap, first-admin]
timestamp: 2026-07-21T16:23:40Z timestamp: 2026-07-21T16:48:20Z
--- ---
# Endpoint # Endpoint
@@ -76,5 +76,6 @@ non-retryable inline error and offer a Retry button for everything else.
# See also # See also
- [First-Run Bootstrap Guide](/guides/bootstrap.md) - [First-Run Bootstrap Guide](/guides/bootstrap.md)
- [Setup Form Field Errors](/guides/setup-field-errors.md)
- [Backend Module Map](/architecture/backend-modules.md) - [Backend Module Map](/architecture/backend-modules.md)
- [Bootstrap Service](/architecture/frontend-structure.md) - [Bootstrap Service](/architecture/frontend-structure.md)
+2 -2
View File
@@ -3,7 +3,7 @@ type: architecture
title: Frontend Structure title: Frontend Structure
description: Angular routes, components, services, guards, and interceptors. description: Angular routes, components, services, guards, and interceptors.
tags: [architecture, frontend, angular] tags: [architecture, frontend, angular]
timestamp: 2026-07-21T15:05:00Z timestamp: 2026-07-21T16:48:20Z
--- ---
# Routes # 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. | | `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. | | `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. | | `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 # Services
+3 -1
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for every important source file in the repository. description: One-line responsibility for every important source file in the repository.
tags: [architecture, index, key-files] tags: [architecture, index, key-files]
timestamp: 2026-07-21T15:05:00Z timestamp: 2026-07-21T16:48:20Z
--- ---
# Backend # 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.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.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.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.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). | | `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-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. |
| `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. | | `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/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/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/csrf-client.ts` | Test helper for CSRF flow. |
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. | | `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 title: First-Run Bootstrap
description: How a fresh HIPCTF instance is initialized by the very first administrator. description: How a fresh HIPCTF instance is initialized by the very first administrator.
tags: [guide, bootstrap, first-admin, onboarding, tester] tags: [guide, bootstrap, first-admin, onboarding, tester]
timestamp: 2026-07-21T16:23:40Z timestamp: 2026-07-21T16:48:20Z
--- ---
# When this flow runs # When this flow runs
@@ -70,7 +70,8 @@ The modal **cannot** be dismissed by the user:
| Modal overlay | `.overlay` | Full-screen backdrop. | | Modal overlay | `.overlay` | Full-screen backdrop. |
| Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. | | Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. |
| Username input | `[data-testid="setup-username"]` | Reactive control with length/pattern rules. | | 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. | | Confirm input | `[data-testid="setup-password-confirm"]` | Reactive control. |
| Submit button | `[data-testid="setup-submit"]` | Triggers validation even when invalid; disabled only while submitting. | | 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()`. | | 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. controls.
The docs below are organized by purpose so agents can pull just the slice 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 # 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 * [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is
initialized by the very first admin via the non-dismissible modal. 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 * [Admin Shell & User Management](/guides/admin-shell.md) - How admins
navigate the post-login shell and reach the user-management area. navigate the post-login shell and reach the user-management area.
* [Session Restoration on Reload](/guides/session-restoration.md) - How * [Session Restoration on Reload](/guides/session-restoration.md) - How
@@ -10,11 +10,11 @@
type="text" type="text"
autocomplete="username" autocomplete="username"
formControlName="username" formControlName="username"
[attr.aria-invalid]="!!usernameErrors()" [attr.aria-invalid]="!!usernameError()"
data-testid="setup-username" data-testid="setup-username"
/> />
@if (usernameErrors()) { @if (usernameError()) {
<small class="field-error" data-testid="setup-username-error">{{ usernameErrors() }}</small> <small class="field-error" data-testid="setup-username-error">{{ usernameError() }}</small>
} }
</label> </label>
@@ -24,9 +24,13 @@
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
formControlName="password" formControlName="password"
[attr.aria-invalid]="!!passwordError()"
data-testid="setup-password" data-testid="setup-password"
/> />
<small class="hint">{{ policyDescription() }}</small> <small class="hint">{{ policyDescription() }}</small>
@if (passwordError()) {
<small class="field-error" data-testid="setup-password-error">{{ passwordError() }}</small>
}
</label> </label>
<label class="field"> <label class="field">
@@ -35,10 +39,11 @@
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
formControlName="passwordConfirm" formControlName="passwordConfirm"
[attr.aria-invalid]="!!confirmError()"
data-testid="setup-password-confirm" data-testid="setup-password-confirm"
/> />
@if (confirmErrors()) { @if (confirmError()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmErrors() }}</small> <small class="field-error" data-testid="setup-confirm-error">{{ confirmError() }}</small>
} }
</label> </label>
@@ -17,6 +17,11 @@ import { AuthService } from '../../core/services/auth.service';
import { BootstrapService } from '../../core/services/bootstrap.service'; import { BootstrapService } from '../../core/services/bootstrap.service';
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service'; import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
import { passwordMatchValidator } from './setup-create-admin.validators'; import { passwordMatchValidator } from './setup-create-admin.validators';
import {
usernameError as computeUsernameError,
passwordError as computePasswordError,
confirmError as computeConfirmError,
} from './setup-create-admin.field-errors';
@Component({ @Component({
selector: 'app-setup-create-admin', selector: 'app-setup-create-admin',
@@ -67,24 +72,33 @@ export class SetupCreateAdminComponent {
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN', () => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
); );
readonly usernameErrors = computed(() => { usernameError(): string | null {
const c = this.form.controls.username; const c = this.form.controls.username;
if (!c.touched || c.valid) return null; return computeUsernameError({
if (c.errors?.['required']) return 'Username is required'; value: c.value,
if (c.errors?.['minlength']) return 'Username must be at least 3 characters'; touched: c.touched,
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters'; errors: (c.errors as { required?: true; minlength?: true; maxlength?: true; pattern?: true } | null) ?? null,
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore'; });
return 'Invalid username'; }
});
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 c = this.form.controls.passwordConfirm;
const groupMismatch = this.form.errors?.['passwordMismatch']; return computeConfirmError({
if ((!c.touched || c.valid) && !groupMismatch) return null; value: c.value,
if (c.errors?.['required']) return 'Please confirm your password'; touched: c.touched,
if (groupMismatch) return 'Passwords do not match'; errors: (c.errors as { required?: true } | null) ?? null,
return 'Invalid value'; groupMismatch: !!this.form.errors?.['passwordMismatch'],
}); });
}
@HostListener('document:keydown.escape', ['$event']) @HostListener('document:keydown.escape', ['$event'])
swallowEscape(event: KeyboardEvent): void { 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, passwordMatchValidator,
PasswordMatchGroupLike, PasswordMatchGroupLike,
} from '../../frontend/src/app/features/setup/setup-create-admin.validators'; } 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 { function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike {
return { return {
@@ -111,3 +116,58 @@ describe('Composed submit gate (mirrors SetupCreateAdminComponent.submit)', () =
expect(isFormReadyForSubmit('first.admin', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass')).toBe(true); 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();
});
});