diff --git a/server/openapi.json b/server/openapi.json index 717893a68..cfb1c5aa4 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -3425,11 +3425,13 @@ "StatusPageTheme": { "type": "string", "enum": ["refined", "modern", "bold", "editorial"], + "default": "refined", "description": "Visual theme for the public status page. Defaults to 'refined'." }, "StatusPageThemeMode": { "type": "string", "enum": ["auto", "light", "dark"], + "default": "auto", "description": "Forces a light/dark mode on the public page. 'auto' follows the visitor's OS preference. Defaults to 'auto'." }, "SuccessResponse": { diff --git a/server/src/controllers/statusPageThemeFlag.ts b/server/src/controllers/statusPageThemeFlag.ts new file mode 100644 index 000000000..bd65d5cb2 --- /dev/null +++ b/server/src/controllers/statusPageThemeFlag.ts @@ -0,0 +1,33 @@ +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, type StatusPage } from "@/types/statusPage.js"; + +/** + * When STATUS_PAGE_THEMES_ENABLED is false, the controller drops incoming + * theme fields before persisting so an admin client can't accidentally store + * a value the public page won't honour. Whatever a caller sends here for + * `theme` / `themeMode` is silently ignored — the rest of the body is + * preserved verbatim. + * + * The strip is silent (not a 400) because the kill switch is an ops-side + * concern: API clients should not have to know whether the operator has + * disabled themes today, only that their saved value won't take effect. + * If we later decide we'd rather fail loudly, change this to throw a + * 400 AppError; downstream consumers already handle it. + */ +export const withoutThemeFields = (body: Record): Record => { + const rest = { ...body }; + delete rest.theme; + delete rest.themeMode; + return rest; +}; + +/** + * When STATUS_PAGE_THEMES_ENABLED is false, callers reading a status page + * should see the canonical default theme / mode regardless of what's stored + * in Mongo. Whatever the user previously chose stays in the DB so flipping + * the flag back on resurfaces their selection. + */ +export const applyDefaultTheme = (statusPage: StatusPage): StatusPage => ({ + ...statusPage, + theme: DEFAULT_STATUS_PAGE_THEME, + themeMode: DEFAULT_STATUS_PAGE_THEME_MODE, +}); diff --git a/server/src/db/models/StatusPage.ts b/server/src/db/models/StatusPage.ts index 0a1b5830c..0b6451492 100644 --- a/server/src/db/models/StatusPage.ts +++ b/server/src/db/models/StatusPage.ts @@ -1,6 +1,12 @@ import { Schema, model, type Types } from "mongoose"; import type { StatusPage, StatusPageLogoDocument } from "@/types/statusPage.js"; -import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.js"; +import { + DEFAULT_STATUS_PAGE_THEME, + DEFAULT_STATUS_PAGE_THEME_MODE, + StatusPageThemeModes, + StatusPageThemes, + StatusPageTypes, +} from "@/types/statusPage.js"; type StatusPageDocumentBase = Omit< StatusPage, @@ -111,12 +117,12 @@ const StatusPageSchema = new Schema( theme: { type: String, enum: StatusPageThemes, - default: "refined", + default: DEFAULT_STATUS_PAGE_THEME, }, themeMode: { type: String, enum: StatusPageThemeModes, - default: "auto", + default: DEFAULT_STATUS_PAGE_THEME_MODE, }, }, { timestamps: true } diff --git a/server/test/helpers/makeStatusPage.ts b/server/test/helpers/makeStatusPage.ts new file mode 100644 index 000000000..23c13bca1 --- /dev/null +++ b/server/test/helpers/makeStatusPage.ts @@ -0,0 +1,14 @@ +import type { StatusPage } from "../../src/types/index.ts"; + +export const makeStatusPage = (overrides?: Partial): StatusPage => + ({ + id: "sp-1", + teamId: "team-1", + userId: "user-1", + url: "my-status-page", + companyName: "Test Co", + monitors: ["mon-1"], + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }) as StatusPage; diff --git a/server/test/unit/controllers/statusPageThemeFlag.test.ts b/server/test/unit/controllers/statusPageThemeFlag.test.ts new file mode 100644 index 000000000..b4f9435a9 --- /dev/null +++ b/server/test/unit/controllers/statusPageThemeFlag.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "@jest/globals"; +import { applyDefaultTheme, withoutThemeFields } from "../../../src/controllers/statusPageThemeFlag.ts"; +import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE } from "../../../src/types/statusPage.ts"; +import { makeStatusPage } from "../../helpers/makeStatusPage.ts"; + +describe("statusPageThemeFlag", () => { + describe("withoutThemeFields", () => { + it("removes theme and themeMode from a body that has them", () => { + const body = { companyName: "Co", theme: "modern", themeMode: "dark" }; + const result = withoutThemeFields(body); + expect(result).toEqual({ companyName: "Co" }); + }); + + it("is a no-op when neither field is present", () => { + const body = { companyName: "Co", url: "page" }; + const result = withoutThemeFields(body); + expect(result).toEqual({ companyName: "Co", url: "page" }); + }); + + it("removes only the theme fields when one is present", () => { + const body = { companyName: "Co", theme: "bold" }; + const result = withoutThemeFields(body); + expect(result).toEqual({ companyName: "Co" }); + }); + + it("does not mutate the input body", () => { + const body = { companyName: "Co", theme: "modern", themeMode: "dark" }; + withoutThemeFields(body); + expect(body).toEqual({ companyName: "Co", theme: "modern", themeMode: "dark" }); + }); + + it("preserves all other fields verbatim", () => { + const body = { + companyName: "Co", + monitors: ["m-1", "m-2"], + isPublished: true, + showCharts: false, + color: "#13715B", + theme: "editorial", + themeMode: "light", + }; + const result = withoutThemeFields(body); + expect(result).toEqual({ + companyName: "Co", + monitors: ["m-1", "m-2"], + isPublished: true, + showCharts: false, + color: "#13715B", + }); + }); + }); + + describe("applyDefaultTheme", () => { + it("overwrites theme and themeMode with the canonical defaults", () => { + const sp = makeStatusPage({ theme: "modern", themeMode: "dark" }); + const result = applyDefaultTheme(sp); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + + it("leaves all other fields untouched", () => { + const sp = makeStatusPage({ + companyName: "Test Co", + url: "my-page", + monitors: ["mon-1", "mon-2"], + isPublished: true, + color: "#13715B", + }); + const result = applyDefaultTheme(sp); + expect(result.companyName).toBe("Test Co"); + expect(result.url).toBe("my-page"); + expect(result.monitors).toEqual(["mon-1", "mon-2"]); + expect(result.isPublished).toBe(true); + expect(result.color).toBe("#13715B"); + }); + + it("does not mutate the input status page", () => { + const sp = makeStatusPage({ theme: "modern", themeMode: "dark" }); + applyDefaultTheme(sp); + expect(sp.theme).toBe("modern"); + expect(sp.themeMode).toBe("dark"); + }); + + it("works on a status page that has no theme set yet", () => { + const sp = makeStatusPage({ theme: undefined, themeMode: undefined }); + const result = applyDefaultTheme(sp); + expect(result.theme).toBe(DEFAULT_STATUS_PAGE_THEME); + expect(result.themeMode).toBe(DEFAULT_STATUS_PAGE_THEME_MODE); + }); + }); +}); diff --git a/server/test/unit/services/settingsService.test.ts b/server/test/unit/services/settingsService.test.ts index 5c4935deb..da75d3ee5 100644 --- a/server/test/unit/services/settingsService.test.ts +++ b/server/test/unit/services/settingsService.test.ts @@ -15,6 +15,7 @@ const makeEnv = (overrides?: Partial): ValidatedEnv => CLIENT_HOST: "http://localhost:5173", DB_CONNECTION_STRING: "mongodb://localhost:27017/test_db", DB_TYPE: "mongodb", + STATUS_PAGE_THEMES_ENABLED: false, ...overrides, }) as ValidatedEnv; @@ -81,6 +82,7 @@ describe("SettingsService", () => { clientHost: "http://localhost:5173", dbConnectionString: "mongodb://localhost:27017/test_db", dbType: "mongodb", + statusPageThemesEnabled: false, }); }); @@ -103,6 +105,22 @@ describe("SettingsService", () => { }); }); + // ── areStatusPageThemesEnabled ────────────────────────────────────────── + + describe("areStatusPageThemesEnabled", () => { + it("returns false when STATUS_PAGE_THEMES_ENABLED env is false", () => { + const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: false }); + + expect(service.areStatusPageThemesEnabled()).toBe(false); + }); + + it("returns true when STATUS_PAGE_THEMES_ENABLED env is true", () => { + const { service } = createService({ STATUS_PAGE_THEMES_ENABLED: true }); + + expect(service.areStatusPageThemesEnabled()).toBe(true); + }); + }); + // ── setRepository ─────────────────────────────────────────────────────── describe("setRepository", () => {