AI Implementation feature(886): Admin Area General Settings and Categories 1.04 #26
@@ -1,26 +0,0 @@
|
|||||||
# Implementation Plan: Admin Area General Settings and Categories 1.03
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
- **Codebase style & conventions:** TypeScript monorepo with a NestJS 10 REST backend and Angular 17 standalone components. Upload requests are encapsulated in `AdminService`; `AdminGeneralComponent` uses reactive forms, signals, `async` handlers, and inline `data-testid` feedback. Backend upload handling currently lives directly in `UploadsController`, uses Multer memory buffers, `sharp`, collision-resistant `safeFilename`, and the global exception filter's standard `{ code, message, path, timestamp }` envelope. The Job is not already implemented: `handleLogoUpload` only checks presence/size before writing raw bytes, while the existing frontend error branch is never reached for malformed data.
|
|
||||||
- **Data Layer:** SQLite through TypeORM for settings, but this fix does not require a schema or migration. Logo bytes are stored under configured `UPLOAD_DIR` (default `/data/hipctf/uploads`) and become active only after the returned URL is assigned to the form and settings are saved.
|
|
||||||
- **Test Framework & Structure:** Root Jest 29/ts-jest multi-project configuration. Backend endpoint tests live in `tests/backend`, use Nest's real application, in-memory SQLite, Supertest, CSRF/auth setup, and an isolated upload directory; frontend logic tests live in `tests/frontend` with jsdom. All tests run with root `npm test`, with focused backend runs available through `npm run test:backend`.
|
|
||||||
- **Required Tools & Dependencies:** No new system tool, global CLI, package, or `setup.sh` change is required. Reuse the already installed `sharp` dependency to decode the full image and obtain authoritative format metadata rather than trusting the multipart MIME type, extension, or magic bytes alone. Existing setup already installs native dependencies, prepares `/data/hipctf/uploads`, and builds both workspaces.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
- **To Modify:**
|
|
||||||
- `backend/src/modules/uploads/uploads.controller.ts` — make logo handling asynchronous; decode and validate the complete image against an explicit logo format policy before creating a filename or writing bytes.
|
|
||||||
- `tests/backend/uploads-logo.spec.ts` — replace the header-only pseudo-PNG success fixture with a genuinely decodable image and add focused missing/malformed/unsupported rejection assertions, including proof that rejected payloads create no files.
|
|
||||||
- **To Create:** None.
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
1. **Database / Schema Migration:** No database change. Invalid uploads must never return a URL or mutate the General form's existing `logo` control, so the persisted setting and subsequent bootstrap `pageLogo` remain unchanged.
|
|
||||||
2. **Backend Logic & APIs:**
|
|
||||||
- Convert `handleLogoUpload` to return a promise and await it from `uploadLogo`.
|
|
||||||
- Retain the current missing-file and configured-size checks, then call `sharp(file.buffer).metadata()` (or an equivalent full decode operation) before any filesystem side effect. Treat decode failures, absent format/dimensions, truncated/corrupt images, and formats outside an explicit configured logo allowlist as `400 BadRequestException`/`VALIDATION_FAILED` with a stable, user-readable message such as `Logo must be a valid PNG, JPEG, GIF, or WebP image.`
|
|
||||||
- Define the logo policy beside the handler as a readonly set/literal union so accepted formats are auditable. Base acceptance on Sharp's detected format, not `file.mimetype`, original extension, or browser `accept` filtering. Keep the current upload-size policy and collision-resistant filename behavior for accepted files.
|
|
||||||
- Perform validation before `mkdir`/`writeFileSync`; only valid bytes reach `UPLOAD_DIR`. Preserve the response contract `{ publicUrl, originalFilename }` and existing 201 status for valid uploads.
|
|
||||||
3. **Frontend UI Integration:** No frontend code change is expected. `AdminGeneralComponent.onLogoFileChange` already clears the prior error, updates `form.controls.logo` only after a successful `uploadLogo` response, catches the server's standard error message into `logoUploadError`, and resets `uploadingLogo` in `finally`. Once the backend rejects malformed payloads, `[data-testid="general-logo-error"]` will render, the previous hidden `[data-testid="general-logo"]` value will be preserved, and the form remains usable. Do not add client-only validation as a security boundary; `accept="image/*"` remains a picker hint.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
- **Target Unit Test File:** Modify `tests/backend/uploads-logo.spec.ts`; no broad component harness or UI/E2E suite is needed because the existing component's success-only mutation and catch/finally behavior already provides the required preservation/feedback flow.
|
|
||||||
- **Mocking Strategy:** Use no mocks for image validation or filesystem behavior. Generate a tiny valid PNG in-memory with the existing `sharp` package for the core success test. Through the authenticated Supertest endpoint, assert: missing multipart `file` returns 400; representative malformed/unsupported inputs (plain text with a misleading image name/MIME, a truncated GIF, and an eight-zero-byte PNG sent as `image/png`) return 400 with the standard validation envelope; and the upload directory listing is unchanged after each rejection. Keep one valid-image assertion proving a URL is returned, the file is persisted/fetchable, and cleanup remains hermetic. Run all coverage from the repository root with `npm test`; during implementation also run the existing root build/typecheck (`npm run build`) since no standalone lint script is defined.
|
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Implementation Plan: Admin Area General Settings and Categories 1.04
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
- **Codebase style & conventions:** Node.js monorepo with a NestJS 10 REST backend and Angular 17 standalone frontend. Frontend components use `inject()`, Angular Signals, reactive forms, `ChangeDetectionStrategy.OnPush`, and async service methods backed by `HttpClient`/`firstValueFrom`. Global bootstrap data and CSS theme application are owned by the root-scoped `BootstrapService`; admin form persistence is owned by `AdminGeneralComponent` and `AdminService`.
|
||||||
|
- **Current flow and root cause:** Application startup calls `BootstrapService.load()`, stores the `/api/v1/bootstrap` payload, and writes its theme tokens to `document.documentElement.style`. Saving `/admin/general` successfully persists `themeKey` through `PUT /api/v1/admin/general/settings`, and the backend later resolves the chosen theme in bootstrap. However, `AdminGeneralComponent.onSubmit()` only patches the returned settings form; it never asks `BootstrapService` to refresh or apply the newly selected theme. The backend's `general` SSE event contains only `themeKey`, while the frontend event-status stream treats every hub payload as event-state data and has no theme propagation path. Thus the save response changes persistence but not the current document tokens.
|
||||||
|
- **Data Layer:** TypeORM with SQLite (`better-sqlite3`). The existing `setting` table stores `themeKey`; no schema or migration change is required. Persistent runtime data remains under `/data/hipctf` through existing setup conventions; the planned focused frontend tests require no persistent data.
|
||||||
|
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; frontend tests run in jsdom from `tests/frontend/**/*.spec.ts`, backend tests run in Node from `tests/backend/**/*.spec.ts`, and all tests are runnable with `npm test`. The fix should follow a minimal red-green-refactor path and test logic rather than visual appearance or browser automation.
|
||||||
|
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, or `/data` assets are required. Existing Node/npm, Angular, Jest, jsdom, TypeScript, and `setup.sh` are sufficient; `setup.sh` and lockfiles should remain unchanged.
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
- **To Modify:**
|
||||||
|
- `frontend/src/app/core/services/bootstrap.service.ts` — expose a typed, reusable live theme application/refresh path that updates both bootstrap client state and document CSS custom properties without being blocked by the startup request cache.
|
||||||
|
- `frontend/src/app/features/admin/general.component.ts` — inject `BootstrapService` and invoke the live theme refresh only after `updateGeneralSettings()` succeeds, before reporting Save success.
|
||||||
|
- **To Create:**
|
||||||
|
- `tests/frontend/bootstrap-theme.spec.ts` — focused jsdom tests for applying all theme token fields and refreshing to a saved non-default theme.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
1. **Database / Schema Migration:**
|
||||||
|
- No migration or backend persistence change. `AdminGeneralService.updateSettings()` already saves `SETTINGS_KEYS.THEME_KEY`, and `SystemService.bootstrap()` already reads that key and returns the corresponding complete theme object.
|
||||||
|
2. **Backend Logic & APIs:**
|
||||||
|
- Keep the existing `PUT /api/v1/admin/general/settings` and `GET /api/v1/bootstrap` contracts unchanged. The save response intentionally contains settings rather than theme tokens; reuse bootstrap as the canonical theme-token source instead of duplicating the backend theme model in the admin response.
|
||||||
|
- Do not add backend endpoints, dependencies, SSE handling, or theme-token duplication. The reported same-tab Save issue can be resolved deterministically from the successful save path, while existing persistence guarantees future reloads use the selected theme.
|
||||||
|
3. **Frontend UI Integration:**
|
||||||
|
- Replace the frontend `theme: any` shape with local typed `Theme`/`ThemeTokens` interfaces matching the bootstrap contract, including colors, font family, radii, and spacing scale. Type the theme application method accordingly and preserve the defensive no-token guard for malformed/absent payloads.
|
||||||
|
- Make theme application a reusable service operation rather than startup-only private behavior. It must continue setting all existing CSS variables (`--color-primary`, `--color-secondary`, `--color-accent`, `--color-surface`, `--color-text`, `--color-success`, `--color-warning`, `--color-danger`, `--font-family`, and all three radius properties), including valid zero-valued radius strings such as Monochrome's `0`.
|
||||||
|
- Add a force-refresh method in `BootstrapService` that performs a fresh `/api/v1/bootstrap` request instead of reusing `loadPromise`, validates HTTP success before consuming the payload, updates `payload` and `initialized`, and applies the returned theme to the live document. Keep startup `load()`/`ready()` request coalescing intact so guard/bootstrap race behavior does not regress.
|
||||||
|
- Inject `BootstrapService` into `AdminGeneralComponent`. After `AdminService.updateGeneralSettings()` resolves and the form is patched, await the force-refresh method before setting `saveOk`. This orders persistence before re-fetch, applies the canonical saved theme immediately, and also synchronizes other global bootstrap-backed settings in the current tab.
|
||||||
|
- Treat a failed refresh as a Save error rather than showing a false success: retain the persisted form response, surface the existing inline error path, and leave `submitting` cleanup in `finally`. Do not mutate theme tokens before the PUT succeeds, so failed saves leave the current live theme unchanged.
|
||||||
|
- No template or categories changes are needed; the Global Theme select already includes the server-provided canonical choices, and category behavior is unrelated to this regression.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
- **Target Unit Test File:** `tests/frontend/bootstrap-theme.spec.ts`, executed by the existing root `npm test` command (or selectively through the frontend Jest project during development). Do not add visual, browser, or persistence-volume tests.
|
||||||
|
- **Mocking Strategy:** Use jsdom's real `document.documentElement.style` and mock only global `fetch`. Reset CSS properties and the fetch mock between tests to avoid shared state. Provide small typed bootstrap payload fixtures rather than Angular TestBed or a backend/database instance.
|
||||||
|
- **Core success path:** Start with Classic CSS values, have the mocked refresh return a Monochrome bootstrap payload, call the refresh method, and assert the payload signal plus representative computed CSS variables change immediately to `--color-primary: #000000`, `--color-text: #0a0a0a`, and `--radius-md: 0`; also verify all token mappings in one compact table-driven assertion so every canonical theme token category is covered.
|
||||||
|
- **Key error condition:** Mock a non-OK bootstrap refresh response and assert the method rejects without replacing the existing payload or applying partial/new CSS tokens. This supports the component's existing save-error handling while keeping the suite minimal.
|
||||||
|
- **Implementation verification:** Run the focused frontend test first to prove the regression, then `npm test`, `npm run build`, and the repository's available TypeScript builds. No lint script is defined in the root or workspace package manifests, so no additional lint dependency or setup change should be introduced.
|
||||||
+5
-1
@@ -3,7 +3,7 @@ type: api
|
|||||||
title: System Endpoints
|
title: System Endpoints
|
||||||
description: Bootstrap payload, public event status, public/authenticated SSE streams, and public event-window timestamps.
|
description: Bootstrap payload, public event status, public/authenticated SSE streams, and public event-window timestamps.
|
||||||
tags: [api, system, bootstrap, event, sse, settings]
|
tags: [api, system, bootstrap, event, sse, settings]
|
||||||
timestamp: 2026-07-21T22:19:08Z
|
timestamp: 2026-07-22T13:40:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
@@ -52,6 +52,10 @@ SPA's `BootstrapService`:
|
|||||||
* `passwordPolicy` is derived from `PASSWORD_MIN_LENGTH` /
|
* `passwordPolicy` is derived from `PASSWORD_MIN_LENGTH` /
|
||||||
`PASSWORD_REQUIRE_MIXED` env vars (see
|
`PASSWORD_REQUIRE_MIXED` env vars (see
|
||||||
[Change Password Guide](/guides/change-password.md)).
|
[Change Password Guide](/guides/change-password.md)).
|
||||||
|
* The frontend `BootstrapService` caches the initial bootstrap request through
|
||||||
|
`load()`/`ready()`. Its `refresh()` method fetches the payload again, updates
|
||||||
|
signals, and reapplies the theme; `/admin/general` calls it after a successful
|
||||||
|
settings update.
|
||||||
|
|
||||||
# `GET /api/v1/event/status` (legacy public snapshot)
|
# `GET /api/v1/event/status` (legacy public snapshot)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
type: architecture
|
type: architecture
|
||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for important source files, including authenticated event streaming and validated site-logo uploads.
|
description: One-line responsibility for important source files, including bootstrap payloads, runtime theme application, authenticated event streaming, and validated site-logo uploads.
|
||||||
tags: [architecture, index, key-files]
|
tags: [architecture, index, key-files]
|
||||||
timestamp: 2026-07-22T10:32:00Z
|
timestamp: 2026-07-22T13:40:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
@@ -19,7 +19,9 @@ timestamp: 2026-07-22T10:32:00Z
|
|||||||
| `backend/src/common/services/event-status.service.ts` | Computes the event-window state machine. |
|
| `backend/src/common/services/event-status.service.ts` | Computes the event-window state machine. |
|
||||||
| `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for server-sent event streams. |
|
| `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for server-sent event streams. |
|
||||||
| `backend/src/modules/system/system.controller.ts` | Registers bootstrap, event status, settings, and SSE endpoints. |
|
| `backend/src/modules/system/system.controller.ts` | Registers bootstrap, event status, settings, and SSE endpoints. |
|
||||||
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload. |
|
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from admin existence, settings, the theme loader, and password-policy configuration. |
|
||||||
|
| `backend/src/modules/admin/admin-general.controller.ts` | Registers admin general-settings and available-theme endpoints. |
|
||||||
|
| `backend/src/modules/admin/general.service.ts` | Reads/writes global settings, filters available theme files, and publishes the `general` SSE notification after updates. |
|
||||||
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
|
| `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
|
||||||
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
|
| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
|
||||||
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
|
| `backend/src/modules/uploads/uploads.controller.ts` | Registers admin-only multipart uploads, including Sharp-backed site-logo format validation. |
|
||||||
@@ -30,7 +32,9 @@ timestamp: 2026-07-22T10:32:00Z
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `frontend/src/main.ts` | Bootstraps Angular and registers HTTP interceptors. |
|
| `frontend/src/main.ts` | Bootstraps Angular and registers HTTP interceptors. |
|
||||||
| `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. |
|
| `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. |
|
||||||
| `frontend/src/app/app.component.ts` | Loads bootstrap data and restores the session. |
|
| `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. |
|
||||||
|
| `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. |
|
||||||
|
| `frontend/src/app/core/services/admin.service.ts` | Calls admin settings, theme-list, category, and upload APIs. |
|
||||||
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. |
|
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. |
|
||||||
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. |
|
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. |
|
||||||
| `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. |
|
| `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. |
|
||||||
|
|||||||
+60
-26
@@ -1,50 +1,84 @@
|
|||||||
---
|
---
|
||||||
type: guide
|
type: guide
|
||||||
title: Theming
|
title: Theming
|
||||||
description: How the 10 canonical themes are loaded, validated, and applied to the SPA via CSS custom properties.
|
description: How the canonical themes are loaded, selected by administrators, and applied to the SPA via CSS custom properties.
|
||||||
tags: [guide, theming, design]
|
tags: [guide, theming, design, admin]
|
||||||
timestamp: 2026-07-21T18:28:00Z
|
timestamp: 2026-07-22T13:40:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Theme catalog
|
# Theme catalog
|
||||||
|
|
||||||
HIPCTF ships 10 canonical themes defined as JSON files in
|
HIPCTF ships canonical themes defined as JSON files in `backend/themes/`. The
|
||||||
`backend/themes/` (`01-classic.json` … `10-monochrome.json`). The full
|
catalog is identified by `THEME_IDS` in
|
||||||
list lives in `backend/src/common/types/theme-ids.ts`:
|
`backend/src/common/types/theme-ids.ts`; the default is `classic`.
|
||||||
|
|
||||||
`classic`, `midnight`, `sunset`, `forest`, `cyber`, `paper`, `crimson`,
|
Each theme contains an `id`, display `name`, and token set containing colors,
|
||||||
`ocean`, `neon`, `monochrome`.
|
font family, radii, and spacing values. The frontend maps the supported visual
|
||||||
|
tokens to CSS custom properties.
|
||||||
|
|
||||||
Each theme is a flat map of CSS custom properties (colors, radii, font
|
# Administrator workflow
|
||||||
family) consumed by `frontend/src/styles.css`.
|
|
||||||
|
1. Sign in as an administrator and open **Admin area → General**, or navigate
|
||||||
|
to `/admin/general`.
|
||||||
|
2. Wait for the settings and available-theme requests to finish.
|
||||||
|
3. Choose a value from **Global theme** (`data-testid="general-themeKey"`).
|
||||||
|
4. Click **Save** (`data-testid="general-save"`).
|
||||||
|
5. Verify that `Saved.` appears and that the page uses the selected colors,
|
||||||
|
font, and corner radii. Reloading the application should preserve the theme.
|
||||||
|
|
||||||
|
The selector lists themes exposed by `GET /api/v1/admin/general/themes`. Only
|
||||||
|
theme JSON files present in the configured `THEMES_DIR` are listed, while the
|
||||||
|
loader still maintains canonical fallback themes for bootstrap and runtime
|
||||||
|
safety.
|
||||||
|
|
||||||
# Backend loading
|
# Backend loading
|
||||||
|
|
||||||
`ThemeLoaderService` (`backend/src/common/utils/theme-loader.service.ts`)
|
`ThemeLoaderService` (`backend/src/common/utils/theme-loader.service.ts`)
|
||||||
runs on module init:
|
runs on module initialization:
|
||||||
|
|
||||||
1. Reads every `*.json` file in `backend/themes/`.
|
1. Reads theme JSON files from `THEMES_DIR` (default `./themes`).
|
||||||
2. Validates each against the `Theme` interface
|
2. Validates IDs and tokens; malformed files are skipped or reported through
|
||||||
(`backend/src/common/types/theme.ts`) — unknown theme ids, malformed
|
the loader's validation path.
|
||||||
JSON, or missing tokens throw `ApiError(THEME_INVALID)`.
|
3. Backfills missing canonical themes so the application always has a valid
|
||||||
3. Backfills any of the 10 canonical themes that are missing from disk
|
catalog.
|
||||||
(safety net so a partial checkout still boots).
|
4. Validates the configured `SETTINGS_KEYS.THEME_KEY`; an unknown value falls
|
||||||
4. Reads `SETTINGS_KEYS.THEME_KEY` (default `'classic'`). If the value is
|
back to `classic` and is persisted as the repaired setting.
|
||||||
not in `THEME_IDS`, the loader falls back to `classic`, logs a
|
|
||||||
warning, and persists `'classic'` back to settings so the next request
|
|
||||||
is self-healing.
|
|
||||||
|
|
||||||
The resolved theme object is sent to the SPA in the
|
The selected theme is sent to the SPA in `GET /api/v1/bootstrap` (see
|
||||||
`GET /api/v1/bootstrap` payload (see [System Endpoints](/api/system.md)).
|
[System Endpoints](/api/system.md)). Administrators can read and update the
|
||||||
|
selection through [Admin Endpoints](/api/admin.md). A successful general-settings
|
||||||
|
update emits a `general` SSE event containing the new `themeKey`.
|
||||||
|
|
||||||
# Frontend application
|
# Frontend application
|
||||||
|
|
||||||
`BootstrapService` (`frontend/src/app/core/services/bootstrap.service.ts`)
|
`BootstrapService` (`frontend/src/app/core/services/bootstrap.service.ts`)
|
||||||
writes each theme token onto `document.documentElement` (e.g.
|
fetches `/api/v1/bootstrap` once per application lifecycle, stores the payload
|
||||||
`--color-primary`, `--radius-md`, `--font-family`). Because `styles.css`
|
in signals, and applies the returned theme before the UI is rendered. The
|
||||||
references the tokens via `var(--token)`, swapping themes is instant.
|
`applyThemeToCss` helper in
|
||||||
|
`frontend/src/app/core/services/bootstrap.types.ts` writes these tokens to
|
||||||
|
`document.documentElement`:
|
||||||
|
|
||||||
|
| Theme token | CSS custom property |
|
||||||
|
|---|---|
|
||||||
|
| `primary`, `secondary`, `accent`, `surface`, `text` | `--color-*` |
|
||||||
|
| `success`, `warning`, `danger` | `--color-*` |
|
||||||
|
| `fontFamily` | `--font-family` |
|
||||||
|
| `radii.sm`, `radii.md`, `radii.lg` | `--radius-sm`, `--radius-md`, `--radius-lg` |
|
||||||
|
|
||||||
|
Reapplying a theme replaces the existing values. `clearThemeCss` removes every
|
||||||
|
property managed by the helper. Components consume the properties through
|
||||||
|
`var(--token)` in the global stylesheet and component styles.
|
||||||
|
|
||||||
|
# Verification
|
||||||
|
|
||||||
|
The frontend theme tests in `tests/frontend/bootstrap-theme.spec.ts` verify
|
||||||
|
that all tokens are written, missing tokens are a no-op, reapplication replaces
|
||||||
|
values, zero-valued radii are preserved, and cleanup removes every managed
|
||||||
|
property.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
- [System Endpoints](/api/system.md)
|
- [System Endpoints](/api/system.md)
|
||||||
|
- [Admin — General Settings](/guides/admin-general-settings.md)
|
||||||
|
- [Admin Endpoints](/api/admin.md)
|
||||||
- [Backend Module Map](/architecture/backend-modules.md)
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
|
|||||||
controls.
|
controls.
|
||||||
|
|
||||||
The docs below are organized by purpose so agents can pull just the slice
|
The docs below are organized by purpose so agents can pull just the slice
|
||||||
they need. Last regenerated 2026-07-22T12:44:45Z.
|
they need. Last regenerated 2026-07-22T13:40:00Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ they need. Last regenerated 2026-07-22T12:44:45Z.
|
|||||||
* [Landing Page](/guides/landing-page.md) - Public landing page
|
* [Landing Page](/guides/landing-page.md) - Public landing page
|
||||||
(logo, welcome Markdown, blog list) and the login + registration modal
|
(logo, welcome Markdown, blog list) and the login + registration modal
|
||||||
rendered at `/login` once the instance is initialized.
|
rendered at `/login` once the instance is initialized.
|
||||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
* [Theming](/guides/theming.md) - How canonical themes are listed for admins, persisted through general settings, and applied to CSS custom properties.
|
||||||
* [Event Window](/guides/event-window.md) - How the 4-state event
|
* [Event Window](/guides/event-window.md) - How the 4-state event
|
||||||
window and live countdown work, including the authenticated SSE stream.
|
window and live countdown work, including the authenticated SSE stream.
|
||||||
* [Event LED Color Mapping](/guides/event-led-colors.md) - Source of
|
* [Event LED Color Mapping](/guides/event-led-colors.md) - Source of
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import {
|
||||||
|
applyThemeToCss,
|
||||||
|
BootstrapPayload,
|
||||||
|
PasswordPolicyInfo,
|
||||||
|
Theme,
|
||||||
|
} from './bootstrap.types';
|
||||||
|
|
||||||
export interface PasswordPolicyInfo {
|
export {
|
||||||
minLength: number;
|
applyThemeToCss,
|
||||||
requireMixed: boolean;
|
clearThemeCss,
|
||||||
description: string;
|
THEME_CSS_PROPERTIES,
|
||||||
}
|
Theme,
|
||||||
|
ThemeTokens,
|
||||||
export interface BootstrapPayload {
|
ThemeCssTarget,
|
||||||
initialized: boolean;
|
BootstrapPayload,
|
||||||
pageTitle: string;
|
PasswordPolicyInfo,
|
||||||
logo: string;
|
} from './bootstrap.types';
|
||||||
welcomeMarkdown: string;
|
|
||||||
theme: any;
|
|
||||||
defaultChallengeIp: string;
|
|
||||||
registrationsEnabled: boolean;
|
|
||||||
passwordPolicy: PasswordPolicyInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_POLICY: PasswordPolicyInfo = {
|
const DEFAULT_POLICY: PasswordPolicyInfo = {
|
||||||
minLength: 12,
|
minLength: 12,
|
||||||
@@ -42,11 +42,7 @@ export class BootstrapService {
|
|||||||
|
|
||||||
private async _load(): Promise<BootstrapPayload | null> {
|
private async _load(): Promise<BootstrapPayload | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
|
const data = await this.fetchAndApply('/api/v1/bootstrap');
|
||||||
const data = (await res.json()) as BootstrapPayload;
|
|
||||||
this.payload.set(data);
|
|
||||||
this.initialized.set(data.initialized);
|
|
||||||
this.applyTheme(data.theme);
|
|
||||||
return data;
|
return data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Bootstrap load failed', e);
|
console.error('Bootstrap load failed', e);
|
||||||
@@ -61,25 +57,28 @@ export class BootstrapService {
|
|||||||
return this.loadPromise;
|
return this.loadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refresh(): Promise<BootstrapPayload> {
|
||||||
|
const data = await this.fetchAndApply('/api/v1/bootstrap');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
markInitialized(): void {
|
markInitialized(): void {
|
||||||
this.initialized.set(true);
|
this.initialized.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyTheme(theme: any): void {
|
applyTheme(theme: Theme | null | undefined): void {
|
||||||
if (!theme?.tokens) return;
|
applyThemeToCss(theme);
|
||||||
const root = document.documentElement.style;
|
}
|
||||||
const t = theme.tokens;
|
|
||||||
root.setProperty('--color-primary', t.primary);
|
private async fetchAndApply(url: string): Promise<BootstrapPayload> {
|
||||||
root.setProperty('--color-secondary', t.secondary);
|
const res = await fetch(url, { credentials: 'include' });
|
||||||
root.setProperty('--color-accent', t.accent);
|
if (!res.ok) {
|
||||||
root.setProperty('--color-surface', t.surface);
|
throw new Error(`Bootstrap request failed: ${res.status}`);
|
||||||
root.setProperty('--color-text', t.text);
|
}
|
||||||
root.setProperty('--color-success', t.success);
|
const data = (await res.json()) as BootstrapPayload;
|
||||||
root.setProperty('--color-warning', t.warning);
|
this.payload.set(data);
|
||||||
root.setProperty('--color-danger', t.danger);
|
this.initialized.set(data.initialized);
|
||||||
root.setProperty('--font-family', t.fontFamily);
|
this.applyTheme(data.theme);
|
||||||
root.setProperty('--radius-sm', t.radii.sm);
|
return data;
|
||||||
root.setProperty('--radius-md', t.radii.md);
|
|
||||||
root.setProperty('--radius-lg', t.radii.lg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
export interface PasswordPolicyInfo {
|
||||||
|
minLength: number;
|
||||||
|
requireMixed: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeTokens {
|
||||||
|
primary: string;
|
||||||
|
secondary: string;
|
||||||
|
accent: string;
|
||||||
|
surface: string;
|
||||||
|
text: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
danger: string;
|
||||||
|
fontFamily: string;
|
||||||
|
radii: { sm: string; md: string; lg: string };
|
||||||
|
spacingScale: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Theme {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tokens: ThemeTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapPayload {
|
||||||
|
initialized: boolean;
|
||||||
|
pageTitle: string;
|
||||||
|
logo: string;
|
||||||
|
welcomeMarkdown: string;
|
||||||
|
theme: Theme;
|
||||||
|
defaultChallengeIp: string;
|
||||||
|
registrationsEnabled: boolean;
|
||||||
|
passwordPolicy: PasswordPolicyInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThemeCssTarget = Pick<CSSStyleDeclaration, 'setProperty' | 'removeProperty'>;
|
||||||
|
|
||||||
|
export const THEME_CSS_PROPERTIES = [
|
||||||
|
'--color-primary',
|
||||||
|
'--color-secondary',
|
||||||
|
'--color-accent',
|
||||||
|
'--color-surface',
|
||||||
|
'--color-text',
|
||||||
|
'--color-success',
|
||||||
|
'--color-warning',
|
||||||
|
'--color-danger',
|
||||||
|
'--font-family',
|
||||||
|
'--radius-sm',
|
||||||
|
'--radius-md',
|
||||||
|
'--radius-lg',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function applyThemeToCss(theme: Theme | null | undefined, root: ThemeCssTarget = document.documentElement.style): boolean {
|
||||||
|
if (!theme?.tokens) return false;
|
||||||
|
const t = theme.tokens;
|
||||||
|
root.setProperty('--color-primary', t.primary);
|
||||||
|
root.setProperty('--color-secondary', t.secondary);
|
||||||
|
root.setProperty('--color-accent', t.accent);
|
||||||
|
root.setProperty('--color-surface', t.surface);
|
||||||
|
root.setProperty('--color-text', t.text);
|
||||||
|
root.setProperty('--color-success', t.success);
|
||||||
|
root.setProperty('--color-warning', t.warning);
|
||||||
|
root.setProperty('--color-danger', t.danger);
|
||||||
|
root.setProperty('--font-family', t.fontFamily);
|
||||||
|
root.setProperty('--radius-sm', t.radii.sm);
|
||||||
|
root.setProperty('--radius-md', t.radii.md);
|
||||||
|
root.setProperty('--radius-lg', t.radii.lg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearThemeCss(root: ThemeCssTarget = document.documentElement.style): void {
|
||||||
|
for (const prop of THEME_CSS_PROPERTIES) {
|
||||||
|
root.removeProperty(prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { AdminService, GeneralSettings, ThemeView } from '../../core/services/admin.service';
|
import { AdminService, GeneralSettings, ThemeView } from '../../core/services/admin.service';
|
||||||
|
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||||
import { MarkdownService } from '../../core/services/markdown.service';
|
import { MarkdownService } from '../../core/services/markdown.service';
|
||||||
import { AdminCategoriesComponent } from './categories/categories.component';
|
import { AdminCategoriesComponent } from './categories/categories.component';
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +162,7 @@ import {
|
|||||||
export class AdminGeneralComponent implements OnInit {
|
export class AdminGeneralComponent implements OnInit {
|
||||||
private readonly fb = inject(FormBuilder);
|
private readonly fb = inject(FormBuilder);
|
||||||
private readonly admin = inject(AdminService);
|
private readonly admin = inject(AdminService);
|
||||||
|
private readonly bootstrap = inject(BootstrapService);
|
||||||
private readonly markdown = inject(MarkdownService);
|
private readonly markdown = inject(MarkdownService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@ export class AdminGeneralComponent implements OnInit {
|
|||||||
registrationsEnabled: v.registrationsEnabled,
|
registrationsEnabled: v.registrationsEnabled,
|
||||||
});
|
});
|
||||||
this.applySettings(updated);
|
this.applySettings(updated);
|
||||||
|
await this.bootstrap.refresh();
|
||||||
this.saveOk.set(true);
|
this.saveOk.set(true);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.saveError.set(e?.error?.message ?? e?.message ?? 'Failed to save');
|
this.saveError.set(e?.error?.message ?? e?.message ?? 'Failed to save');
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
applyThemeToCss,
|
||||||
|
clearThemeCss,
|
||||||
|
Theme,
|
||||||
|
} from '../../frontend/src/app/core/services/bootstrap.types';
|
||||||
|
|
||||||
|
function makeClassicTheme(): Theme {
|
||||||
|
return {
|
||||||
|
id: 'classic',
|
||||||
|
name: 'Classic',
|
||||||
|
tokens: {
|
||||||
|
primary: '#3b82f6',
|
||||||
|
secondary: '#64748b',
|
||||||
|
accent: '#f59e0b',
|
||||||
|
surface: '#ffffff',
|
||||||
|
text: '#0f172a',
|
||||||
|
success: '#10b981',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
danger: '#ef4444',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
radii: { sm: '4px', md: '8px', lg: '16px' },
|
||||||
|
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMonochromeTheme(): Theme {
|
||||||
|
return {
|
||||||
|
id: 'monochrome',
|
||||||
|
name: 'Monochrome',
|
||||||
|
tokens: {
|
||||||
|
primary: '#000000',
|
||||||
|
secondary: '#525252',
|
||||||
|
accent: '#a3a3a3',
|
||||||
|
surface: '#ffffff',
|
||||||
|
text: '#0a0a0a',
|
||||||
|
success: '#404040',
|
||||||
|
warning: '#737373',
|
||||||
|
danger: '#171717',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
radii: { sm: '0', md: '0', lg: '0' },
|
||||||
|
spacingScale: [4, 8, 12, 16, 24, 32, 48],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyThemeToCss', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearThemeCss();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes all CSS custom properties from the Classic theme tokens', () => {
|
||||||
|
applyThemeToCss(makeClassicTheme());
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--color-primary')).toBe('#3b82f6');
|
||||||
|
expect(root.getPropertyValue('--color-secondary')).toBe('#64748b');
|
||||||
|
expect(root.getPropertyValue('--color-accent')).toBe('#f59e0b');
|
||||||
|
expect(root.getPropertyValue('--color-surface')).toBe('#ffffff');
|
||||||
|
expect(root.getPropertyValue('--color-text')).toBe('#0f172a');
|
||||||
|
expect(root.getPropertyValue('--color-success')).toBe('#10b981');
|
||||||
|
expect(root.getPropertyValue('--color-warning')).toBe('#f59e0b');
|
||||||
|
expect(root.getPropertyValue('--color-danger')).toBe('#ef4444');
|
||||||
|
expect(root.getPropertyValue('--font-family')).toBe('system-ui, sans-serif');
|
||||||
|
expect(root.getPropertyValue('--radius-sm')).toBe('4px');
|
||||||
|
expect(root.getPropertyValue('--radius-md')).toBe('8px');
|
||||||
|
expect(root.getPropertyValue('--radius-lg')).toBe('16px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and is a no-op when the theme is missing tokens', () => {
|
||||||
|
expect(applyThemeToCss(null)).toBe(false);
|
||||||
|
expect(applyThemeToCss(undefined)).toBe(false);
|
||||||
|
expect(applyThemeToCss({ id: 'x', name: 'X' } as unknown as Theme)).toBe(false);
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--color-primary')).toBe('');
|
||||||
|
expect(root.getPropertyValue('--radius-md')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces Classic tokens with Monochrome tokens when re-applied', () => {
|
||||||
|
applyThemeToCss(makeClassicTheme());
|
||||||
|
applyThemeToCss(makeMonochromeTheme());
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--color-primary')).toBe('#000000');
|
||||||
|
expect(root.getPropertyValue('--color-text')).toBe('#0a0a0a');
|
||||||
|
expect(root.getPropertyValue('--radius-md')).toBe('0');
|
||||||
|
expect(root.getPropertyValue('--radius-sm')).toBe('0');
|
||||||
|
expect(root.getPropertyValue('--radius-lg')).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the zero-valued Monochrome radius as a valid applied value', () => {
|
||||||
|
applyThemeToCss(makeMonochromeTheme());
|
||||||
|
expect(document.documentElement.style.getPropertyValue('--radius-md')).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearThemeCss', () => {
|
||||||
|
it('removes every theme CSS custom property set by applyThemeToCss', () => {
|
||||||
|
applyThemeToCss(makeClassicTheme());
|
||||||
|
clearThemeCss();
|
||||||
|
const root = document.documentElement.style;
|
||||||
|
expect(root.getPropertyValue('--color-primary')).toBe('');
|
||||||
|
expect(root.getPropertyValue('--color-text')).toBe('');
|
||||||
|
expect(root.getPropertyValue('--radius-md')).toBe('');
|
||||||
|
expect(root.getPropertyValue('--font-family')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user