From ec0bd5790f21ca367e7d6186098308ce1dec230c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 31 Mar 2026 14:25:09 +0200 Subject: [PATCH] fix(login): respect branding themeMode for theme toggle, fix CSP (#11903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11721 # Which Problems Are Solved The login app now correctly handles the themeMode from branding settings: - Hide toggle when themeMode is LIGHT or DARK — the theme is forced, users cannot switch - Show 3-option toggle (light / system / dark) when themeMode is AUTO or UNSPECIFIED # How the Problems Are Solved - Introduced a `BrandingContext` to pass `themeMode` from `ThemeWrapper` down to `ThemeSwitch` # Additional Changes - removed the CSP from next.config.ts to prevent a precedency issue where the CSP was actually not applied --- apps/login/next.config.mjs | 5 -- .../login/src/components/branding-context.tsx | 41 +++++++++ apps/login/src/components/theme-switch.tsx | 19 +++- apps/login/src/components/theme-wrapper.tsx | 39 +++++--- apps/login/src/proxy.ts | 88 ++++++++++++------- 5 files changed, 141 insertions(+), 51 deletions(-) create mode 100644 apps/login/src/components/branding-context.tsx diff --git a/apps/login/next.config.mjs b/apps/login/next.config.mjs index a1533d03b..5bdeea755 100755 --- a/apps/login/next.config.mjs +++ b/apps/login/next.config.mjs @@ -1,5 +1,4 @@ import createNextIntlPlugin from "next-intl/plugin"; -import { DEFAULT_CSP } from "./constants/csp.js"; const withNextIntl = createNextIntlPlugin(); @@ -20,10 +19,6 @@ const secureHeaders = [ key: "X-XSS-Protection", value: "1; mode=block", }, - { - key: "Content-Security-Policy", - value: DEFAULT_CSP, - }, { key: "X-Frame-Options", value: "deny" }, ]; diff --git a/apps/login/src/components/branding-context.tsx b/apps/login/src/components/branding-context.tsx new file mode 100644 index 000000000..898a9aa30 --- /dev/null +++ b/apps/login/src/components/branding-context.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +/** + * Module-level store for themeMode from branding settings. + * This is used instead of React Context because ThemeSwitch in the layout + * is a sibling of (not a descendant of) the ThemeWrapper/DynamicTheme tree. + */ +let currentThemeMode = 0; // Default to UNSPECIFIED +const listeners = new Set<() => void>(); + +function notifyListeners() { + listeners.forEach((listener) => listener()); +} + +export function setThemeMode(mode: number) { + if (currentThemeMode !== mode) { + currentThemeMode = mode; + notifyListeners(); + } +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot() { + return currentThemeMode; +} + +/** + * Hook to subscribe to themeMode changes using React 18+ useSyncExternalStore. + * Ensures ThemeSwitch always sees a consistent snapshot during concurrent renders. + */ +export function useThemeMode(): number { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/apps/login/src/components/theme-switch.tsx b/apps/login/src/components/theme-switch.tsx index c75e964ae..dd9e9e110 100644 --- a/apps/login/src/components/theme-switch.tsx +++ b/apps/login/src/components/theme-switch.tsx @@ -1,10 +1,11 @@ "use client"; import { APPEARANCE_STYLES, getComponentRoundness, getThemeConfig } from "@/lib/theme"; -import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; +import { ComputerDesktopIcon, MoonIcon, SunIcon } from "@heroicons/react/24/outline"; +import { ThemeMode } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; - +import { useThemeMode } from "./branding-context"; function getThemeToggleRoundness() { return getComponentRoundness("themeSwitch"); } @@ -39,6 +40,7 @@ function getSelectedButtonStyle(isSelected: boolean): string { export default function ThemeSwitch() { const [mounted, setMounted] = useState(false); const { theme, setTheme } = useTheme(); + const themeMode = useThemeMode(); const toggleRoundness = getThemeToggleRoundness(); const cardAppearance = getThemeSwitchCardAppearance(); @@ -48,6 +50,12 @@ export default function ThemeSwitch() { if (!mounted) return null; + // Hide toggle when theme is forced to light or dark only + if (themeMode === ThemeMode.LIGHT || themeMode === ThemeMode.DARK) { + return null; + } + + // themeMode is AUTO (1) or UNSPECIFIED (0): show light, system, dark options return (
+