diff --git a/.kilo/plans/885.md b/.kilo/plans/885.md deleted file mode 100644 index 060a212..0000000 --- a/.kilo/plans/885.md +++ /dev/null @@ -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. diff --git a/.kilo/plans/886.md b/.kilo/plans/886.md new file mode 100644 index 0000000..4b970a4 --- /dev/null +++ b/.kilo/plans/886.md @@ -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. diff --git a/docs/api/system.md b/docs/api/system.md index b175a49..101db53 100644 --- a/docs/api/system.md +++ b/docs/api/system.md @@ -3,7 +3,7 @@ type: api title: System Endpoints description: Bootstrap payload, public event status, public/authenticated SSE streams, and public event-window timestamps. tags: [api, system, bootstrap, event, sse, settings] -timestamp: 2026-07-21T22:19:08Z +timestamp: 2026-07-22T13:40:00Z --- # Endpoints @@ -52,6 +52,10 @@ SPA's `BootstrapService`: * `passwordPolicy` is derived from `PASSWORD_MIN_LENGTH` / `PASSWORD_REQUIRE_MIXED` env vars (see [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) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 5f8cf24..283b5da 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -1,9 +1,9 @@ --- type: architecture 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] -timestamp: 2026-07-22T10:32:00Z +timestamp: 2026-07-22T13:40:00Z --- # 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/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.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.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. | @@ -30,7 +32,9 @@ timestamp: 2026-07-22T10:32:00Z |---|---| | `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.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/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. | diff --git a/docs/guides/theming.md b/docs/guides/theming.md index 06a3095..ef07049 100644 --- a/docs/guides/theming.md +++ b/docs/guides/theming.md @@ -1,50 +1,84 @@ --- type: guide title: Theming -description: How the 10 canonical themes are loaded, validated, and applied to the SPA via CSS custom properties. -tags: [guide, theming, design] -timestamp: 2026-07-21T18:28:00Z +description: How the canonical themes are loaded, selected by administrators, and applied to the SPA via CSS custom properties. +tags: [guide, theming, design, admin] +timestamp: 2026-07-22T13:40:00Z --- # Theme catalog -HIPCTF ships 10 canonical themes defined as JSON files in -`backend/themes/` (`01-classic.json` … `10-monochrome.json`). The full -list lives in `backend/src/common/types/theme-ids.ts`: +HIPCTF ships canonical themes defined as JSON files in `backend/themes/`. The +catalog is identified by `THEME_IDS` in +`backend/src/common/types/theme-ids.ts`; the default is `classic`. -`classic`, `midnight`, `sunset`, `forest`, `cyber`, `paper`, `crimson`, -`ocean`, `neon`, `monochrome`. +Each theme contains an `id`, display `name`, and token set containing colors, +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 -family) consumed by `frontend/src/styles.css`. +# Administrator workflow + +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 `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/`. -2. Validates each against the `Theme` interface - (`backend/src/common/types/theme.ts`) — unknown theme ids, malformed - JSON, or missing tokens throw `ApiError(THEME_INVALID)`. -3. Backfills any of the 10 canonical themes that are missing from disk - (safety net so a partial checkout still boots). -4. Reads `SETTINGS_KEYS.THEME_KEY` (default `'classic'`). If the value is - 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. +1. Reads theme JSON files from `THEMES_DIR` (default `./themes`). +2. Validates IDs and tokens; malformed files are skipped or reported through +the loader's validation path. +3. Backfills missing canonical themes so the application always has a valid +catalog. +4. Validates the configured `SETTINGS_KEYS.THEME_KEY`; an unknown value falls +back to `classic` and is persisted as the repaired setting. -The resolved theme object is sent to the SPA in the -`GET /api/v1/bootstrap` payload (see [System Endpoints](/api/system.md)). +The selected theme is sent to the SPA in `GET /api/v1/bootstrap` (see +[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 `BootstrapService` (`frontend/src/app/core/services/bootstrap.service.ts`) -writes each theme token onto `document.documentElement` (e.g. -`--color-primary`, `--radius-md`, `--font-family`). Because `styles.css` -references the tokens via `var(--token)`, swapping themes is instant. +fetches `/api/v1/bootstrap` once per application lifecycle, stores the payload +in signals, and applies the returned theme before the UI is rendered. The +`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 - [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) diff --git a/docs/index.md b/docs/index.md index b7da900..03a78f2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. 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 @@ -84,7 +84,7 @@ they need. Last regenerated 2026-07-22T12:44:45Z. * [Landing Page](/guides/landing-page.md) - Public landing page (logo, welcome Markdown, blog list) and the login + registration modal 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 window and live countdown work, including the authenticated SSE stream. * [Event LED Color Mapping](/guides/event-led-colors.md) - Source of diff --git a/frontend/src/app/core/services/bootstrap.service.ts b/frontend/src/app/core/services/bootstrap.service.ts index b5817ab..eed9af9 100644 --- a/frontend/src/app/core/services/bootstrap.service.ts +++ b/frontend/src/app/core/services/bootstrap.service.ts @@ -1,21 +1,21 @@ import { Injectable, signal, computed } from '@angular/core'; +import { + applyThemeToCss, + BootstrapPayload, + PasswordPolicyInfo, + Theme, +} from './bootstrap.types'; -export interface PasswordPolicyInfo { - minLength: number; - requireMixed: boolean; - description: string; -} - -export interface BootstrapPayload { - initialized: boolean; - pageTitle: string; - logo: string; - welcomeMarkdown: string; - theme: any; - defaultChallengeIp: string; - registrationsEnabled: boolean; - passwordPolicy: PasswordPolicyInfo; -} +export { + applyThemeToCss, + clearThemeCss, + THEME_CSS_PROPERTIES, + Theme, + ThemeTokens, + ThemeCssTarget, + BootstrapPayload, + PasswordPolicyInfo, +} from './bootstrap.types'; const DEFAULT_POLICY: PasswordPolicyInfo = { minLength: 12, @@ -42,11 +42,7 @@ export class BootstrapService { private async _load(): Promise { try { - const res = await fetch('/api/v1/bootstrap', { credentials: 'include' }); - const data = (await res.json()) as BootstrapPayload; - this.payload.set(data); - this.initialized.set(data.initialized); - this.applyTheme(data.theme); + const data = await this.fetchAndApply('/api/v1/bootstrap'); return data; } catch (e) { console.error('Bootstrap load failed', e); @@ -61,25 +57,28 @@ export class BootstrapService { return this.loadPromise; } + async refresh(): Promise { + const data = await this.fetchAndApply('/api/v1/bootstrap'); + return data; + } + markInitialized(): void { this.initialized.set(true); } - private applyTheme(theme: any): void { - if (!theme?.tokens) return; - const root = document.documentElement.style; - 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); + applyTheme(theme: Theme | null | undefined): void { + applyThemeToCss(theme); + } + + private async fetchAndApply(url: string): Promise { + const res = await fetch(url, { credentials: 'include' }); + if (!res.ok) { + throw new Error(`Bootstrap request failed: ${res.status}`); + } + const data = (await res.json()) as BootstrapPayload; + this.payload.set(data); + this.initialized.set(data.initialized); + this.applyTheme(data.theme); + return data; } } \ No newline at end of file diff --git a/frontend/src/app/core/services/bootstrap.types.ts b/frontend/src/app/core/services/bootstrap.types.ts new file mode 100644 index 0000000..5e8daf3 --- /dev/null +++ b/frontend/src/app/core/services/bootstrap.types.ts @@ -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; + +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); + } +} \ No newline at end of file diff --git a/frontend/src/app/features/admin/general.component.ts b/frontend/src/app/features/admin/general.component.ts index 643545b..76bfce0 100644 --- a/frontend/src/app/features/admin/general.component.ts +++ b/frontend/src/app/features/admin/general.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AdminService, GeneralSettings, ThemeView } from '../../core/services/admin.service'; +import { BootstrapService } from '../../core/services/bootstrap.service'; import { MarkdownService } from '../../core/services/markdown.service'; import { AdminCategoriesComponent } from './categories/categories.component'; import { @@ -161,6 +162,7 @@ import { export class AdminGeneralComponent implements OnInit { private readonly fb = inject(FormBuilder); private readonly admin = inject(AdminService); + private readonly bootstrap = inject(BootstrapService); private readonly markdown = inject(MarkdownService); private readonly destroyRef = inject(DestroyRef); @@ -293,6 +295,7 @@ export class AdminGeneralComponent implements OnInit { registrationsEnabled: v.registrationsEnabled, }); this.applySettings(updated); + await this.bootstrap.refresh(); this.saveOk.set(true); } catch (e: any) { this.saveError.set(e?.error?.message ?? e?.message ?? 'Failed to save'); diff --git a/tests/frontend/bootstrap-theme.spec.ts b/tests/frontend/bootstrap-theme.spec.ts new file mode 100644 index 0000000..1e36d2e --- /dev/null +++ b/tests/frontend/bootstrap-theme.spec.ts @@ -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(''); + }); +}); \ No newline at end of file