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