19 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| guide | Admin — General Settings | How an admin edits required platform-wide settings, including the validated event window, from the /admin/general page. |
|
2026-07-22T15:09:39Z |
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.
| 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)
- Sign in as an admin user (see First-Run Bootstrap if no admin exists).
- Open the username menu in the shell header and click Admin area,
or use the side-nav's General entry, or visit
/admin/generaldirectly. - The page renders a
Loading settings...placeholder while the initialGET /api/v1/admin/general/settings+GET .../themesrequests 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, 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() === truerendersgeneral-loading. After both requests resolve, the form is patched with the values from the backend (UTC timestamps are converted todatetime-localstrings viatoDatetimeLocalso the native picker shows them — see Datetime UTC round-trip below). - Logo upload: selecting a file fires
POST /api/v1/uploads/logo, then writes the returnedpublicUrlinto the hiddenlogocontrol. If the upload fails,general-logo-errorshows the message; the previous logo is preserved. - Welcome Markdown preview: every keystroke in
general-welcometriggersMarkdownService.renderand updatesgeneral-welcome-previewsynchronously. - Event-state derivation: the disabled
general-event-togglelabel is computed fromderiveEventState(start, end):- both empty →
UNCONFIGURED now < start→COUNTDOWNstart <= now < end→RUNNINGnow >= end→STOPPED
- both empty →
- 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-errorregion 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 througharia-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/settingswith 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 rendersgeneral-pageTitle-errorso the request is never issued. The server trims surrounding whitespace and re-validates against the same 1–120 character rule, so invalid payloads return400 VALIDATION_FAILEDand 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-okrenders briefly, and the backend emits an SSEgeneralevent viaSseHubServiceso other tabs refresh their theme. - Error states: load failures render
general-error; save failures rendergeneral-save-errorwith theerror.message(orerror.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 returnsnullonly whenDate.parseaccepts 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."wheninvalidDatetimeis 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.") whencontrolErrors?.['invalidDatetime']is set, otherwise it surfaces the cross-fieldendBeforeStartmessage ("Event end must be after event start."), otherwisenull. The component reads this helper through theeventEndFieldMessageTextcomputed signal so both error kinds render inside the singlegeneral-eventEnd-errorelement.
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
@ifblock that renders the inline<div class="field-error">withdata-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"] |
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
generalSSE 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
settingtable; the UI converts to/fromdatetime-localfor display. - Both timestamps are required by the General Settings form and API.
The
UNCONFIGUREDderived 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
invalidand Save stays disabled until they are.
See also
- Admin Shell — side-nav layout and guard chain.
- Admin — Categories — categories management page that renders below General settings.
- Admin Endpoints —
GET/PUT /api/v1/admin/general/*reference. - Uploads Endpoints —
POST /api/v1/uploads/logoreference. - Backend Module Map