diff --git a/apps/dokploy/components/dashboard/settings/whitelabelling/whitelabel-form.tsx b/apps/dokploy/components/dashboard/settings/whitelabelling/whitelabel-form.tsx new file mode 100644 index 000000000..c0f84ee55 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/whitelabelling/whitelabel-form.tsx @@ -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; + +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(null); + const faviconFileInputRef = useRef(null); + const { data, isLoading } = api.whitelabel.get.useQuery(); + const utils = api.useUtils(); + const { mutateAsync, isLoading: isPending } = + api.whitelabel.update.useMutation(); + + const form = useForm({ + 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 ( +
+
+
+
+ + Whitelabelling +
+ + Customize the app name and logos for your self-hosted instance. + These will appear on the login page, sidebar, and browser tab. + +
+
+ + {isLoading ? ( +
+ Loading... + +
+ ) : ( +
+ + ( + + App name + + + + + Replaces "Dokploy" in the UI when set. + + + )} + /> + ( + + Tagline + + + + + Quote shown on the login/onboarding side panel. Leave empty + for default. + + + )} + /> + ( + + Logo + + <> + {field.value?.startsWith("data:") ? ( +
+
+ +
+

+ Uploaded image +

+

+ Image is stored and will appear in sidebar and + login. +

+
+
+ + +
+
+
+ ) : ( +
+ + +
+ )} + { + 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 = ""; + }} + /> + +
+ + Paste a logo URL or upload an image (max 2MB). Used in + sidebar and login. + +
+ )} + /> + ( + + Favicon + + <> + {field.value?.startsWith("data:") ? ( +
+
+ {field.value && ( + // biome-ignore lint/performance/noImgElement: favicon preview from data URL + Favicon + )} +
+

+ Uploaded favicon +

+

+ Shown in the browser tab. +

+
+
+ + +
+
+
+ ) : ( +
+ + +
+ )} + { + 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 = ""; + }} + /> + +
+ + Paste a favicon URL or upload an image (max 512KB). Shown in + the browser tab. + +
+ )} + /> + ( + +
+ Custom CSS + +
+ + + + + Optional. Override theme colors using CSS variables. Use{" "} + :root for + light mode and{" "} + .dark for + dark mode. Values in HSL without "hsl()". + Don't use quotes around colors (e.g.{" "} + red, not + "red"). Max 8KB. + + + + + Available CSS variables + + +
+												{CSS_VARIABLES_REFERENCE}
+											
+
+
+
+ )} + /> + {form.watch("logoUrl") && ( +
+

+ Logo preview +

+ +
+ )} + + + + )} +
+ ); +}; diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index fff5413e0..7e5981141 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -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 (
@@ -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" > - - Dokploy + + {appName}
-

- “The Open Source alternative to Netlify, Vercel, - Heroku.” -

+

“{tagline}”

diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 4b3354ed8..78ee49573 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -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 + } />

- {activeOrganization?.name ?? "Select Organization"} + {activeOrganization?.name ?? + whitelabel?.appName ?? + "Select Organization"}

diff --git a/apps/dokploy/components/proprietary/whitelabel-head.tsx b/apps/dokploy/components/proprietary/whitelabel-head.tsx new file mode 100644 index 000000000..7d05bef31 --- /dev/null +++ b/apps/dokploy/components/proprietary/whitelabel-head.tsx @@ -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 ( + + {whitelabel?.appName && {whitelabel.appName}} + {whitelabel?.faviconUrl ? ( + + ) : ( + + )} + {whitelabel?.customCss?.trim() && ( + Dokploy + + - + {/* Default favicon; WhitelabelHead overrides with key="favicon" when custom favicon is set */}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx index 7917bd97c..e0aca3b9c 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx @@ -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 = ( /> - Application: {data?.name} - {data?.environment.project.name} | Dokploy + Application: {data?.name} - {data?.environment.project.name} |{" "} + {whitelabel?.appName ?? "Dokploy"}
@@ -165,7 +167,7 @@ const Service = ( : "destructive" } > - {data?.server?.name || "Dokploy Server"} + {data?.server?.name || `${whitelabel?.appName ?? "Dokploy"} Server`} {data?.server?.serverStatus === "inactive" && ( diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx index 1d6902c59..473322c92 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/compose/[composeId].tsx @@ -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 = ( /> - Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy + Compose: {data?.name} - {data?.environment?.project?.name} |{" "} + {whitelabel?.appName ?? "Dokploy"}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx index 0a1e8501d..0d3ec32f5 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mariadb/[mariadbId].tsx @@ -59,6 +59,7 @@ const Mariadb = ( const [tab, setSab] = useState(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 = (
- Database: {data?.name} - {data?.environment?.project?.name} | - Dokploy + Database: {data?.name} - {data?.environment?.project?.name} |{" "} + {whitelabel?.appName ?? "Dokploy"} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx index bae83cb2b..54f89e94b 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mongo/[mongoId].tsx @@ -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 = ( /> - Database: {data?.name} - {data?.environment?.project?.name} | Dokploy + Database: {data?.name} - {data?.environment?.project?.name} |{" "} + {whitelabel?.appName ?? "Dokploy"}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx index ba2b9d8a0..c9838196e 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/mysql/[mysqlId].tsx @@ -58,6 +58,7 @@ const MySql = ( const [tab, setSab] = useState(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 = (
- Database: {data?.name} - {data?.environment?.project?.name} | - Dokploy + Database: {data?.name} - {data?.environment?.project?.name} |{" "} + {whitelabel?.appName ?? "Dokploy"}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx index 1d90e3e13..e42ad92b0 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/postgres/[postgresId].tsx @@ -58,6 +58,7 @@ const Postgresql = ( const [tab, setSab] = useState(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 = ( /> - Database: {data?.name} - {data?.environment?.project?.name} | Dokploy + Database: {data?.name} - {data?.environment?.project?.name} |{" "} + {whitelabel?.appName ?? "Dokploy"}
diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx index 47eb82a74..d89c6e78d 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/redis/[redisId].tsx @@ -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 = ( /> - Database: {data?.name} - {data?.environment?.project?.name} | Dokploy + Database: {data?.name} - {data?.environment?.project?.name} |{" "} + {whitelabel?.appName ?? "Dokploy"}
diff --git a/apps/dokploy/pages/dashboard/settings/whitelabelling.tsx b/apps/dokploy/pages/dashboard/settings/whitelabelling.tsx new file mode 100644 index 000000000..34855ea48 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/whitelabelling.tsx @@ -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 ( +
+
+ +
+
+ + + +
+
+
+
+
+ ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => ( + {page} +); + +export async function getServerSideProps( + ctx: GetServerSidePropsContext>, +) { + 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"])), + }, + }; +} diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index de4294581..3d84ba4cd 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -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, }, }; } diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx index a6df106e7..6a9ec6722 100644 --- a/apps/dokploy/pages/invitation.tsx +++ b/apps/dokploy/pages/invitation.tsx @@ -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({ 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) { diff --git a/apps/dokploy/pages/send-reset-password.tsx b/apps/dokploy/pages/send-reset-password.tsx index 739c45cd8..1b32d9b2a 100644 --- a/apps/dokploy/pages/send-reset-password.tsx +++ b/apps/dokploy/pages/send-reset-password.tsx @@ -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(null); + const { data: whitelabel } = api.whitelabel.get.useQuery(); const [isLoading, setIsLoading] = useState(false); const _router = useRouter(); const form = useForm({ @@ -82,7 +84,9 @@ export default function Home() {
- Dokploy + + {whitelabel?.appName ?? "Dokploy"} + Reset Password diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index c8b4295fe..3a7410961 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -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, diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabel.ts b/apps/dokploy/server/api/routers/proprietary/whitelabel.ts new file mode 100644 index 000000000..154d26634 --- /dev/null +++ b/apps/dokploy/server/api/routers/proprietary/whitelabel.ts @@ -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, + }; + }), +}); diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index fe5cc5ad1..209bc80ef 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -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 diff --git a/packages/server/src/utils/crons/enterprise.ts b/packages/server/src/utils/crons/enterprise.ts index 7b07aaefb..a9026c641 100644 --- a/packages/server/src/utils/crons/enterprise.ts +++ b/packages/server/src/utils/crons/enterprise.ts @@ -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 () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 788d6c5fa..4d3865148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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