Files
HIPCTF2/docs/guides/admin-general-settings.md
T

332 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: guide
title: Admin — General Settings
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, default-challenge-ip, ip-hostname]
timestamp: 2026-07-22T15:46:18Z
---
# When this view is available
The General Settings page is rendered at `/admin/general` for users with
`role === 'admin'` once the instance is initialized. It is reached from
the [Admin Shell](/guides/admin-shell.md) side-nav (`General` entry) or
by navigating directly to the URL.
| Layer | File | Check |
|------------------|----------------------------------------------------------------------------|------------------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/general` child of `adminGuard`. |
| Client component | `frontend/src/app/features/admin/general.component.ts` | `AdminGeneralComponent.ngOnInit` fetches settings + themes. |
| Client predicate | `frontend/src/app/features/admin/general.pure.ts` (`deriveEventState`, `pageTitleError`, `pageTitleMessage`, `eventEndFieldMessage`) | Pure helpers for event-state derivation, Page-title error/message mapping, and the merged Event End field message (per-field `invalidDatetime` + cross-field `endBeforeStart`). |
| Server route | `backend/src/modules/admin/admin-general.controller.ts` | `GET/PUT /api/v1/admin/general/settings` + `GET /api/v1/admin/general/themes`. |
# How to access (tester steps)
1. Sign in as an admin user (see [First-Run Bootstrap](/guides/bootstrap.md)
if no admin exists).
2. Open the username menu in the shell header and click **Admin area**,
or use the side-nav's **General** entry, or visit `/admin/general`
directly.
3. The page renders a `Loading settings...` placeholder while the
initial `GET /api/v1/admin/general/settings` + `GET .../themes`
requests are in flight.
# Fields
The page is a single reactive form with these controls (every
`data-testid` listed is asserted in the existing test suite):
| Label | `data-testid` | Backend field | Notes |
|------------------------|---------------------------|-------------------------|-------|
| Page title | `general-pageTitle` | `pageTitle` | Required, non-blank, max 120 chars; trimmed server-side. |
| Logo (file picker) | `general-logo-file` | `logo` (public URL) | Uploads via `POST /api/v1/uploads/logo`; the returned `publicUrl` is bound to a hidden input `general-logo`. |
| Logo upload status | `general-logo-uploading` / `general-logo-error` / `general-logo-current` | — | Inline status text under the file picker. |
| 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 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, 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`. |
| 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`). |
| Save button | `general-save` | — | Disabled while `submitting()` or `form.invalid`. |
| Save error / success | `general-save-error` / `general-save-ok` | — | Inline status. |
# Expected behavior
* **Initial load:** `loading() === true` renders `general-loading`. After
both requests resolve, the form is patched with the values from the
backend (UTC timestamps are converted to `datetime-local` strings via
`toDatetimeLocal` so the native picker shows them — see
[Datetime UTC round-trip](#datetime-utc-round-trip) below).
* **Logo upload:** selecting a file fires `POST /api/v1/uploads/logo`,
then writes the returned `publicUrl` into the hidden `logo` control.
If the upload fails, `general-logo-error` shows the message; the
previous logo is preserved.
* **Welcome Markdown preview:** every keystroke in `general-welcome`
triggers `MarkdownService.render` and updates `general-welcome-preview`
synchronously.
* **Event-state derivation:** the disabled `general-event-toggle` label
is computed from `deriveEventState(start, end)`:
* both empty → `UNCONFIGURED`
* `now < start``COUNTDOWN`
* `start <= now < end``RUNNING`
* `now >= end``STOPPED`
* **End-before-start validation:** if the user picks an end that is not
strictly after the start, the form becomes invalid and the
`general-eventEnd-error` region under the end input renders
"Event end must be after event start." Save remains disabled.
* **Per-field datetime validation:** clearing a field or supplying a value
that cannot be parsed as an ISO-8601 datetime makes that control invalid.
The affected input receives `aria-invalid="true"`, references its inline
error through `aria-describedby`, and renders
`"<Field> must be a valid ISO-8601 datetime."`. Save remains disabled
until both timestamps are valid and ordered correctly.
* **Save:** clicking Save sends `PUT /api/v1/admin/general/settings`
with all fields. The Page title input is validated client-side for
non-blank content; an empty or whitespace-only Page title disables the
Save button and renders `general-pageTitle-error` so the request is
never issued. The server trims surrounding whitespace and re-validates
against the same 1120 character rule, so invalid payloads return
`400 VALIDATION_FAILED` and the stored Page title is unchanged.
On success the form is patched with the response, the Page-title
control is marked untouched/pristine (so a freshly-loaded valid
form does not flash an error), `general-save-ok` renders briefly, and
the backend emits an SSE `general` event via `SseHubService` so
other tabs refresh their theme.
* **Error states:** load failures render `general-error`; save failures
render `general-save-error` with the `error.message` (or
`error.error.message`) from the standard envelope.
# Page-title inline error message
The inline error message under the Page-title field
(`general-pageTitle-error`) is driven by the pure helper
`pageTitleMessage(value, controlErrors, maxLength = 120)` exported from
`frontend/src/app/features/admin/general.pure.ts:10-22`. The component
keeps three local `signal`s — `pageTitleValue`, `pageTitleInvalid`,
`pageTitleTouchedOrDirty` — and subscribes to the reactive form
control's `valueChanges` and `statusChanges` (via
`takeUntilDestroyed(this.destroyRef)`) so the `computed`s for
`showPageTitleError` and `pageTitleMessage` re-evaluate when the user
types or blurs the input. The helper returns:
| Condition | Message |
|--------------------------------------------------------------|-----------------------------------------------------------|
| Empty or whitespace-only value | `Page title is required and cannot contain only whitespace.` |
| Value length > 120 | `Page title must be 120 characters or fewer.` |
| Control has `required`, `maxlength`, or `whitespace` error | Matching human-readable message (whitespace falls back to "required" wording). |
| Valid value | `null` (no message rendered, Save becomes enabled). |
As a result, the `general-pageTitle-error` element renders as soon as
the Page-title control becomes both `touched || dirty` AND `invalid`,
without requiring a full component re-render. After a successful
Save, the control is reset to untouched/pristine and the
`pageTitleTouchedOrDirty` signal is cleared, so reloading valid
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
The inline error elements under the Event start and Event end inputs
(`general-eventStart-error` and `general-eventEnd-error`) are driven by
two pure helpers exported from
`frontend/src/app/features/admin/general.pure.ts`:
* `isoDatetimeValidator(ctrl)` — an Angular reactive-forms validator
that returns `null` only when `Date.parse` accepts the value. Empty and
malformed values return `{ invalidDatetime: true }`.
* `datetimeMessage(fieldLabel, value, controlErrors)` — renders the
human-readable message `"<Field> must be a valid ISO-8601 datetime."`
when `invalidDatetime` is set or when the raw value is unparseable.
* `eventEndFieldMessage(controlErrors, crossFieldErrors)` — returns the
canonical message for the Event End field's own error region. It
prefers the per-field ISO message
(`"Event end must be a valid ISO-8601 datetime."`) when
`controlErrors?.['invalidDatetime']` is set, otherwise it surfaces the
cross-field `endBeforeStart` message
(`"Event end must be after event start."`), otherwise `null`. The
component reads this helper through the `eventEndFieldMessageText`
computed signal so both error kinds render inside the single
`general-eventEnd-error` element.
The component wires both controls (`eventStartUtc`, `eventEndUtc`) with
this validator and keeps three local `signal`s per field — `Value`,
`Invalid`, `TouchedOrDirty` — exactly mirroring the Page-title pattern.
The `computed` signals `showEventStartError` / `showEventEndError` and
`eventStartMessage` / `eventEndMessage` are then bound to:
* `[attr.aria-invalid]` and `[attr.aria-describedby]` on each 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-eventStart-error"` /
`data-testid="general-eventEnd-error"`.
| Condition | Message |
|------------------------------------------------------------|--------------------------------------------------------|
| Empty value (`''`) | `"<Field> must be a valid ISO-8601 datetime."` |
| Value that `Date.parse` accepts | `null` (no error). |
| Non-empty value that `Date.parse` rejects | `"<Field> must be a valid ISO-8601 datetime."` |
| Control carries `invalidDatetime` error | Same message (covered by the validator path). |
The error is shown only when the control is both `touched || dirty`
AND `invalid`, so the inputs do not flash errors on initial load. After
a successful Save, both controls are reset to untouched/pristine via
`resetTouchedState()` and their `TouchedOrDirty` signals are cleared.
The cross-field `endBeforeStart` error is emitted by the form-level
validator whenever both valid timestamps are present and end is not
strictly after start. The Event end input receives `aria-invalid="true"`,
references `general-eventEnd-error` via `aria-describedby`, and exposes
`Event end must be after event start.` as its title — the message itself
is rendered inside the per-field error region
(`data-testid="general-eventEnd-error"`) by the
`eventEndFieldMessage` helper.
# Datetime UTC round-trip
Both Event-window fields (`general-eventStart`, `general-eventEnd`) are
backed by HTML `<input type="datetime-local">` controls, which the
browser renders as a wall-clock picker **without** any timezone
indicator. The two pure helpers exported from
`frontend/src/app/features/admin/general.pure.ts` make the wall-clock
values behave as if they were already UTC, so the admin sees the same
instant no matter where the browser is running:
| Helper | Direction | Purpose |
|------------------------------------------------|-------------------------------------|---------|
| `toDatetimeLocal(iso: string): string` | ISO-8601 UTC → `YYYY-MM-DDTHH:mm` | Renders a backend UTC instant in the native picker using `getUTC*` getters. |
| `toIsoUtc(local: string): string` | `YYYY-MM-DDTHH:mm[:ss[.fff]]` → ISO-8601 UTC | Parses the wall-clock components as **UTC** (not local time) via `Date.UTC(...)` and returns `toISOString()`. |
The `toIsoUtc` helper explicitly does **not** call `new Date(local)`,
which would interpret the components in the browser's local timezone
and shift the round-tripped instant by the browser's UTC offset. Instead
it regex-parses the year / month / day / hour / minute components (and
optional seconds / milliseconds), constructs the instant with
`new Date(Date.UTC(...))`, and returns `toISOString()`. As a result,
`toIsoUtc(toDatetimeLocal(iso)) === iso` for any minute-precision
`datetime-local` value the picker can produce, regardless of the
browser's `Intl.DateTimeFormat().resolvedOptions().timeZone` value.
The helper preserves its existing fallback contract:
| Input | Output |
|------------------------------------|---------------------------------|
| `''` (empty) | `''` |
| Non-matching `YYYY-MM-DDTHH:mm…` | Returns the input verbatim so the reactive-form `invalidDatetime` validator still fires. |
| Matching `YYYY-MM-DDTHH:mm` | `<value>:00.000Z` |
| Matching `YYYY-MM-DDTHH:mm:ss` | `<value>.000Z` |
| Matching `YYYY-MM-DDTHH:mm:ss.fff` | `<value>Z` (fraction padded to ms) |
`AdminGeneralComponent` calls `toIsoUtc` on both `eventStartUtc` and
`eventEndUtc` before issuing `PUT /api/v1/admin/general/settings`, so
correcting the shared helper fixes start and end submissions without
any further component, form, or backend rewiring. The backend zod
schema (`GeneralSettingsSchema` in
`backend/src/modules/admin/dto/general.dto.ts`) accepts the resulting
timezone-aware ISO-8601 strings unchanged and enforces
`eventEndUtc > eventStartUtc` via `superRefine`.
The regression for this fix lives in
`tests/frontend/admin-general-pure.spec.ts` under the `datetime helpers`
suite, with cases that lock in:
* `toIsoUtc('2027-03-15T08:30')``'2027-03-15T08:30:00.000Z'`
(would previously have returned `'2027-03-15T15:30:00.000Z'` from a
PDT browser).
* `toIsoUtc('2027-03-15T08:30:15')``'2027-03-15T08:30:15.000Z'`.
* `toIsoUtc('2027-03-15T08:30:15.123')``'2027-03-15T08:30:15.123Z'`.
* `toIsoUtc(toDatetimeLocal('2027-03-15T08:30:00.000Z'))` round-trips
back to the original ISO string.
# Visual elements
| Element | Selector |
|-------------------------------|-----------------------------------------------------|
| Page section | `[data-testid="admin-general"]` |
| Loading placeholder | `[data-testid="general-loading"]` |
| Load error | `[data-testid="general-error"]` |
| Form | `[data-testid="general-form"]` |
| Welcome preview | `[data-testid="general-welcome-preview"]` |
| 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) |
| Page-title inline error | `[data-testid="general-pageTitle-error"]` |
| Default challenge IP inline error | `[data-testid="general-defaultIp-error"]` |
# Architecture map
| Step | Where | What happens |
|------|------------------------------------------------------|---------------------------------------------------------------------------|
| 1 | `frontend/src/app/app.routes.ts` | `/admin/general` lazy-loads `AdminGeneralComponent`. |
| 2 | `frontend/src/app/features/admin/general.component.ts` | `ngOnInit` calls `AdminService.getGeneralSettings()` + `listAdminThemes()` in parallel. |
| 3 | `frontend/src/app/core/services/admin.service.ts` | `getGeneralSettings()``GET /api/v1/admin/general/settings`; `listAdminThemes()``GET /api/v1/admin/general/themes`; `updateGeneralSettings()``PUT .../settings`; `uploadLogo()``POST /api/v1/uploads/logo`. |
| 4 | `backend/src/modules/admin/admin-general.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 5 | `backend/src/modules/admin/general.service.ts` | `getSettings` reads 8 keys via `SettingsService`; `updateSettings` writes all 8, emits `{ topic: 'general', themeKey }` via `SseHubService`. |
| 6 | `backend/src/modules/admin/dto/general.dto.ts` | `GeneralSettingsSchema` requires ISO-8601 values for both event-window fields and enforces `eventEndUtc > eventStartUtc` via `superRefine`. |
# Notes
* The `general` SSE event (`{ topic: 'general', themeKey }`) is a
lightweight signal so authenticated tabs can pick up the new theme
without polling. Other tabs do not auto-refresh settings values.
* The "Event controls" toggle is intentionally a derived display, not
an editable control — adjust the UTC timestamps to change state.
* All valid timestamps are stored as ISO-8601 UTC strings in the `setting`
table; the UI converts to/from `datetime-local` for display.
* Both timestamps are required by the General Settings form and API.
The `UNCONFIGURED` derived display remains a defensive state for missing
or invalid data, but clearing either input makes the form unsavable.
* Saving the form requires the page title and default challenge IP to
be non-empty; the form is `invalid` and Save stays disabled until
they are.
# See also
- [Admin Shell](/guides/admin-shell.md) — side-nav layout and guard chain.
- [Admin — Categories](/guides/admin-categories.md) — categories management page that renders below General settings.
- [Admin Endpoints](/api/admin.md) — `GET/PUT /api/v1/admin/general/*` reference.
- [Uploads Endpoints](/api/uploads.md) — `POST /api/v1/uploads/logo` reference.
- [Backend Module Map](/architecture/backend-modules.md)