feat(whitelabel): implement whitelabeling features and settings

- Added whitelabeling support, allowing customization of application name, logos, and login page.
- Introduced new WhitelabelSettings component for managing whitelabel configurations.
- Updated onboarding and sidebar layouts to reflect whitelabel settings dynamically.
- Created database schema changes to accommodate new whitelabel fields.
- Implemented API endpoints for retrieving and updating whitelabel settings.
This commit is contained in:
Mauricio Siu
2026-01-31 05:29:41 -06:00
parent 54229b0dcd
commit 1d266b0840
7 changed files with 512 additions and 8 deletions

View File

@@ -4,21 +4,35 @@ import { cn } from "@/lib/utils";
import { GithubIcon } from "../icons/data-tools-icons"; import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo"; import { Logo } from "../shared/logo";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { api } from "@/utils/api";
interface Props { interface Props {
children: React.ReactNode; children: React.ReactNode;
} }
export const OnboardingLayout = ({ children }: Props) => { export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
const logoUrl =
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
return ( 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"> <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">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex"> <div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
<div className="absolute inset-0 bg-muted" /> <div className="absolute inset-0 bg-muted" />
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
style={{
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
}}
/>
)}
<Link <Link
href="https://dokploy.com" href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary" className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
> >
<Logo className="size-10" /> <Logo className="size-10" logoUrl={logoUrl ?? undefined} />
Dokploy {appName}
</Link> </Link>
<div className="relative z-20 mt-auto"> <div className="relative z-20 mt-auto">
<blockquote className="space-y-2"> <blockquote className="space-y-2">

View File

@@ -21,8 +21,10 @@ import {
Key, Key,
KeyRound, KeyRound,
Loader2, Loader2,
LogIn,
type LucideIcon, type LucideIcon,
Package, Package,
Palette,
PieChart, PieChart,
Server, Server,
ShieldCheck, ShieldCheck,
@@ -30,7 +32,6 @@ import {
Trash2, Trash2,
User, User,
Users, Users,
LogIn,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
@@ -416,6 +417,15 @@ const MENU: Menu = {
isEnabled: ({ auth }) => isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"), !!(auth?.role === "owner" || auth?.role === "admin"),
}, },
{
isSingle: true,
title: "Whitelabeling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Enterprise only page shows gate if no license
isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || auth?.role === "admin"),
},
], ],
help: [ help: [
@@ -546,6 +556,7 @@ function SidebarLogo() {
refetch, refetch,
isLoading, isLoading,
} = api.organization.all.useQuery(); } = api.organization.all.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const { mutateAsync: deleteOrganization, isLoading: isRemoving } = const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
api.organization.delete.useMutation(); api.organization.delete.useMutation();
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } = const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
@@ -611,7 +622,11 @@ function SidebarLogo() {
"transition-all", "transition-all",
state === "collapsed" ? "size-4" : "size-5", state === "collapsed" ? "size-4" : "size-5",
)} )}
logoUrl={activeOrganization?.logo || undefined} logoUrl={
activeOrganization?.logo ||
whitelabel?.whitelabelLogoUrl ||
undefined
}
/> />
</div> </div>
<div <div
@@ -621,7 +636,9 @@ function SidebarLogo() {
)} )}
> >
<p className="text-sm font-medium leading-none"> <p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"} {activeOrganization?.name ??
whitelabel?.whitelabelAppName ??
"Select Organization"}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,290 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, Palette } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const whitelabelSchema = z.object({
whitelabelAppName: z.string().min(1).max(100),
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
whitelabelLoginTitle: z.string().max(200).optional(),
whitelabelLoginSubtitle: z.string().max(500).optional(),
whitelabelLoginBackgroundImageUrl: z
.union([z.string().url(), z.literal("")])
.optional(),
});
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
export function WhitelabelSettings() {
const { data: settings, isLoading } =
api.settings.getWebServerSettings.useQuery();
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
api.settings.updateWhitelabelSettings.useMutation();
const utils = api.useUtils();
const form = useForm<WhitelabelFormValues>({
resolver: zodResolver(whitelabelSchema),
defaultValues: {
whitelabelAppName: "Dokploy",
whitelabelLogoUrl: "",
whitelabelLoginLogoUrl: "",
whitelabelFaviconUrl: "",
whitelabelLoginTitle: "",
whitelabelLoginSubtitle: "",
whitelabelLoginBackgroundImageUrl: "",
},
});
useEffect(() => {
if (settings) {
form.reset({
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? "",
});
}
}, [settings, form]);
const onSubmit = async (values: WhitelabelFormValues) => {
try {
await updateWhitelabel({
whitelabelAppName: values.whitelabelAppName || null,
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
whitelabelLoginBackgroundImageUrl:
values.whitelabelLoginBackgroundImageUrl || undefined,
});
toast.success("Whitelabel settings saved");
utils.settings.getWebServerSettings.invalidate();
utils.settings.getWhitelabelSettings.invalidate();
} catch (e) {
toast.error("Failed to save whitelabel settings");
}
};
if (isLoading) {
return (
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-sm text-muted-foreground">
Loading whitelabel settings...
</span>
</div>
);
}
return (
<div className="flex flex-col gap-4 rounded-lg ">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Palette className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Whitelabeling</CardTitle>
</div>
<CardDescription>
Customize the application name, logos, and login page for your brand.
Leave URLs empty to use defaults.
</CardDescription>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<div className="space-y-4 pt-2 border-t">
<div>
<h3 className="text-sm font-medium">Brand</h3>
<p className="text-sm text-muted-foreground">
Application name and main logo (sidebar, header).
</p>
</div>
<FormField
control={form.control}
name="whitelabelAppName"
render={({ field }) => (
<FormItem>
<FormLabel>Application name</FormLabel>
<FormControl>
<Input
placeholder="Dokploy"
{...field}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo shown in the sidebar and header.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelFaviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/favicon.ico"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 pt-6 border-t">
<div>
<h3 className="text-sm font-medium">Login page</h3>
<p className="text-sm text-muted-foreground">
Customize the sign-in and registration screens.
</p>
</div>
<FormField
control={form.control}
name="whitelabelLoginLogoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login logo URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/login-logo.png"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Logo on the login and register pages. Falls back to the main
logo if empty.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginTitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login title</FormLabel>
<FormControl>
<Input
placeholder="Sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginSubtitle"
render={({ field }) => (
<FormItem>
<FormLabel>Login subtitle</FormLabel>
<FormControl>
<Input
placeholder="Enter your email and password to sign in"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="whitelabelLoginBackgroundImageUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Login background image URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/background.jpg"
{...field}
value={field.value ?? ""}
className="max-w-md"
/>
</FormControl>
<FormDescription>
Optional background image for the login page.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end pt-4 border-t">
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Saving...
</>
) : (
"Save changes"
)}
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Enterprise Whitelabeling",
description:
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
ctaLabel: "Go to License",
}}
>
<WhitelabelSettings />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req } = ctx;
const locale = await getLocale(req.cookies);
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: ctx.res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -59,6 +59,7 @@ interface Props {
export default function Home({ IS_CLOUD }: Props) { export default function Home({ IS_CLOUD }: Props) {
const router = useRouter(); const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery(); const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false); const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false); const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false); const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -212,17 +213,27 @@ export default function Home({ IS_CLOUD }: Props) {
</> </>
); );
const loginLogoUrl =
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
const loginSubtitle =
whitelabel?.whitelabelLoginSubtitle ??
"Enter your email and password to sign in";
return ( return (
<> <>
<div className="flex flex-col space-y-2 text-center"> <div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight"> <h1 className="text-2xl font-semibold tracking-tight">
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<Logo className="size-12" /> <Logo
Sign in className="size-12"
logoUrl={loginLogoUrl ?? undefined}
/>
{loginTitle}
</div> </div>
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Enter your email and password to sign in {loginSubtitle}
</p> </p>
</div> </div>
{error && ( {error && (

View File

@@ -63,6 +63,7 @@ import {
apiServerSchema, apiServerSchema,
apiTraefikConfig, apiTraefikConfig,
apiUpdateDockerCleanup, apiUpdateDockerCleanup,
apiUpdateWhitelabel,
projects, projects,
server, server,
} from "@/server/db/schema"; } from "@/server/db/schema";
@@ -72,6 +73,7 @@ import { appRouter } from "../root";
import { import {
adminProcedure, adminProcedure,
createTRPCRouter, createTRPCRouter,
enterpriseProcedure,
protectedProcedure, protectedProcedure,
publicProcedure, publicProcedure,
} from "../trpc"; } from "../trpc";
@@ -84,6 +86,57 @@ export const settingsRouter = createTRPCRouter({
const settings = await getWebServerSettings(); const settings = await getWebServerSettings();
return settings; return settings;
}), }),
getWhitelabelSettings: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
if (!settings) return null;
return {
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? null,
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? null,
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? null,
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? null,
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? null,
whitelabelLoginBackgroundImageUrl:
settings.whitelabelLoginBackgroundImageUrl ?? null,
};
}),
updateWhitelabelSettings: enterpriseProcedure
.input(apiUpdateWhitelabel)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}
const updates: Record<string, unknown> = {};
if (input.whitelabelAppName !== undefined)
updates.whitelabelAppName = input.whitelabelAppName;
if (input.whitelabelLogoUrl !== undefined)
updates.whitelabelLogoUrl =
input.whitelabelLogoUrl === "" ? null : input.whitelabelLogoUrl;
if (input.whitelabelLoginLogoUrl !== undefined)
updates.whitelabelLoginLogoUrl =
input.whitelabelLoginLogoUrl === ""
? null
: input.whitelabelLoginLogoUrl;
if (input.whitelabelFaviconUrl !== undefined)
updates.whitelabelFaviconUrl =
input.whitelabelFaviconUrl === ""
? null
: input.whitelabelFaviconUrl;
if (input.whitelabelLoginTitle !== undefined)
updates.whitelabelLoginTitle = input.whitelabelLoginTitle;
if (input.whitelabelLoginSubtitle !== undefined)
updates.whitelabelLoginSubtitle = input.whitelabelLoginSubtitle;
if (input.whitelabelLoginBackgroundImageUrl !== undefined)
updates.whitelabelLoginBackgroundImageUrl =
input.whitelabelLoginBackgroundImageUrl === ""
? null
: input.whitelabelLoginBackgroundImageUrl;
const updated = await updateWebServerSettings(updates as any);
return updated;
}),
reloadServer: adminProcedure.mutation(async () => { reloadServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return true;

View File

@@ -76,6 +76,14 @@ export const webServerSettings = pgTable("webServerSettings", {
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose") cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
.notNull() .notNull()
.default(false), .default(false),
// Whitelabel (Enterprise)
whitelabelAppName: text("whitelabelAppName").default("Dokploy"),
whitelabelLogoUrl: text("whitelabelLogoUrl"),
whitelabelLoginLogoUrl: text("whitelabelLoginLogoUrl"),
whitelabelFaviconUrl: text("whitelabelFaviconUrl"),
whitelabelLoginTitle: text("whitelabelLoginTitle"),
whitelabelLoginSubtitle: text("whitelabelLoginSubtitle"),
whitelabelLoginBackgroundImageUrl: text("whitelabelLoginBackgroundImageUrl"),
createdAt: timestamp("created_at").defaultNow(), createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); });
@@ -125,6 +133,18 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
cleanupCacheApplications: z.boolean().optional(), cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(), cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(), cleanupCacheOnCompose: z.boolean().optional(),
whitelabelAppName: z.string().optional().nullable(),
whitelabelLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
whitelabelLoginLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
whitelabelFaviconUrl: z.string().url().optional().nullable().or(z.literal("")),
whitelabelLoginTitle: z.string().optional().nullable(),
whitelabelLoginSubtitle: z.string().optional().nullable(),
whitelabelLoginBackgroundImageUrl: z
.string()
.url()
.optional()
.nullable()
.or(z.literal("")),
}); });
export const apiAssignDomain = z export const apiAssignDomain = z
@@ -154,6 +174,21 @@ export const apiUpdateDockerCleanup = z.object({
serverId: z.string().optional(), serverId: z.string().optional(),
}); });
export const apiUpdateWhitelabel = z.object({
whitelabelAppName: z.string().min(1).max(100).optional().nullable(),
whitelabelLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
whitelabelLoginLogoUrl: z.string().url().optional().nullable().or(z.literal("")),
whitelabelFaviconUrl: z.string().url().optional().nullable().or(z.literal("")),
whitelabelLoginTitle: z.string().max(200).optional().nullable(),
whitelabelLoginSubtitle: z.string().max(500).optional().nullable(),
whitelabelLoginBackgroundImageUrl: z
.string()
.url()
.optional()
.nullable()
.or(z.literal("")),
});
export const apiUpdateWebServerMonitoring = z.object({ export const apiUpdateWebServerMonitoring = z.object({
metricsConfig: z metricsConfig: z
.object({ .object({