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

This commit was merged in pull request #26.
This commit is contained in:
2026-07-22 13:41:09 +00:00
parent b2f0a4736d
commit 98fee8f7ee
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 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)
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+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('');
});
});