mirror of
https://github.com/andwati/zitadel.git
synced 2026-11-01 20:02:15 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user