feat(whitelabel): implement whitelabeling features and database updates

- Added support for whitelabeling, allowing customization of app name, logo, favicon, tagline, and custom CSS.
- Introduced new database columns to store whitelabel settings.
- Updated various components to utilize whitelabel settings, including the logo and favicon in the UI.
- Enhanced the onboarding layout and sidebar to reflect whitelabel configurations.
- Integrated whitelabel settings into the application head for dynamic title and favicon updates.
This commit is contained in:
Mauricio Siu
2026-02-16 01:59:00 -06:00
parent e8bec0ae03
commit 516315db79
32 changed files with 22897 additions and 28 deletions

View File

@@ -0,0 +1,540 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, ImagePlus, Loader2, Palette, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const formSchema = z.object({
appName: z.string().max(256).optional().or(z.literal("")),
tagline: z.string().max(512).optional().or(z.literal("")),
logoUrl: z
.string()
.optional()
.or(z.literal(""))
.refine(
(v) =>
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
"Must be a valid URL or uploaded image",
),
faviconUrl: z
.string()
.optional()
.or(z.literal(""))
.refine(
(v) =>
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
"Must be a valid URL or uploaded image",
),
customCss: z.string().max(8192).optional().or(z.literal("")),
});
type FormValues = z.infer<typeof formSchema>;
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const MAX_FAVICON_SIZE_BYTES = 512 * 1024; // 512KB (favicons are small)
/** Reference of CSS variables used by the app (from globals.css). Use in :root for light, .dark for dark mode. Values: HSL without hsl() e.g. 220 70% 50% */
const CSS_VARIABLES_REFERENCE = `/* General */
--background, --foreground
--card, --card-foreground
--popover, --popover-foreground
--primary, --primary-foreground
--secondary, --secondary-foreground
--muted, --muted-foreground
--accent, --accent-foreground
--destructive, --destructive-foreground
--border, --input, --ring
--radius (e.g. 0.5rem)
--overlay (e.g. rgba(0,0,0,0.2))
/* Sidebar */
--sidebar-background, --sidebar-foreground
--sidebar-primary, --sidebar-primary-foreground
--sidebar-accent, --sidebar-accent-foreground
--sidebar-border, --sidebar-ring
/* Charts */
--chart-1, --chart-2, --chart-3, --chart-4, --chart-5`;
/** Default theme CSS (mirrors globals.css). Load into editor so user can edit variables without writing from scratch. */
const DEFAULT_THEME_CSS = `:root {
--terminal-paste: rgba(0, 0, 0, 0.2);
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
--overlay: rgba(0, 0, 0, 0.2);
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--terminal-paste: rgba(255, 255, 255, 0.2);
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 240 4% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 4% 10%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 4% 10%;
--ring: 240 4.9% 83.9%;
--overlay: rgba(0, 0, 0, 0.5);
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 160 60% 45%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
`;
export const WhitelabelForm = () => {
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading } = api.whitelabel.get.useQuery();
const utils = api.useUtils();
const { mutateAsync, isLoading: isPending } =
api.whitelabel.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
appName: "",
tagline: "",
logoUrl: "",
faviconUrl: "",
customCss: "",
},
});
useEffect(() => {
if (data) {
form.reset({
appName: data.appName ?? "",
tagline: data.tagline ?? "",
logoUrl: data.logoUrl ?? "",
faviconUrl: data.faviconUrl ?? "",
customCss: data.customCss ?? "",
});
}
}, [data, form]);
const onSubmit = async (values: FormValues) => {
await mutateAsync({
appName: values.appName?.trim() || null,
tagline: values.tagline?.trim() || null,
logoUrl: values.logoUrl?.trim() || null,
faviconUrl: values.faviconUrl?.trim() || null,
customCss: values.customCss?.trim() || null,
})
.then(() => {
utils.whitelabel.get.invalidate();
toast.success("Whitelabel settings saved");
})
.catch((error) => {
toast.error(error.message ?? "Failed to save");
});
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<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">Whitelabelling</CardTitle>
</div>
<CardDescription>
Customize the app name and logos for your self-hosted instance.
These will appear on the login page, sidebar, and browser tab.
</CardDescription>
</div>
</div>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App name</FormLabel>
<FormControl>
<Input placeholder="e.g. My Company DevOps" {...field} />
</FormControl>
<FormDescription>
Replaces &quot;Dokploy&quot; in the UI when set.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagline"
render={({ field }) => (
<FormItem>
<FormLabel>Tagline</FormLabel>
<FormControl>
<Input
placeholder="e.g. The Open Source alternative to Netlify, Vercel, Heroku."
{...field}
/>
</FormControl>
<FormDescription>
Quote shown on the login/onboarding side panel. Leave empty
for default.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo</FormLabel>
<FormControl>
<>
{field.value?.startsWith("data:") ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
<Logo
className="size-12 shrink-0"
logoUrl={field.value}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Uploaded image
</p>
<p className="text-xs text-muted-foreground">
Image is stored and will appear in sidebar and
login.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
logoFileInputRef.current?.click()
}
>
<ImagePlus className="mr-1 size-4" />
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange("")}
>
<X className="mr-1 size-4" />
Use URL instead
</Button>
</div>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="https://example.com/logo.png"
{...field}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => logoFileInputRef.current?.click()}
>
<ImagePlus className="mr-2 size-4" />
Upload image
</Button>
</div>
)}
<input
ref={logoFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_LOGO_SIZE_BYTES) {
toast.error("Image size must be less than 2MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
e.target.value = "";
}}
/>
</>
</FormControl>
<FormDescription>
Paste a logo URL or upload an image (max 2MB). Used in
sidebar and login.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon</FormLabel>
<FormControl>
<>
{field.value?.startsWith("data:") ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
{field.value && (
// biome-ignore lint/performance/noImgElement: favicon preview from data URL
<img
src={field.value}
alt="Favicon"
className="size-8 shrink-0 object-contain"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Uploaded favicon
</p>
<p className="text-xs text-muted-foreground">
Shown in the browser tab.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
faviconFileInputRef.current?.click()
}
>
<ImagePlus className="mr-1 size-4" />
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange("")}
>
<X className="mr-1 size-4" />
Use URL instead
</Button>
</div>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="https://example.com/favicon.ico"
{...field}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => faviconFileInputRef.current?.click()}
>
<ImagePlus className="mr-2 size-4" />
Upload image
</Button>
</div>
)}
<input
ref={faviconFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FAVICON_SIZE_BYTES) {
toast.error(
"Favicon size must be less than 512KB",
);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
e.target.value = "";
}}
/>
</>
</FormControl>
<FormDescription>
Paste a favicon URL or upload an image (max 512KB). Shown in
the browser tab.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="customCss"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>Custom CSS</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
form.setValue("customCss", DEFAULT_THEME_CSS, {
shouldDirty: true,
});
toast.success("Default theme loaded. Edit and save.");
}}
>
Load default theme
</Button>
</div>
<FormControl>
<CodeEditor
language="css"
lineWrapping
lineNumbers={false}
wrapperClassName="min-h-[180px] rounded-md border"
placeholder={`:root {
--primary: 220 70% 50%;
--primary-foreground: 0 0% 100%;
}
.dark {
--primary: 220 70% 50%;
}`}
{...field}
/>
</FormControl>
<FormDescription>
Optional. Override theme colors using CSS variables. Use{" "}
<code className="rounded bg-muted px-1">:root</code> for
light mode and{" "}
<code className="rounded bg-muted px-1">.dark</code> for
dark mode. Values in HSL without &quot;hsl()&quot;.
Don&apos;t use quotes around colors (e.g.{" "}
<code className="rounded bg-muted px-1">red</code>, not
&quot;red&quot;). Max 8KB.
</FormDescription>
<Collapsible className="mt-2 group">
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
Available CSS variables
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 rounded-md border bg-muted p-3 text-xs font-mono overflow-x-auto whitespace-pre text-muted-foreground">
{CSS_VARIABLES_REFERENCE}
</pre>
</CollapsibleContent>
</Collapsible>
</FormItem>
)}
/>
{form.watch("logoUrl") && (
<div className="rounded-lg border p-4 bg-muted/30">
<p className="text-sm text-muted-foreground mb-2">
Logo preview
</p>
<Logo
className="size-12"
logoUrl={form.watch("logoUrl") || undefined}
/>
</div>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</form>
</Form>
)}
</div>
);
};

View File

@@ -1,14 +1,23 @@
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
const DEFAULT_TAGLINE =
"The Open Source alternative to Netlify, Vercel, Heroku.";
interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.whitelabel.get.useQuery();
const appName = whitelabel?.appName ?? "Dokploy";
const logoUrl = whitelabel?.logoUrl ?? undefined;
const tagline = whitelabel?.tagline ?? DEFAULT_TAGLINE;
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="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
@@ -17,15 +26,12 @@ export const OnboardingLayout = ({ children }: Props) => {
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" />
Dokploy
<Logo className="size-10" logoUrl={logoUrl} />
{appName}
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg text-primary">
&ldquo;The Open Source alternative to Netlify, Vercel,
Heroku.&rdquo;
</p>
<p className="text-lg text-primary">&ldquo;{tagline}&rdquo;</p>
</blockquote>
</div>
</div>

View File

@@ -24,6 +24,7 @@ import {
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Server,
ShieldCheck,
@@ -406,6 +407,15 @@ const MENU: Menu = {
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Whitelabelling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Proprietary: only in non-cloud, admins only
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
title: "SSO",
@@ -538,6 +548,9 @@ function LogoWrapper() {
function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery(undefined, {
enabled: !isCloud,
});
const { data: user } = api.user.get.useQuery();
const { data: session } = authClient.useSession();
const {
@@ -610,7 +623,11 @@ function SidebarLogo() {
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={activeOrganization?.logo || undefined}
logoUrl={
activeOrganization?.logo ||
whitelabel?.logoUrl ||
undefined
}
/>
</div>
<div
@@ -620,7 +637,9 @@ function SidebarLogo() {
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ?? "Select Organization"}
{activeOrganization?.name ??
whitelabel?.appName ??
"Select Organization"}
</p>
</div>
</div>

View File

@@ -0,0 +1,27 @@
"use client";
import Head from "next/head";
import { api } from "@/utils/api";
/** When whitelabel is configured (non-cloud), overrides document title, favicon, and optional custom CSS. */
export function WhitelabelHead() {
const { data: whitelabel } = api.whitelabel.get.useQuery();
// Always render so we can override favicon (same key as _app default replaces it)
return (
<Head>
{whitelabel?.appName && <title>{whitelabel.appName}</title>}
{whitelabel?.faviconUrl ? (
<link rel="icon" href={whitelabel.faviconUrl} key="favicon" />
) : (
<link rel="icon" href="/icon.svg" key="favicon" />
)}
{whitelabel?.customCss?.trim() && (
<style
id="whitelabel-custom-css"
dangerouslySetInnerHTML={{ __html: whitelabel.customCss.trim() }}
/>
)}
</Head>
);
}

View File

@@ -4,6 +4,7 @@ import {
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language";
@@ -130,7 +131,7 @@ function dockerComposeComplete(
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties" | "shell";
language?: "yaml" | "json" | "properties" | "shell" | "css";
lineWrapping?: boolean;
lineNumbers?: boolean;
}

View File

@@ -1,15 +1,17 @@
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
className?: string;
logoUrl?: string;
}
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
if (logoUrl) {
export const Logo = ({ className = "size-14" }: Props) => {
const { data: whitelabel } = api.whitelabel.get.useQuery();
if (whitelabel?.logoUrl) {
return (
<img
src={logoUrl}
src={whitelabel?.logoUrl}
alt="Organization Logo"
className={cn(className, "object-contain rounded-sm")}
/>

View File

@@ -0,0 +1,3 @@
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelAppName" text;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelLogoUrl" text;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelFaviconUrl" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelTagline" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelCustomCss" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1009,6 +1009,27 @@
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1771222951607,
"tag": "0144_flaky_psylocke",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1771226249421,
"tag": "0145_modern_risque",
"breakpoints": true
},
{
"idx": 146,
"version": "7",
"when": 1771226541422,
"tag": "0146_last_kitty_pryde",
"breakpoints": true
}
]
}

View File

@@ -49,6 +49,7 @@
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",

View File

@@ -9,6 +9,7 @@ import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelHead } from "@/components/proprietary/whitelabel-head";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
@@ -26,9 +27,10 @@ type AppPropsWithLayout = AppProps & {
const MyApp = ({
Component,
pageProps: { ...pageProps },
pageProps: { whitelabelFaviconUrl, ...pageProps },
}: AppPropsWithLayout) => {
const getLayout = Component.getLayout ?? ((page) => page);
const faviconHref = whitelabelFaviconUrl ?? "/icon.svg";
return (
<>
@@ -41,7 +43,9 @@ const MyApp = ({
</style>
<Head>
<title>Dokploy</title>
<link rel="icon" href={faviconHref} key="favicon" />
</Head>
<WhitelabelHead />
<ThemeProvider
attribute="class"
defaultTheme="system"

View File

@@ -4,7 +4,7 @@ export default function Document() {
return (
<Html lang="en" className="font-sans">
<Head>
<link rel="icon" href="/icon.svg" />
{/* Default favicon; WhitelabelHead overrides with key="favicon" when custom favicon is set */}
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />

View File

@@ -90,6 +90,7 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.project?.projectId || "",
@@ -121,7 +122,8 @@ const Service = (
/>
<Head>
<title>
Application: {data?.name} - {data?.environment.project.name} | Dokploy
Application: {data?.name} - {data?.environment.project.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">
@@ -165,7 +167,7 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || "Dokploy Server"}
{data?.server?.name || `${whitelabel?.appName ?? "Dokploy"} Server`}
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>

View File

@@ -79,6 +79,7 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
@@ -110,7 +111,8 @@ const Service = (
/>
<Head>
<title>
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
Compose: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -59,6 +59,7 @@ const Mariadb = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -93,8 +94,8 @@ const Mariadb = (
<div className="flex flex-col gap-4">
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">

View File

@@ -59,6 +59,7 @@ const Mongo = (
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -91,7 +92,8 @@ const Mongo = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -58,6 +58,7 @@ const MySql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -91,8 +92,8 @@ const MySql = (
<div className="flex flex-col gap-4">
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -58,6 +58,7 @@ const Postgresql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -90,7 +91,8 @@ const Postgresql = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -58,6 +58,7 @@ const Redis = (
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -90,7 +91,8 @@ const Redis = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
</title>
</Head>
<div className="w-full">

View File

@@ -0,0 +1,96 @@
import { IS_CLOUD, 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 { WhitelabelForm } from "@/components/dashboard/settings/whitelabelling/whitelabel-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
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: "Whitelabelling",
description:
"Customise app name and logos (whitelabelling) is part of Dokploy Enterprise. Add a valid license to use it.",
ctaLabel: "Go to License",
}}
>
<WhitelabelForm />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => (
<DashboardLayout metaName="Whitelabelling">{page}</DashboardLayout>
);
export async function getServerSideProps(
ctx: GetServerSidePropsContext<Record<string, string>>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role !== "owner" && user.role !== "admin") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: null as any,
user: user as any,
},
transformer: superjson,
});
await helpers.whitelabel.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -1,6 +1,7 @@
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -8,6 +9,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
@@ -40,6 +42,7 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const LoginSchema = z.object({
@@ -59,6 +62,7 @@ interface Props {
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -435,9 +439,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: context.req as any,
res: context.res as any,
db: null as any,
session: null,
user: null,
},
transformer: superjson,
});
await helpers.whitelabel.get.prefetch();
const whitelabel = await helpers.whitelabel.get.fetch();
return {
props: {
hasAdmin,
trpcState: helpers.dehydrate(),
whitelabelFaviconUrl: whitelabel?.faviconUrl ?? null,
},
};
}

View File

@@ -1,11 +1,13 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -22,6 +24,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const registerSchema = z
@@ -91,6 +94,7 @@ const Invitation = ({
initialData: invitation,
},
);
const { data: whitelabel } = api.whitelabel.get.useQuery();
const form = useForm<Register>({
defaultValues: {
@@ -346,6 +350,21 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
try {
const invitation = await getUserByToken(token);
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: ctx.req as any,
res: ctx.res as any,
db: null as any,
session: null,
user: null,
},
transformer: superjson,
});
if (!IS_CLOUD) {
await helpers.whitelabel.get.prefetch();
}
if (invitation.userAlreadyExists) {
return {
props: {
@@ -353,6 +372,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
token: token,
invitation: invitation,
userAlreadyExists: true,
trpcState: helpers.dehydrate(),
},
};
}
@@ -371,6 +391,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
trpcState: helpers.dehydrate(),
},
};
} catch (error) {

View File

@@ -22,6 +22,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const loginSchema = z.object({
email: z
@@ -48,6 +49,7 @@ export default function Home() {
});
const [error, setError] = useState<string | null>(null);
const { data: whitelabel } = api.whitelabel.get.useQuery();
const [isLoading, setIsLoading] = useState(false);
const _router = useRouter();
const form = useForm<Login>({
@@ -82,7 +84,9 @@ export default function Home() {
<div className="flex flex-col items-center gap-4 w-full">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo />
<span className="font-medium text-sm">Dokploy</span>
<span className="font-medium text-sm">
{whitelabel?.appName ?? "Dokploy"}
</span>
</Link>
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
<CardDescription>

View File

@@ -24,6 +24,7 @@ import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelRouter } from "./routers/proprietary/whitelabel";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
@@ -86,6 +87,7 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
whitelabel: whitelabelRouter,
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,

View File

@@ -0,0 +1,61 @@
import { getWebServerSettings, updateWebServerSettings } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { apiUpdateWhitelabelSettings } from "@dokploy/server/db/schema";
import {
createTRPCRouter,
enterpriseProcedure,
publicProcedure,
} from "@/server/api/trpc";
export const whitelabelRouter = createTRPCRouter({
get: publicProcedure.query(async () => {
if (IS_CLOUD) return null;
const settings = await getWebServerSettings();
if (!settings) return null;
return {
appName: settings.whitelabelAppName ?? null,
logoUrl: settings.whitelabelLogoUrl ?? null,
faviconUrl: settings.whitelabelFaviconUrl ?? null,
tagline: settings.whitelabelTagline ?? null,
customCss: settings.whitelabelCustomCss ?? null,
};
}),
update: enterpriseProcedure
.input(apiUpdateWhitelabelSettings)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
throw new Error(
"Whitelabelling is only available in self-hosted (non-cloud) installations.",
);
}
await updateWebServerSettings({
...(input.appName !== undefined && {
whitelabelAppName: input.appName,
}),
...(input.tagline !== undefined && {
whitelabelTagline: input.tagline,
}),
...(input.logoUrl !== undefined && {
whitelabelLogoUrl: input.logoUrl,
}),
...(input.faviconUrl !== undefined && {
whitelabelFaviconUrl: input.faviconUrl,
}),
...(input.customCss !== undefined && {
whitelabelCustomCss: input.customCss,
}),
});
const settings = await getWebServerSettings();
return {
appName: settings?.whitelabelAppName ?? null,
logoUrl: settings?.whitelabelLogoUrl ?? null,
faviconUrl: settings?.whitelabelFaviconUrl ?? null,
tagline: settings?.whitelabelTagline ?? null,
customCss: settings?.whitelabelCustomCss ?? null,
};
}),
});

View File

@@ -76,6 +76,12 @@ export const webServerSettings = pgTable("webServerSettings", {
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
.notNull()
.default(false),
// Whitelabelling (non-cloud only)
whitelabelAppName: text("whitelabelAppName"),
whitelabelLogoUrl: text("whitelabelLogoUrl"),
whitelabelFaviconUrl: text("whitelabelFaviconUrl"),
whitelabelTagline: text("whitelabelTagline"),
whitelabelCustomCss: text("whitelabelCustomCss"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -125,6 +131,59 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(),
whitelabelAppName: z.string().max(256).optional().nullable(),
whitelabelLogoUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
whitelabelFaviconUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
whitelabelTagline: z.string().max(512).optional().nullable(),
whitelabelCustomCss: z.string().max(8192).optional().nullable(),
});
export const apiUpdateWhitelabelSettings = z.object({
appName: z.string().max(256).optional().nullable(),
tagline: z.string().max(512).optional().nullable(),
logoUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
faviconUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
customCss: z.string().max(8192).optional().nullable(),
});
export const apiAssignDomain = z

View File

@@ -5,9 +5,9 @@ import { db } from "../../db/index";
import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
process.env.NODE_ENV === "development"
? "http://localhost:4002"
: "https://licenses-api.dokploy.com";
// process.env.NODE_ENV === "development"
// ? "http://localhost:4002"
"https://licenses-api.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {

23
pnpm-lock.yaml generated
View File

@@ -115,6 +115,9 @@ importers:
'@codemirror/autocomplete':
specifier: ^6.18.6
version: 6.18.6
'@codemirror/lang-css':
specifier: ^6.2.1
version: 6.3.1
'@codemirror/lang-json':
specifier: ^6.0.1
version: 6.0.1
@@ -1170,6 +1173,9 @@ packages:
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-json@6.0.1':
resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==}
@@ -2207,6 +2213,9 @@ packages:
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
@@ -8861,6 +8870,14 @@ snapshots:
'@codemirror/view': 6.29.0
'@lezer/common': 1.2.3
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.11.0
'@codemirror/state': 6.5.2
'@lezer/common': 1.2.3
'@lezer/css': 1.3.0
'@codemirror/lang-json@6.0.1':
dependencies:
'@codemirror/language': 6.11.0
@@ -9542,6 +9559,12 @@ snapshots:
'@lezer/common@1.2.3': {}
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3