Merge pull request #5649 from imrja8/feat/dual-mode-branding

feat(enterprise): dual-mode branding with separate light/dark theme logos
This commit is contained in:
Narciso E. Núñez Arias
2026-10-12 09:59:25 -04:00
committed by GitHub
16 changed files with 275 additions and 27 deletions
@@ -62,6 +62,10 @@ const baseSettings: WebServerSettings = {
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
@@ -186,6 +186,7 @@ export const ImpersonationBar = () => {
<Logo
className="w-10 h-10"
logoUrl={whitelabeling?.logoUrl || undefined}
logoLightUrl={whitelabeling?.logoLightUrl || undefined}
/>
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
@@ -19,6 +19,10 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
const logoUrl =
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
const logoLightUrl =
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
@@ -28,7 +32,11 @@ export const OnboardingLayout = ({ children, leftPanel }: Props) => {
href="/"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" logoUrl={logoUrl} />
<Logo
className="size-10"
logoUrl={logoUrl}
logoLightUrl={logoLightUrl}
/>
{appName}
</Link>
<div className="relative z-20 mt-auto">
@@ -1,5 +1,6 @@
"use client";
import { useTheme } from "next-themes";
import {
Card,
CardContent,
@@ -12,12 +13,19 @@ interface WhitelabelingPreviewProps {
config: {
appName?: string;
logoUrl?: string;
logoLightUrl?: string;
dualModeBranding?: boolean;
footerText?: string;
};
}
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
const appName = config.appName || "Dokploy";
const { resolvedTheme } = useTheme();
const effectiveLogoUrl =
config.dualModeBranding && resolvedTheme === "light" && config.logoLightUrl
? config.logoLightUrl
: config.logoUrl;
return (
<Card className="bg-transparent">
@@ -31,9 +39,9 @@ export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
<div className="rounded-lg border overflow-hidden">
{/* Simulated sidebar header */}
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
{config.logoUrl ? (
{effectiveLogoUrl ? (
<img
src={config.logoUrl}
src={effectiveLogoUrl}
alt="Preview Logo"
className="size-8 rounded-sm object-contain"
/>
@@ -26,6 +26,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { WhitelabelingPreview } from "./whitelabeling-preview";
@@ -36,20 +37,42 @@ const safeUrlField = z
message: "Only http:// and https:// URLs are allowed",
});
const formSchema = z.object({
appName: z.string(),
appDescription: z.string(),
logoUrl: safeUrlField,
faviconUrl: safeUrlField,
customCss: z.string(),
loginLogoUrl: safeUrlField,
supportUrl: safeUrlField,
docsUrl: safeUrlField,
errorPageTitle: z.string(),
errorPageDescription: z.string(),
footerText: z.string(),
ogImageUrl: safeUrlField,
});
const formSchema = z
.object({
appName: z.string(),
appDescription: z.string(),
logoUrl: safeUrlField,
faviconUrl: safeUrlField,
customCss: z.string(),
loginLogoUrl: safeUrlField,
supportUrl: safeUrlField,
docsUrl: safeUrlField,
errorPageTitle: z.string(),
errorPageDescription: z.string(),
footerText: z.string(),
ogImageUrl: safeUrlField,
logoLightUrl: z.string(),
loginLogoLightUrl: z.string(),
faviconLightUrl: z.string(),
dualModeBranding: z.boolean(),
})
.superRefine((data, ctx) => {
if (!data.dualModeBranding) return;
for (const field of [
"logoLightUrl",
"loginLogoLightUrl",
"faviconLightUrl",
] as const) {
const val = data[field];
if (val !== "" && !/^https?:\/\//i.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Only http:// and https:// URLs are allowed",
path: [field],
});
}
}
});
type FormSchema = z.infer<typeof formSchema>;
@@ -195,6 +218,10 @@ export function WhitelabelingSettings() {
errorPageDescription: "",
footerText: "",
ogImageUrl: "",
logoLightUrl: "",
loginLogoLightUrl: "",
faviconLightUrl: "",
dualModeBranding: false,
},
resolver: zodResolver(formSchema),
});
@@ -214,6 +241,10 @@ export function WhitelabelingSettings() {
errorPageDescription: data.errorPageDescription ?? "",
footerText: data.footerText ?? "",
ogImageUrl: data.ogImageUrl ?? "",
logoLightUrl: data.logoLightUrl ?? "",
loginLogoLightUrl: data.loginLogoLightUrl ?? "",
faviconLightUrl: data.faviconLightUrl ?? "",
dualModeBranding: data.dualModeBranding ?? false,
});
}
}, [data, form]);
@@ -244,6 +275,10 @@ export function WhitelabelingSettings() {
errorPageDescription: values.errorPageDescription || null,
footerText: values.footerText || null,
ogImageUrl: values.ogImageUrl || null,
logoLightUrl: values.logoLightUrl || null,
loginLogoLightUrl: values.loginLogoLightUrl || null,
faviconLightUrl: values.faviconLightUrl || null,
dualModeBranding: values.dualModeBranding,
},
})
.then(async () => {
@@ -412,6 +447,111 @@ export function WhitelabelingSettings() {
</CardContent>
</Card>
{/* Light Mode Logos Section */}
<Card className="bg-transparent">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<CardTitle>Light Mode Logos</CardTitle>
<CardDescription>
Use separate logos for light and dark themes. When enabled,
the logos in Branding are used for dark mode.
</CardDescription>
</div>
<FormField
control={form.control}
name="dualModeBranding"
render={({ field }) => (
<FormItem>
<FormControl>
<Switch
aria-label="Enable light mode logos"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.clearErrors([
"logoLightUrl",
"loginLogoLightUrl",
"faviconLightUrl",
]);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</CardHeader>
<div
className="grid transition-all duration-300 ease-in-out"
style={{
gridTemplateRows: form.watch("dualModeBranding")
? "1fr"
: "0fr",
}}
>
<div className="overflow-hidden">
<CardContent className="flex flex-col gap-4">
<FormField
control={form.control}
name="logoLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo-light.svg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="loginLogoLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login Page Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo-light.svg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconLightUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon-light.ico"
{...field}
/>
</FormControl>
<FormDescription>
Follows the browser/OS theme, not the website theme.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</div>
</div>
</Card>
{/* Appearance Section */}
<Card className="bg-transparent">
<CardHeader>
+13 -4
View File
@@ -1,16 +1,25 @@
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
interface Props {
className?: string;
logoUrl?: string;
logoLightUrl?: string;
}
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
if (logoUrl) {
export const Logo = ({
className = "size-14",
logoUrl,
logoLightUrl,
}: Props) => {
const { resolvedTheme } = useTheme();
const effectiveUrl =
resolvedTheme === "light" && logoLightUrl ? logoLightUrl : logoUrl;
if (effectiveUrl) {
return (
// biome-ignore lint/performance/noImgElement: this is for dynamic logo loading
<img
src={logoUrl}
src={effectiveUrl}
alt="Organization Logo"
className={cn(className, "object-contain rounded-sm")}
/>
+23 -1
View File
@@ -13,6 +13,7 @@ interface WhitelabelingDocumentProps {
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
faviconLightHref: string | null;
customCss: string | null;
baseUrl: string;
}
@@ -22,6 +23,7 @@ export default function Document({
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
baseUrl,
}: WhitelabelingDocumentProps) {
@@ -44,7 +46,22 @@ export default function Document({
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={ogImage} />
<link rel="icon" href={faviconHref || "/icon.svg"} />
{faviconLightHref ? (
<>
<link
rel="icon"
href={faviconHref || "/icon.svg"}
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
href={faviconLightHref}
media="(prefers-color-scheme: light)"
/>
</>
) : (
<link rel="icon" href={faviconHref || "/icon.svg"} />
)}
{customCss && (
<style
id="whitelabeling-styles"
@@ -69,6 +86,7 @@ declare global {
appDescription: string | null;
ogImageUrl: string | null;
faviconHref: string | null;
faviconLightHref: string | null;
customCss: string | null;
};
expiresAt: number;
@@ -84,6 +102,7 @@ Document.getInitialProps = async (
let appDescription: string | null = null;
let ogImageUrl: string | null = null;
let faviconHref: string | null = null;
let faviconLightHref: string | null = null;
let customCss: string | null = null;
const host = ctx.req?.headers?.host || "localhost:3000";
@@ -113,6 +132,7 @@ Document.getInitialProps = async (
? config.customCss.replace(/<\/\s*style[^>]*>/gi, "")
: null;
faviconHref = config.faviconUrl || null;
faviconLightHref = config.faviconLightUrl || null;
}
} catch {
// Fall back to defaults if settings can't be read (e.g. DB not ready)
@@ -124,6 +144,7 @@ Document.getInitialProps = async (
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
},
expiresAt: Date.now() + SETTINGS_CACHE_TTL,
@@ -135,6 +156,7 @@ Document.getInitialProps = async (
appDescription,
ogImageUrl,
faviconHref,
faviconLightHref,
customCss,
baseUrl,
};
+2 -1
View File
@@ -13,6 +13,7 @@ export default function ErrorPage({ statusCode }: Props) {
const { config: whitelabeling } = useWhitelabelingPublic();
const appName = whitelabeling?.appName || "Dokploy";
const logoUrl = whitelabeling?.logoUrl || undefined;
const logoLightUrl = whitelabeling?.logoLightUrl || undefined;
const errorTitle = whitelabeling?.errorPageTitle;
const errorDescription = whitelabeling?.errorPageDescription;
@@ -22,7 +23,7 @@ export default function ErrorPage({ statusCode }: Props) {
<header className="mb-auto flex justify-center z-50 w-full py-4">
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo logoUrl={logoUrl} />
<Logo logoUrl={logoUrl} logoLightUrl={logoLightUrl} />
<span className="font-medium text-sm">{appName}</span>
</Link>
</nav>
+6 -1
View File
@@ -5,7 +5,6 @@ import {
} from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { Fingerprint } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@@ -49,6 +48,7 @@ import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const LoginSchema = z.object({
@@ -306,6 +306,11 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
Sign in
</div>
+6 -1
View File
@@ -1,6 +1,5 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -26,6 +25,7 @@ import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -164,6 +164,11 @@ const Invitation = ({
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
Invitation
+6 -1
View File
@@ -1,6 +1,5 @@
import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -29,6 +28,7 @@ import { Input } from "@/components/ui/input";
import { pushToDataLayer } from "@/lib/analytics";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const registerSchema = z
@@ -138,6 +138,11 @@ const Register = ({ isCloud }: Props) => {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
{isCloud ? "Sign Up" : "Setup the server"}
+5
View File
@@ -106,6 +106,11 @@ export default function Home({ tokenResetPassword }: Props) {
whitelabeling?.logoUrl ||
undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
</Link>
Reset Password
+6 -1
View File
@@ -1,6 +1,5 @@
import { IS_CLOUD } from "@dokploy/server";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
@@ -24,6 +23,7 @@ import {
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { generateServerSideHelper } from "@/utils/create-server-helpers";
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
const loginSchema = z.object({
@@ -92,6 +92,11 @@ export default function Home() {
logoUrl={
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
}
logoLightUrl={
whitelabeling?.loginLogoLightUrl ||
whitelabeling?.logoLightUrl ||
undefined
}
/>
<span className="font-medium text-sm">
{whitelabeling?.appName || "Dokploy"}
@@ -87,6 +87,10 @@ export const whitelabelingRouter = createTRPCRouter({
errorPageDescription: null,
ogImageUrl: null,
footerText: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
},
});
@@ -88,6 +88,10 @@ export const webServerSettings = pgTable("webServerSettings", {
errorPageDescription: string | null;
footerText: string | null;
ogImageUrl: string | null;
logoLightUrl: string | null;
loginLogoLightUrl: string | null;
faviconLightUrl: string | null;
dualModeBranding: boolean;
}>()
.default({
appName: null,
@@ -102,6 +106,10 @@ export const webServerSettings = pgTable("webServerSettings", {
errorPageDescription: null,
footerText: null,
ogImageUrl: null,
logoLightUrl: null,
loginLogoLightUrl: null,
faviconLightUrl: null,
dualModeBranding: false,
}),
// Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
@@ -225,6 +233,10 @@ export const whitelabelingConfigSchema = z.object({
errorPageDescription: z.string().nullable(),
footerText: z.string().nullable(),
ogImageUrl: safeUrl,
logoLightUrl: safeUrl,
loginLogoLightUrl: safeUrl,
faviconLightUrl: safeUrl,
dualModeBranding: z.boolean(),
});
export const apiUpdateWhitelabeling = z.object({
@@ -14,6 +14,10 @@ export interface PublicWhitelabelingConfig {
errorPageTitle: string | null;
errorPageDescription: string | null;
footerText: string | null;
logoLightUrl: string | null;
loginLogoLightUrl: string | null;
faviconLightUrl: string | null;
dualModeBranding: boolean;
}
// Self-hosted is single-tenant; gate on the oldest organization's license,
@@ -54,5 +58,15 @@ export const getPublicWhitelabelingConfig =
errorPageTitle: config.errorPageTitle,
errorPageDescription: config.errorPageDescription,
footerText: config.footerText,
dualModeBranding: config.dualModeBranding ?? false,
logoLightUrl: config.dualModeBranding
? (config.logoLightUrl ?? null)
: null,
loginLogoLightUrl: config.dualModeBranding
? (config.loginLogoLightUrl ?? null)
: null,
faviconLightUrl: config.dualModeBranding
? (config.faviconLightUrl ?? null)
: null,
};
};