332 lines
22 KiB
Markdown
332 lines
22 KiB
Markdown
---
|
||
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 1–120 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 0–255 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)
|