mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user