fix(login): respect branding themeMode for theme toggle, fix CSP (#11903)

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
This commit is contained in:
Max Peintner
2026-03-31 12:25:09 +00:00
committed by GitHub
parent 604d5cb0a9
commit ec0bd5790f
5 changed files with 141 additions and 51 deletions
-5
View File
@@ -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" },
];
@@ -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);
}
+17 -2
View File
@@ -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 (
<div className={`flex space-x-1 p-1 ${toggleRoundness} ${cardAppearance}`}>
<button
@@ -57,6 +65,13 @@ export default function ThemeSwitch() {
>
<SunIcon className="h-5 w-5" />
</button>
<button
className={`flex h-8 w-8 flex-row items-center justify-center ${toggleRoundness} transition-colors ${getSelectedButtonStyle(theme === "system")}`}
onClick={() => setTheme("system")}
aria-label="Switch to system mode"
>
<ComputerDesktopIcon className="h-4 w-4" />
</button>
<button
className={`flex h-8 w-8 flex-row items-center justify-center ${toggleRoundness} transition-colors ${getSelectedButtonStyle(theme === "dark")}`}
onClick={() => setTheme("dark")}
+27 -12
View File
@@ -1,9 +1,10 @@
"use client";
import { setTheme } from "@/helpers/colors";
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
import { BrandingSettings, ThemeMode } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
import { useTheme } from "next-themes";
import { ReactNode, useEffect } from "react";
import { ReactNode, useEffect, useLayoutEffect } from "react";
import { setThemeMode } from "./branding-context";
type Props = {
branding: BrandingSettings | undefined;
@@ -17,23 +18,37 @@ export const ThemeWrapper = ({ children, branding }: Props) => {
setTheme(document, branding);
}, [branding]);
// Handle branding themeMode to force specific theme
// Publish themeMode to the module-level store so ThemeSwitch can read it
useEffect(() => {
setThemeMode(branding?.themeMode ?? ThemeMode.UNSPECIFIED);
}, [branding?.themeMode]);
// Handle branding themeMode to force specific theme.
// Uses useLayoutEffect to apply before paint and writes the forced value
// to localStorage so next-themes doesn't fall back to system default.
useLayoutEffect(() => {
if (branding?.themeMode !== undefined) {
// Based on the proto definition:
// THEME_MODE_UNSPECIFIED = 0
// THEME_MODE_AUTO = 1
// THEME_MODE_LIGHT = 2
// THEME_MODE_DARK = 3
switch (branding.themeMode) {
case 2: // THEME_MODE_LIGHT
case ThemeMode.LIGHT:
document.documentElement.classList.remove("dark");
try {
localStorage.setItem("cp-theme", "light");
} catch {
/* localStorage unavailable (e.g. private mode) */
}
setNextTheme("light");
break;
case 3: // THEME_MODE_DARK
case ThemeMode.DARK:
document.documentElement.classList.add("dark");
try {
localStorage.setItem("cp-theme", "dark");
} catch {
/* localStorage unavailable (e.g. private mode) */
}
setNextTheme("dark");
break;
case 1: // THEME_MODE_AUTO
case 0: // THEME_MODE_UNSPECIFIED
case ThemeMode.AUTO:
case ThemeMode.UNSPECIFIED:
default:
setNextTheme("system");
break;
+56 -32
View File
@@ -39,48 +39,72 @@ export async function proxy(request: NextRequest) {
requestHeaders.set("x-zitadel-i18n-organization", organization);
}
// Only run the rest of the logic for the original matcher paths
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"];
const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix));
// Only proxy in self-hosted mode
if (!isMatched) {
// For multi-tenant or non-proxied routes, just add the header and continue
return NextResponse.next({
request: { headers: requestHeaders },
});
}
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
// add additional headers if available
if (serviceConfig.publicHost) {
requestHeaders.set("x-zitadel-public-host", serviceConfig.publicHost);
}
if (serviceConfig.instanceHost) {
requestHeaders.set("x-zitadel-instance-host", serviceConfig.instanceHost);
// Internal infrastructure routes — skip middleware entirely.
// /security is the internal API this middleware fetches (loop prevention).
// /healthy and /ready are Kubernetes/Docker health probes that must respond
// without depending on a ZITADEL backend.
const skipPaths = ["/security", "/healthy", "/ready"];
if (skipPaths.includes(request.nextUrl.pathname)) {
return NextResponse.next({ request: { headers: requestHeaders } });
}
// Build security response headers (shared by all routes).
// Wrapped in try/catch so the middleware gracefully degrades with a default
// CSP when the ZITADEL backend is unavailable (e.g. during container startup).
const responseHeaders = new Headers();
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Access-Control-Allow-Headers", "*");
let publicHost: string | undefined;
let instanceHost: string | undefined;
let baseUrl: string | undefined;
const securitySettings = await loadSecuritySettings(request);
try {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
publicHost = serviceConfig.publicHost;
instanceHost = serviceConfig.instanceHost;
baseUrl = serviceConfig.baseUrl;
const iframeOrigins =
securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0
? securitySettings.embeddedIframe.allowedOrigins
: undefined;
const securitySettings = await loadSecuritySettings(request);
const iframeOrigins =
securitySettings?.embeddedIframe?.enabled && securitySettings.embeddedIframe.allowedOrigins.length > 0
? securitySettings.embeddedIframe.allowedOrigins
: undefined;
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: serviceConfig.baseUrl, iframeOrigins }));
responseHeaders.set("Content-Security-Policy", buildCSP({ serviceUrl: baseUrl, iframeOrigins }));
if (!iframeOrigins) {
if (!iframeOrigins) {
responseHeaders.set("X-Frame-Options", "deny");
}
} catch (err) {
logger.error("Failed to load security settings for CSP, using default CSP", {
error: err instanceof Error ? err.message : String(err),
});
responseHeaders.set("Content-Security-Policy", buildCSP());
responseHeaders.set("X-Frame-Options", "deny");
}
request.nextUrl.href = `${serviceConfig.baseUrl}${request.nextUrl.pathname}${request.nextUrl.search}`;
// Only run the rest of the logic for the original matcher paths
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"];
const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix));
if (!isMatched) {
return NextResponse.next({
request: { headers: requestHeaders },
headers: responseHeaders,
});
}
// Proxy-specific headers
if (publicHost) {
requestHeaders.set("x-zitadel-public-host", publicHost);
}
if (instanceHost) {
requestHeaders.set("x-zitadel-instance-host", instanceHost);
}
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Access-Control-Allow-Headers", "*");
request.nextUrl.href = `${baseUrl}${request.nextUrl.pathname}${request.nextUrl.search}`;
return NextResponse.rewrite(request.nextUrl, {
request: {