AI Implementation feature(886): Admin Area General Settings and Categories 1.04 #26

Merged
m0rph3us1987 merged 2 commits from feature-886-1784726844881 into dev 2026-07-22 13:41:09 +00:00
10 changed files with 332 additions and 96 deletions
-26
View File
@@ -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.
+36
View File
@@ -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
View File
@@ -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)
+8 -4
View File
@@ -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. |
+60 -26
View File
@@ -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)
+2 -2
View File
@@ -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
@@ -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<BootstrapPayload | null> {
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<BootstrapPayload> {
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<BootstrapPayload> {
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;
}
}
@@ -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 { 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');
+105
View File
@@ -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('');
});
});