--- 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` | `` 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` | `:00.000Z` | | Matching `YYYY-MM-DDTHH:mm:ss` | `.000Z` | | Matching `YYYY-MM-DDTHH:mm:ss.fff` | `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)