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 (
+