How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page.
guide
admin
settings
general
tester
datetime
validation
2026-07-22T14:50:25Z
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 side-nav (General entry) or
by navigating directly to the URL.
Pure helpers for event-state derivation, Page-title error/message mapping, and the merged Event End field message (per-field invalidDatetime + cross-field endBeforeStart).
<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, max 255 chars.
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).
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 signals — pageTitleValue, pageTitleInvalid,
pageTitleTouchedOrDirty — and subscribes to the reactive form
control's valueChanges and statusChanges (via
takeUntilDestroyed(this.destroyRef)) so the computeds 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.
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 signals 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.
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)
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.
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.