fix(login): load custom font from branding settings and allow in CSP (#12279)

Closes #11200

# Which Problems Are Solved

Custom fonts uploaded via the branding/label policy were not supported.

# How the Problems Are Solved

- The login now correctly applies the branding settings, based on the
organization context
- Use the absolute `fontUrl` from the branding API directly in the
`@font-face` `src`, matching how logo and icon assets are already loaded
via absolute URLs.
- Add the Zitadel service URL to the `font-src` CSP directive so the
browser permits loading the cross-origin font.

# Additional Changes

- Updated CSP tests to reflect the new `font-src` behavior.
This commit is contained in:
Max Peintner
2026-06-15 14:33:54 +00:00
committed by GitHub
parent 8e82ec1cb9
commit fdafb95a63
5 changed files with 66 additions and 4 deletions
@@ -18,6 +18,66 @@ export const ThemeWrapper = ({ children, branding }: Props) => {
setTheme(document, branding);
}, [branding]);
// Apply custom font from branding settings before paint to avoid FOUC.
// When a custom font is uploaded via the label/branding policy, fontUrl
// contains a fully-resolved URL to the font file served by the assets API.
// We inject a @font-face rule and set a CSS custom property so the entire
// login UI picks up the custom font with the existing font as fallback.
useLayoutEffect(() => {
const STYLE_ID = "zitadel-custom-font";
if (branding?.fontUrl) {
let fontSrc: string;
try {
fontSrc = new URL(branding.fontUrl).href;
} catch {
// Malformed URL — skip custom font
return;
}
let styleEl = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement("style");
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
// Capture the current font-family (Lato from next/font) before overriding,
// so it serves as fallback if the custom font fails to load.
const existingFont = getComputedStyle(document.documentElement).fontFamily || "sans-serif";
const fontStack = `'ZitadelCustomFont', ${existingFont}`;
styleEl.textContent = `
@font-face {
font-family: 'ZitadelCustomFont';
font-style: normal;
font-display: swap;
src: url('${fontSrc}');
}
`;
document.documentElement.style.setProperty("--zitadel-font-family", fontStack);
// Inline style overrides the class-based Lato from next/font
document.documentElement.style.setProperty("font-family", fontStack);
} else {
// No custom font — remove injected style and let Lato class take over
const existing = document.getElementById(STYLE_ID);
if (existing) {
existing.remove();
}
document.documentElement.style.removeProperty("--zitadel-font-family");
document.documentElement.style.removeProperty("font-family");
}
return () => {
const existing = document.getElementById(STYLE_ID);
if (existing) {
existing.remove();
}
document.documentElement.style.removeProperty("--zitadel-font-family");
document.documentElement.style.removeProperty("font-family");
};
}, [branding?.fontUrl]);
// Publish themeMode to the module-level store so ThemeSwitch can read it
useEffect(() => {
setThemeMode(branding?.themeMode ?? ThemeMode.UNSPECIFIED);