Files
HIPCTF2/docs/guides/admin-general-settings.md
T
2026-07-22 14:52:57 +00:00

16 KiB
Raw Blame History

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.
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.

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 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, 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 < startCOUNTDOWN
    • start <= now < endRUNNING
    • now >= endSTOPPED
  • 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 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)
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 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