mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -15,11 +15,11 @@ describe("buildCSP", () => {
|
||||
expect(csp).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
test("adds serviceUrl to img-src only", () => {
|
||||
test("adds serviceUrl to img-src and font-src", () => {
|
||||
const csp = buildCSP({ serviceUrl: "https://my-instance.zitadel.cloud" });
|
||||
|
||||
expect(csp).toContain("img-src 'self' https://my-instance.zitadel.cloud");
|
||||
expect(csp).toMatch(/font-src 'self'(?:;| |$)/);
|
||||
expect(csp).toContain("font-src 'self' https://my-instance.zitadel.cloud");
|
||||
});
|
||||
|
||||
test("keeps frame-ancestors as 'none' when iframeOrigins is empty", () => {
|
||||
@@ -44,6 +44,7 @@ describe("buildCSP", () => {
|
||||
});
|
||||
|
||||
expect(csp).toContain("img-src 'self' https://zitadel.mycompany.com");
|
||||
expect(csp).toContain("font-src 'self' https://zitadel.mycompany.com");
|
||||
expect(csp).toContain("frame-ancestors https://portal.mycompany.com");
|
||||
expect(csp).not.toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export function buildCSP(options: CSPOptions = {}): string {
|
||||
|
||||
if (options.serviceUrl) {
|
||||
directives["img-src"] = [...directives["img-src"], options.serviceUrl];
|
||||
directives["font-src"] = [...directives["font-src"], options.serviceUrl];
|
||||
}
|
||||
|
||||
if (options.iframeOrigins && options.iframeOrigins.length > 0) {
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Only proxy paths need to be rewritten to the ZITADEL backend
|
||||
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/"];
|
||||
const proxyPaths = ["/.well-known/", "/oauth/", "/oidc/", "/idps/callback/", "/saml/", "/assets/"];
|
||||
const isMatched = proxyPaths.some((prefix) => request.nextUrl.pathname.startsWith(prefix));
|
||||
|
||||
if (!isMatched) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/16173
|
||||
@import "tailwindcss";
|
||||
@use "tailwindcss";
|
||||
@config "../../tailwind.config.mjs";
|
||||
|
||||
@layer base {
|
||||
|
||||
Reference in New Issue
Block a user