AI Implementation feature(891): Admin Area General Settings and Categories 1.09 #31

Merged
m0rph3us1987 merged 2 commits from feature-891-1784734481852 into dev 2026-07-22 15:47:05 +00:00
11 changed files with 432 additions and 42 deletions
-26
View File
@@ -1,26 +0,0 @@
# Implementation Plan: Admin Area General Settings UTC Datetime Round-Trip
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo with an Angular 17 standalone SPA and NestJS backend. The General Settings screen uses a strictly typed, non-nullable reactive form in `frontend/src/app/features/admin/general.component.ts`, while reusable formatting and validation logic is extracted to pure functions in `frontend/src/app/features/admin/general.pure.ts`. The component loads backend ISO timestamps through `toDatetimeLocal`, submits form values through `toIsoUtc`, and uses async/await around the promise-based `AdminService` API facade.
- **Data Layer:** NestJS persists general settings as string key/value rows through `SettingsService`. `AdminGeneralService.updateSettings` writes `eventStartUtc` and `eventEndUtc` verbatim after `GeneralSettingsSchema` validates timezone-aware ISO-8601 strings and strict start/end ordering. No database schema or migration is required; the defect is isolated to frontend conversion of UTC-labelled `datetime-local` values.
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from the dedicated `tests/frontend/` folder. `npm test` runs backend and frontend projects together, while `npm run test:frontend` runs the focused frontend project. Existing pure-helper coverage is in `tests/frontend/admin-general-pure.spec.ts`, which is the appropriate lightweight regression location and requires no browser UI.
- **Required Tools & Dependencies:** No new system tools, global CLI utilities, package dependencies, persistent `/data` assets, or `setup.sh` changes are required. The implementation uses built-in JavaScript `Date` UTC construction/serialization and the existing Jest toolchain.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/general.pure.ts` — make `toIsoUtc` interpret the numeric components of the UTC-labelled `datetime-local` string as UTC rather than as the browser's local timezone.
- `tests/frontend/admin-general-pure.spec.ts` — add a focused timezone regression proving the displayed UTC wall-clock components serialize unchanged and preserve existing empty/invalid behavior.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:** No changes. The existing setting keys and backend zod schema already accept and persist canonical UTC ISO strings correctly.
2. **Backend Logic & APIs:** No changes. `PUT /api/v1/admin/general/settings` already validates timezone-aware ISO-8601 input, enforces `eventEndUtc > eventStartUtc`, and stores the payload without timezone conversion; `GET /api/v1/event/status` therefore reflects whatever canonical instant the frontend sends.
3. **Frontend UI Integration:**
1. Update `toIsoUtc` in `general.pure.ts` to parse the `datetime-local` year, month, day, hour, minute, and optional second/fraction components as UTC components, then create the timestamp with `Date.UTC(...)` and return `toISOString()`.
2. Preserve the helper's current contract for empty values (`''`) and invalid values (return the original string), so the existing reactive-form validation and negative save paths remain unchanged.
3. Keep `toDatetimeLocal` unchanged because it already renders backend timestamps from UTC getters. Together, `toDatetimeLocal(iso)` and `toIsoUtc(local)` will become true timezone-independent inverses for the minute-precision native inputs used by `AdminGeneralComponent`.
4. Keep `AdminGeneralComponent.onSubmit` and the service/API contract unchanged; both event fields already pass through `toIsoUtc`, so correcting the shared helper fixes start and end submissions without UI or backend rewiring.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/admin-general-pure.spec.ts`.
- **Mocking Strategy:** No external boundaries need mocking because the defect is entirely in a pure function. Add a minimal deterministic regression around `toIsoUtc('2027-03-15T08:30') === '2027-03-15T08:30:00.000Z'` and the corresponding end value `2027-03-17T16:45`, explicitly exercising the America/Los_Angeles/PDT scenario without launching a UI. Prefer running the focused Jest process with `TZ=America/Los_Angeles` (or isolate a small child Jest invocation configured with that environment) so the test fails against the current local-time implementation and passes only when conversion is browser-timezone independent. Retain or extend the existing empty-input assertion and add only the key invalid-input assertion if needed to lock the helper's fallback contract. Verify with the root single-command suite (`npm test`) plus the existing root build/type-check path (`npm run build`); no visual confirmation or persistent test data is involved.
+42
View File
@@ -0,0 +1,42 @@
# Implementation Plan: Admin Area General Settings and Categories 1.09
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone SPA. The General page is an `OnPush` standalone component using a non-nullable reactive form, Angular signals/computed state, `@if` template control flow, and `takeUntilDestroyed` subscriptions. Reusable validation and message logic is kept in `frontend/src/app/features/admin/general.pure.ts`; the API validates request bodies at the controller boundary with Zod through `ZodValidationPipe`.
- **Data Layer:** TypeORM with `better-sqlite3`; general settings are key/value rows accessed through `SettingsService`. No schema migration is required because `defaultChallengeIp` already exists and only its accepted value contract changes. `AdminGeneralService.updateSettings` persists the Zod-parsed payload, so server-side normalization must happen in `GeneralSettingsSchema` before the service receives it.
- **Test Framework & Structure:** Root Jest 29 configuration with separate backend and jsdom frontend projects. Tests live under dedicated `tests/backend` and `tests/frontend` folders and are run together with `npm test` from `/repo`; targeted commands are `npm run test:backend` and `npm run test:frontend`. Existing General Settings schema tests are in `tests/backend/admin-general-service.spec.ts`, while pure Angular form-validation helpers are tested in `tests/frontend/admin-general-pure.spec.ts` without rendering a UI.
- **Required Tools & Dependencies:** No new system tools, global CLIs, package dependencies, persistent `/data` assets, or `setup.sh` changes are required. Use the existing Angular Forms APIs, Zod, Jest, TypeScript, and current npm workspace setup. Verification should run `npm test` and `npm run build`; no separate lint/typecheck scripts are defined, and both workspace builds perform the available TypeScript/Angular compilation checks.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/dto/general.dto.ts` — trim and strictly validate the default challenge address at the API boundary, with a field-specific validation message.
- `frontend/src/app/features/admin/general.pure.ts` — add a reusable pure default-address validator, normalization helper, and canonical inline-message mapper.
- `frontend/src/app/features/admin/general.component.ts` — attach the new validator to the reactive-form control, expose touched/dirty invalid state to the `OnPush` template, render accessible inline feedback, and submit the normalized value.
- `tests/backend/admin-general-service.spec.ts` — add focused Zod schema regression cases for accepted, normalized, and rejected default challenge addresses.
- `tests/frontend/admin-general-pure.spec.ts` — add focused tests for default-address validation, messaging, and normalization without browser/UI infrastructure.
- `docs/guides/admin-general-settings.md` — update the documented field contract, inline error selector, accessibility behavior, normalization, and test coverage.
- `docs/api/admin.md` — document server-side trimming and IP/hostname validation for `defaultChallengeIp`.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- Do not alter the SQLite schema or settings keys. Keep valid persisted values unchanged, including the demonstrated IPv4 value `10.66.77.88`.
- Strengthen `GeneralSettingsSchema.defaultChallengeIp` so Zod trims surrounding whitespace first, rejects an empty result, and accepts only a complete IPv4 address or a valid hostname. Implement the format predicate with built-in/runtime-safe logic (for example Node's `net.isIP(value) === 4` plus a hostname-label check) rather than adding a dependency. Reject incomplete dotted addresses such as `10.0.0.` and `10.0.0`, whitespace-only strings, IPv4 octets outside `0..255`, and malformed hostnames. Preserve the existing 255-character ceiling and return a clear field-specific message on `defaultChallengeIp`.
- Because Zod transforms before `AdminGeneralService.updateSettings`, a valid padded address such as ` 10.20.30.40 ` is persisted canonically as `10.20.30.40`, while invalid values never reach `SettingsService`. This makes the backend authoritative even if the frontend is bypassed.
2. **Backend Logic & APIs:**
- Keep `PUT /api/v1/admin/general/settings`, its controller, service, response shape, settings key, and SSE behavior unchanged. The existing `ZodValidationPipe` will emit the standard `400 VALIDATION_FAILED` envelope with an issue path of `defaultChallengeIp` for bad input.
- Keep `GET /api/v1/admin/general/settings` and public bootstrap reads unchanged; successful updates continue to round-trip the normalized value through `AdminGeneralService.getSettings` and bootstrap refresh.
- Avoid service-level duplicate validation: the parsed `GeneralSettingsPayload` is the trusted normalized contract at the service boundary.
3. **Frontend UI Integration:**
- In `general.pure.ts`, add a strict pure validator compatible with Angular's validator signature. It should trim only for checking, return a dedicated error key for empty/whitespace input and another for malformed IP/hostname input, and return `null` only for a complete IPv4 address or valid hostname. Add a normalization helper that trims the submitted value and a message helper that maps raw values/control errors to stable text, including a required message for empty/whitespace-only input and a format message for incomplete or malformed addresses.
- Replace the `defaultChallengeIp` control's `Validators.required`-only configuration with the extracted validation logic (retaining `Validators.maxLength(255)` if message handling distinguishes length). Do not use permissive browser URL parsing or `Date.parse`-style heuristics.
- Mirror the component's established Page Title/Event field signal pattern: track the default-IP raw value, validity, and touched-or-dirty state from `valueChanges` and `statusChanges` using `takeUntilDestroyed(this.destroyRef)`, then derive `showDefaultChallengeIpError` and the message with `computed` signals. Reset the control's touched/pristine state and tracking signal in `applySettings` so valid loaded or freshly saved data does not display stale errors.
- Enhance `[data-testid=general-defaultIp]` with conditional `aria-invalid="true"` and `aria-describedby="general-defaultIp-error"`. Render a `.field-error` element with both `id` and `data-testid="general-defaultIp-error"` whenever the field has been touched or dirtied and is invalid. This gives the empty case the required visible explanation and keeps Save disabled through `form.invalid` for incomplete/malformed values.
- Normalize `defaultChallengeIp` with the shared helper in `onSubmit` before calling `AdminService.updateGeneralSettings`. Valid surrounding whitespace is therefore removed rather than persisted, aligning client behavior with the authoritative backend schema. Do not mutate the control during typing, so the user can see and correct the original entry.
## 4. Test Strategy
- **Target Unit Test File:**
- `tests/backend/admin-general-service.spec.ts`: extend the existing `GeneralSettingsSchema - validation rules` suite with minimal cases proving a valid IPv4 survives exactly, a valid hostname is accepted, surrounding whitespace is trimmed in parsed output, and the key negatives (`''`/whitespace-only plus representative incomplete dotted forms `10.0.0.` and `10.0.0`) fail with an issue on `defaultChallengeIp` and a clear format/required message.
- `tests/frontend/admin-general-pure.spec.ts`: test the pure validator/message/normalizer directly: valid IPv4 and hostname return no error/message; empty and whitespace-only return the required error/message; incomplete dotted addresses return the format error/message; padded valid input normalizes to its trimmed value. Keep tests logic-only and independent of Angular TestBed or visual confirmation.
- **Mocking Strategy:** No external services, HTTP, database, browser automation, or persistent data are needed. Parse plain objects directly with the Zod schema and invoke pure frontend helpers with small control-shaped objects, following the existing fast Jest suites. Avoid adding a component harness or full Nest application because the behavior is fully covered at the validation boundaries and the existing form wiring uses those helpers directly. Run all suites from the root with `npm test`, then run `npm run build` to verify Angular template bindings and backend TypeScript compilation.
+48 -1
View File
@@ -1,6 +1,53 @@
import { isIP } from 'net';
import { z } from 'zod'; import { z } from 'zod';
import { THEME_IDS } from '../../../common/types/theme-ids'; import { THEME_IDS } from '../../../common/types/theme-ids';
const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/;
const ALL_NUMERIC_LABEL = /^[0-9]+$/;
function isValidHostname(value: string): boolean {
if (value.length === 0 || value.length > 253) return false;
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (labels.some((label) => ALL_NUMERIC_LABEL.test(label))) return false;
return labels.every((label) => HOSTNAME_LABEL.test(label));
}
export function isValidDefaultChallengeAddress(value: string): boolean {
if (isIP(value) === 4) return true;
return isValidHostname(value);
}
const defaultChallengeIpSchema = z
.string({ required_error: 'defaultChallengeIp is required', invalid_type_error: 'defaultChallengeIp must be a string' })
.transform((v) => v.trim())
.superRefine((value, ctx) => {
if (value.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [],
message: 'defaultChallengeIp is required and cannot contain only whitespace',
});
return;
}
if (value.length > 255) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [],
message: 'defaultChallengeIp must be 255 characters or fewer',
});
return;
}
if (!isValidDefaultChallengeAddress(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [],
message: 'defaultChallengeIp must be a valid IPv4 address or hostname',
});
}
});
export const GeneralSettingsSchema = z export const GeneralSettingsSchema = z
.object({ .object({
pageTitle: z.string().trim().min(1).max(120), pageTitle: z.string().trim().min(1).max(120),
@@ -9,7 +56,7 @@ export const GeneralSettingsSchema = z
themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]), themeKey: z.enum(THEME_IDS as unknown as [string, ...string[]]),
eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }), eventStartUtc: z.string().datetime({ message: 'eventStartUtc must be a valid ISO-8601 datetime' }),
eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }), eventEndUtc: z.string().datetime({ message: 'eventEndUtc must be a valid ISO-8601 datetime' }),
defaultChallengeIp: z.string().min(1).max(255), defaultChallengeIp: defaultChallengeIpSchema,
registrationsEnabled: z.boolean(), registrationsEnabled: z.boolean(),
}) })
.superRefine((val, ctx) => { .superRefine((val, ctx) => {
+8 -3
View File
@@ -2,8 +2,8 @@
type: api type: api
title: Admin Endpoints title: Admin Endpoints
description: Admin-only endpoints for user management, required general settings, categories, and supporting uploads. description: Admin-only endpoints for user management, required general settings, categories, and supporting uploads.
tags: [api, admin, users, general, categories] tags: [api, admin, users, general, categories, default-challenge-ip, ip-hostname, validation]
timestamp: 2026-07-22T14:24:08Z timestamp: 2026-07-22T15:46:18Z
--- ---
# Endpoints # Endpoints
@@ -53,7 +53,12 @@ removes the row.
`superRefine` additionally requires `eventEndUtc > eventStartUtc` and `superRefine` additionally requires `eventEndUtc > eventStartUtc` and
reports `eventEndUtc must be strictly after eventStartUtc` on the end reports `eventEndUtc must be strictly after eventStartUtc` on the end
field when both values parse but are out of order. field when both values parse but are out of order.
* `defaultChallengeIp` 1255 chars * `defaultChallengeIp` is trimmed server-side; empty/whitespace-only values
are rejected (`defaultChallengeIp is required and cannot contain only
whitespace`), and the trimmed value must be a complete IPv4 address or a
valid hostname, otherwise `400 VALIDATION_FAILED` with a per-field
`defaultChallengeIp` issue (`defaultChallengeIp must be a valid IPv4
address or hostname`). Maximum length is 255 characters.
* `registrationsEnabled` boolean * `registrationsEnabled` boolean
On success the handler persists every field via `SettingsService`, On success the handler persists every field via `SettingsService`,
+4 -4
View File
@@ -2,8 +2,8 @@
type: architecture type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for important source and contract-test files, including strict event-window validation. description: One-line responsibility for important source and contract-test files, including strict event-window validation.
tags: [architecture, key-files, event-window, validation] tags: [architecture, key-files, event-window, validation, default-challenge-ip]
timestamp: 2026-07-22T14:24:08Z timestamp: 2026-07-22T15:46:18Z
--- ---
# Backend # Backend
@@ -49,8 +49,8 @@ timestamp: 2026-07-22T14:24:08Z
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. | | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
| `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. | | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent` reactive form for `/admin/general` — per-field inline error rendering (page-title + event-start + event-end), logo upload wiring, welcome Markdown preview, event-state derivation, and SSE `general` event handling. |
| `frontend/src/app/features/admin/general.pure.ts` | Pure General Settings helpers, including required datetime validation, field messages, UTC conversion, and end-after-start validation. | | `frontend/src/app/features/admin/general.pure.ts` | Pure General Settings helpers, including required datetime validation, field messages, UTC conversion, end-after-start validation, and the default-challenge-address IPv4/hostname validator/normalizer/message trio. |
| `tests/frontend/admin-general-pure.spec.ts` | Pure client-contract tests for required event timestamps, datetime messaging, UTC conversion, and event-window ordering. | | `tests/frontend/admin-general-pure.spec.ts` | Pure client-contract tests for required event timestamps, datetime messaging, UTC conversion, event-window ordering, and default-challenge-address validation, error mapping, and normalization. |
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. | | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | | `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
+49 -3
View File
@@ -2,8 +2,8 @@
type: guide type: guide
title: Admin — General Settings title: Admin — General Settings
description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page. description: How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page.
tags: [guide, admin, settings, general, tester, datetime, validation, utc, timezone] tags: [guide, admin, settings, general, tester, datetime, validation, utc, timezone, default-challenge-ip, ip-hostname]
timestamp: 2026-07-22T15:09:39Z timestamp: 2026-07-22T15:46:18Z
--- ---
# When this view is available # When this view is available
@@ -44,7 +44,7 @@ The page is a single reactive form with these controls (every
| Global theme | `general-themeKey` | `themeKey` | `<select>` populated from `/themes`. Value is one of `THEME_IDS`. | | Global theme | `general-themeKey` | `themeKey` | `<select>` populated from `/themes`. Value is one of `THEME_IDS`. |
| Event start (UTC) | `general-eventStart` | `eventStartUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventStart-error` and disables Save. | | Event start (UTC) | `general-eventStart` | `eventStartUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventStart-error` and disables Save. |
| Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventEnd-error`; it must also be strictly after Event start — when it is not, the same `general-eventEnd-error` region displays "Event end must be after event start." | | Event end (UTC) | `general-eventEnd` | `eventEndUtc` | Required valid ISO-8601 datetime. Empty or malformed input renders `general-eventEnd-error`; it must also be strictly after Event start — when it is not, the same `general-eventEnd-error` region displays "Event end must be after event start." |
| Default challenge IP | `general-defaultIp` | `defaultChallengeIp` | Required, max 255 chars. | | Default challenge IP | `general-defaultIp` | `defaultChallengeIp` | Required, non-blank, max 255 chars after trimming; must be a complete IPv4 address or a valid hostname; trimmed server-side. Renders `general-defaultIp-error` for empty/whitespace or malformed input. |
| Enable registrations | `general-registrations` | `registrationsEnabled` | Boolean checkbox. When `false`, the public register endpoint returns `REGISTRATIONS_DISABLED`. | | Enable registrations | `general-registrations` | `registrationsEnabled` | Boolean checkbox. When `false`, the public register endpoint returns `REGISTRATIONS_DISABLED`. |
| Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. | | Welcome description | `general-welcome` | `welcomeMarkdown` | Multi-line textarea; a live preview is rendered into `general-welcome-preview`. |
| Event controls | `general-event-toggle` | (derived) | Disabled button whose text is the derived event state (`UNCONFIGURED` / `COUNTDOWN` / `RUNNING` / `STOPPED`). | | Event controls | `general-event-toggle` | (derived) | Disabled button whose text is the derived event state (`UNCONFIGURED` / `COUNTDOWN` / `RUNNING` / `STOPPED`). |
@@ -124,6 +124,51 @@ Save, the control is reset to untouched/pristine and the
`pageTitleTouchedOrDirty` signal is cleared, so reloading valid `pageTitleTouchedOrDirty` signal is cleared, so reloading valid
settings does not flash a stale error. settings does not flash a stale error.
# Default challenge IP inline error message
The inline error element under the Default challenge IP field
(`general-defaultIp-error`) is driven by the pure helpers exported
from `frontend/src/app/features/admin/general.pure.ts`:
* `isValidDefaultChallengeAddress(value)` — returns `true` only for a
complete IPv4 address (four 0255 octets, no leading zeros on a
multi-digit octet) or a valid RFC-1123 hostname.
* `defaultChallengeAddressError(value)` — returns `'required'` for
empty/whitespace-only input and `'format'` for malformed addresses,
otherwise `null`.
* `defaultChallengeAddressMessage(value, controlErrors)` — renders the
human-readable text used by the inline region.
* `normalizeDefaultChallengeAddress(value)` — trims surrounding
whitespace from the value submitted to the backend.
The component keeps three local `signal`s — `defaultChallengeIpValue`,
`defaultChallengeIpInvalid`, and `defaultChallengeIpTouchedOrDirty`
mirroring the Page-title pattern. The reactive-form control uses a
custom validator that returns `{ defaultChallengeAddressFormat: true }`
for malformed addresses (alongside `Validators.required` for the empty
case), and the `computed` signals `showDefaultChallengeIpError` and
`defaultChallengeIpMessage` drive:
* `[attr.aria-invalid]` and `[attr.aria-describedby]` on the input so
assistive tech is informed the instant the field goes invalid.
* An `@if` block that renders the inline `<div class="field-error">`
with `data-testid="general-defaultIp-error"`.
| Condition | Message |
|------------------------------------------------------------|--------------------------------------------------------|
| Empty or whitespace-only value | `Default challenge IP is required and cannot contain only whitespace.` |
| Incomplete IPv4 (`10.0.0.`, `10.0.0`), out-of-range octets, or malformed hostname | `Default challenge IP must be a valid IPv4 address or hostname.` |
| Valid IPv4 or hostname | `null` (no error rendered; Save enabled). |
After a successful Save the component trims the value through
`normalizeDefaultChallengeAddress`, persists the canonical form via
`PUT /api/v1/admin/general/settings`, and resets the control to
untouched/pristine, so a freshly-loaded valid value does not flash a
stale error. The server `GeneralSettingsSchema` performs the same
trim and format check, so invalid values return `400 VALIDATION_FAILED`
with a per-field `defaultChallengeIp` issue and the stored value is
unchanged.
# Event start / end inline error messages # Event start / end inline error messages
The inline error elements under the Event start and Event end inputs The inline error elements under the Event start and Event end inputs
@@ -248,6 +293,7 @@ suite, with cases that lock in:
| Event start inline error | `[data-testid="general-eventStart-error"]` | | Event start inline error | `[data-testid="general-eventStart-error"]` |
| Event end inline error | `[data-testid="general-eventEnd-error"]` (renders both the per-field `invalidDatetime` message and the cross-field `endBeforeStart` "Event end must be after event start." message) | | Event end inline error | `[data-testid="general-eventEnd-error"]` (renders both the per-field `invalidDatetime` message and the cross-field `endBeforeStart` "Event end must be after event start." message) |
| Page-title inline error | `[data-testid="general-pageTitle-error"]` | | Page-title inline error | `[data-testid="general-pageTitle-error"]` |
| Default challenge IP inline error | `[data-testid="general-defaultIp-error"]` |
# Architecture map # Architecture map
+1 -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-22T15:09:39Z. they need. Last regenerated 2026-07-22T15:46:18Z.
# Architecture # Architecture
@@ -8,11 +8,14 @@ import { MarkdownService } from '../../core/services/markdown.service';
import { AdminCategoriesComponent } from './categories/categories.component'; import { AdminCategoriesComponent } from './categories/categories.component';
import { import {
datetimeMessage, datetimeMessage,
defaultChallengeAddressMessage,
deriveEventState, deriveEventState,
endAfterStartValidator, endAfterStartValidator,
endBeforeStartMessage, endBeforeStartMessage,
eventEndFieldMessage, eventEndFieldMessage,
isValidDefaultChallengeAddress,
isoDatetimeValidator, isoDatetimeValidator,
normalizeDefaultChallengeAddress,
normalizePageTitle, normalizePageTitle,
pageTitleMessage, pageTitleMessage,
toDatetimeLocal, toDatetimeLocal,
@@ -126,7 +129,19 @@ import {
<label for="defaultChallengeIp">Default challenge IP</label> <label for="defaultChallengeIp">Default challenge IP</label>
<div> <div>
<input id="defaultChallengeIp" type="text" formControlName="defaultChallengeIp" data-testid="general-defaultIp" /> <input
id="defaultChallengeIp"
type="text"
formControlName="defaultChallengeIp"
data-testid="general-defaultIp"
[attr.aria-invalid]="showDefaultChallengeIpError() ? 'true' : null"
[attr.aria-describedby]="showDefaultChallengeIpError() ? 'general-defaultIp-error' : null"
/>
@if (showDefaultChallengeIpError()) {
<div class="field-error" id="general-defaultIp-error" data-testid="general-defaultIp-error">
{{ defaultChallengeIpMessage() }}
</div>
}
</div> </div>
<label for="registrationsEnabled">Enable registrations</label> <label for="registrationsEnabled">Enable registrations</label>
@@ -209,6 +224,10 @@ export class AdminGeneralComponent implements OnInit {
private readonly eventEndInvalid = signal<boolean>(false); private readonly eventEndInvalid = signal<boolean>(false);
private readonly eventEndTouchedOrDirty = signal<boolean>(false); private readonly eventEndTouchedOrDirty = signal<boolean>(false);
private readonly defaultChallengeIpValue = signal<string>('');
private readonly defaultChallengeIpInvalid = signal<boolean>(false);
private readonly defaultChallengeIpTouchedOrDirty = signal<boolean>(false);
readonly form = this.fb.nonNullable.group( readonly form = this.fb.nonNullable.group(
{ {
pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]), pageTitle: this.fb.nonNullable.control('', [Validators.required, Validators.maxLength(120), this.pageTitleNotBlankValidator()]),
@@ -217,7 +236,7 @@ export class AdminGeneralComponent implements OnInit {
themeKey: this.fb.nonNullable.control('classic'), themeKey: this.fb.nonNullable.control('classic'),
eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), eventStartUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]), eventEndUtc: this.fb.nonNullable.control('', [isoDatetimeValidator]),
defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required]), defaultChallengeIp: this.fb.nonNullable.control('', [Validators.required, this.defaultChallengeAddressValidator()]),
registrationsEnabled: this.fb.nonNullable.control(false), registrationsEnabled: this.fb.nonNullable.control(false),
}, },
{ validators: endAfterStartValidator }, { validators: endAfterStartValidator },
@@ -262,6 +281,17 @@ export class AdminGeneralComponent implements OnInit {
return eventEndFieldMessage(fieldErrs, crossErrs); return eventEndFieldMessage(fieldErrs, crossErrs);
}); });
readonly showDefaultChallengeIpError = computed(
() => this.defaultChallengeIpTouchedOrDirty() && this.defaultChallengeIpInvalid(),
);
readonly defaultChallengeIpMessage = computed(() =>
defaultChallengeAddressMessage(
this.defaultChallengeIpValue(),
this.form.controls.defaultChallengeIp.errors,
),
);
constructor() { constructor() {
this.form.controls.welcomeMarkdown.valueChanges this.form.controls.welcomeMarkdown.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -317,6 +347,23 @@ export class AdminGeneralComponent implements OnInit {
this.eventEndInvalid.set(ee.invalid); this.eventEndInvalid.set(ee.invalid);
this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty); this.eventEndTouchedOrDirty.set(ee.touched || ee.dirty);
}); });
const ip = this.form.controls.defaultChallengeIp;
this.defaultChallengeIpValue.set(ip.value);
this.defaultChallengeIpInvalid.set(ip.invalid);
this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty);
ip.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((v) => {
this.defaultChallengeIpValue.set(v);
this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty);
});
ip.statusChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.defaultChallengeIpInvalid.set(ip.invalid);
this.defaultChallengeIpTouchedOrDirty.set(ip.touched || ip.dirty);
});
} }
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
@@ -344,6 +391,19 @@ export class AdminGeneralComponent implements OnInit {
}; };
} }
private defaultChallengeAddressValidator(): (ctrl: { value: string | null }) => { defaultChallengeAddressFormat: true } | null {
return (ctrl) => {
const value = ctrl?.value ?? '';
if (value.length === 0) return null;
if (value.trim().length === 0) return null;
if (value.length > 255) return { defaultChallengeAddressFormat: true };
if (!isValidDefaultChallengeAddress(value)) {
return { defaultChallengeAddressFormat: true };
}
return null;
};
}
private applySettings(s: GeneralSettings): void { private applySettings(s: GeneralSettings): void {
this.form.patchValue({ this.form.patchValue({
pageTitle: s.pageTitle, pageTitle: s.pageTitle,
@@ -366,6 +426,10 @@ export class AdminGeneralComponent implements OnInit {
this.form.controls.eventEndUtc.markAsUntouched(); this.form.controls.eventEndUtc.markAsUntouched();
this.form.controls.eventEndUtc.markAsPristine(); this.form.controls.eventEndUtc.markAsPristine();
this.eventEndTouchedOrDirty.set(false); this.eventEndTouchedOrDirty.set(false);
this.form.controls.defaultChallengeIp.markAsUntouched();
this.form.controls.defaultChallengeIp.markAsPristine();
this.defaultChallengeIpTouchedOrDirty.set(false);
} }
async onSubmit(): Promise<void> { async onSubmit(): Promise<void> {
@@ -385,7 +449,7 @@ export class AdminGeneralComponent implements OnInit {
themeKey: v.themeKey, themeKey: v.themeKey,
eventStartUtc: toIsoUtc(v.eventStartUtc), eventStartUtc: toIsoUtc(v.eventStartUtc),
eventEndUtc: toIsoUtc(v.eventEndUtc), eventEndUtc: toIsoUtc(v.eventEndUtc),
defaultChallengeIp: v.defaultChallengeIp, defaultChallengeIp: normalizeDefaultChallengeAddress(v.defaultChallengeIp),
registrationsEnabled: v.registrationsEnabled, registrationsEnabled: v.registrationsEnabled,
}); });
this.applySettings(updated); this.applySettings(updated);
@@ -1,3 +1,68 @@
export type DefaultChallengeAddressError = 'required' | 'format' | null;
const HOSTNAME_LABEL = /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/;
const ALL_NUMERIC_LABEL = /^[0-9]+$/;
function isValidIpv4(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 4) return false;
for (const part of parts) {
if (part.length === 0 || part.length > 3) return false;
if (part.length > 1 && part.startsWith('0')) return false;
if (!/^[0-9]+$/.test(part)) return false;
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
}
return true;
}
function isValidHostname(value: string): boolean {
if (value.length === 0 || value.length > 253) return false;
if (value.startsWith('.') || value.endsWith('.')) return false;
const labels = value.split('.');
if (labels.length < 2) return false;
if (labels.some((label) => ALL_NUMERIC_LABEL.test(label))) return false;
return labels.every((label) => HOSTNAME_LABEL.test(label));
}
export function isValidDefaultChallengeAddress(value: string): boolean {
if (isValidIpv4(value)) return true;
return isValidHostname(value);
}
export function defaultChallengeAddressError(
value: string | null | undefined,
maxLength = 255,
): DefaultChallengeAddressError {
const raw = value ?? '';
if (raw.trim().length === 0) return 'required';
if (raw.length > maxLength) return 'format';
if (!isValidDefaultChallengeAddress(raw)) return 'format';
return null;
}
export function normalizeDefaultChallengeAddress(value: string): string {
return value.trim();
}
export const defaultChallengeAddressRequiredMessage =
'Default challenge IP is required and cannot contain only whitespace.';
export function defaultChallengeAddressMessage(
value: string | null | undefined,
controlErrors: { [key: string]: unknown } | null | undefined,
maxLength = 255,
): string | null {
const err = defaultChallengeAddressError(value, maxLength);
if (err === 'required') return defaultChallengeAddressRequiredMessage;
if (err === 'format') return 'Default challenge IP must be a valid IPv4 address or hostname.';
if (controlErrors?.['required']) return defaultChallengeAddressRequiredMessage;
if (controlErrors?.['defaultChallengeAddressFormat']) {
return 'Default challenge IP must be a valid IPv4 address or hostname.';
}
return null;
}
export type PageTitleError = 'required' | 'maxlength' | null; export type PageTitleError = 'required' | 'maxlength' | null;
export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError { export function pageTitleError(value: string | null | undefined, maxLength = 120): PageTitleError {
@@ -161,6 +161,65 @@ describe('GeneralSettingsSchema - validation rules', () => {
}); });
}); });
describe('GeneralSettingsSchema - defaultChallengeIp (Job 891)', () => {
const base = {
pageTitle: 'T',
logo: '',
welcomeMarkdown: '',
themeKey: 'classic',
eventStartUtc: '2026-01-01T00:00:00Z',
eventEndUtc: '2026-02-01T00:00:00Z',
registrationsEnabled: false,
};
it('accepts a valid IPv4 address', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.66.77.88' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.defaultChallengeIp).toBe('10.66.77.88');
});
it('accepts a valid hostname', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: 'challenge.example.com' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.defaultChallengeIp).toBe('challenge.example.com');
});
it('trims surrounding whitespace from a valid value', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: ' 10.20.30.40 ' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.defaultChallengeIp).toBe('10.20.30.40');
});
it('rejects an empty string', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '' });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.some((i) => i.path.join('.') === 'defaultChallengeIp')).toBe(true);
expect(r.error.issues.some((i) => /required/i.test(i.message))).toBe(true);
});
it('rejects a whitespace-only value', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: ' ' });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.some((i) => i.path.join('.') === 'defaultChallengeIp')).toBe(true);
});
it('rejects incomplete IPv4 forms', () => {
const r1 = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.0.0.' });
const r2 = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '10.0.0' });
expect(r1.success).toBe(false);
expect(r2.success).toBe(false);
});
it('rejects malformed addresses with the format message', () => {
const r = GeneralSettingsSchema.safeParse({ ...base, defaultChallengeIp: '999.1.1.1' });
expect(r.success).toBe(false);
if (r.success) return;
expect(r.error.issues.some((i) => /IPv4/.test(i.message) || /hostname/.test(i.message))).toBe(true);
});
});
describe('AdminGeneralService.updateSettings - happy path', () => { describe('AdminGeneralService.updateSettings - happy path', () => {
it('persists all keys and emits a settings event', async () => { it('persists all keys and emits a settings event', async () => {
const stored: Record<string, string> = {}; const stored: Record<string, string> = {};
+88
View File
@@ -1,9 +1,13 @@
import { import {
datetimeMessage, datetimeMessage,
defaultChallengeAddressError,
defaultChallengeAddressMessage,
deriveEventState, deriveEventState,
endAfterStartValidator, endAfterStartValidator,
eventEndFieldMessage, eventEndFieldMessage,
isValidDefaultChallengeAddress,
isoDatetimeValidator, isoDatetimeValidator,
normalizeDefaultChallengeAddress,
normalizePageTitle, normalizePageTitle,
pageTitleError, pageTitleError,
pageTitleMessage, pageTitleMessage,
@@ -238,3 +242,87 @@ describe('eventEndFieldMessage (Job 889)', () => {
expect(eventEndFieldMessage({}, {})).toBeNull(); expect(eventEndFieldMessage({}, {})).toBeNull();
}); });
}); });
describe('isValidDefaultChallengeAddress (Job 891)', () => {
it('accepts valid IPv4 addresses', () => {
expect(isValidDefaultChallengeAddress('10.66.77.88')).toBe(true);
expect(isValidDefaultChallengeAddress('127.0.0.1')).toBe(true);
expect(isValidDefaultChallengeAddress('0.0.0.0')).toBe(true);
expect(isValidDefaultChallengeAddress('255.255.255.255')).toBe(true);
});
it('accepts valid hostnames', () => {
expect(isValidDefaultChallengeAddress('challenge.example.com')).toBe(true);
expect(isValidDefaultChallengeAddress('ctf.example.org')).toBe(true);
});
it('rejects incomplete IPv4 forms', () => {
expect(isValidDefaultChallengeAddress('10.0.0.')).toBe(false);
expect(isValidDefaultChallengeAddress('10.0.0')).toBe(false);
expect(isValidDefaultChallengeAddress('10')).toBe(false);
});
it('rejects IPv4 addresses with out-of-range octets', () => {
expect(isValidDefaultChallengeAddress('999.1.1.1')).toBe(false);
expect(isValidDefaultChallengeAddress('256.0.0.1')).toBe(false);
});
it('rejects empty or whitespace-only strings', () => {
expect(isValidDefaultChallengeAddress('')).toBe(false);
expect(isValidDefaultChallengeAddress(' ')).toBe(false);
});
});
describe('defaultChallengeAddressError', () => {
it('returns "required" for empty or whitespace-only values', () => {
expect(defaultChallengeAddressError('')).toBe('required');
expect(defaultChallengeAddressError(' ')).toBe('required');
expect(defaultChallengeAddressError(null)).toBe('required');
});
it('returns "format" for malformed values', () => {
expect(defaultChallengeAddressError('10.0.0.')).toBe('format');
expect(defaultChallengeAddressError('10.0.0')).toBe('format');
expect(defaultChallengeAddressError('999.1.1.1')).toBe('format');
});
it('returns null for valid IPv4 or hostname values', () => {
expect(defaultChallengeAddressError('10.66.77.88')).toBeNull();
expect(defaultChallengeAddressError('challenge.example.com')).toBeNull();
});
});
describe('normalizeDefaultChallengeAddress', () => {
it('trims surrounding whitespace from valid input', () => {
expect(normalizeDefaultChallengeAddress(' 10.20.30.40 ')).toBe('10.20.30.40');
});
it('returns empty string for empty or whitespace-only input', () => {
expect(normalizeDefaultChallengeAddress('')).toBe('');
expect(normalizeDefaultChallengeAddress(' ')).toBe('');
});
});
describe('defaultChallengeAddressMessage', () => {
const requiredMsg = 'Default challenge IP is required and cannot contain only whitespace.';
const formatMsg = 'Default challenge IP must be a valid IPv4 address or hostname.';
it('returns the required message for empty or whitespace-only values', () => {
expect(defaultChallengeAddressMessage('', null)).toBe(requiredMsg);
expect(defaultChallengeAddressMessage(' ', null)).toBe(requiredMsg);
});
it('returns the format message for malformed values', () => {
expect(defaultChallengeAddressMessage('10.0.0.', null)).toBe(formatMsg);
expect(defaultChallengeAddressMessage('10.0.0', null)).toBe(formatMsg);
});
it('returns null for valid values', () => {
expect(defaultChallengeAddressMessage('10.66.77.88', null)).toBeNull();
expect(defaultChallengeAddressMessage('challenge.example.com', null)).toBeNull();
});
it('surfaces control-level errors when raw value would otherwise look valid', () => {
expect(defaultChallengeAddressMessage('not valid', { defaultChallengeAddressFormat: true })).toBe(formatMsg);
});
});