diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index fff5413e0..13dcb6bad 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -4,21 +4,35 @@ import { cn } from "@/lib/utils"; import { GithubIcon } from "../icons/data-tools-icons"; import { Logo } from "../shared/logo"; import { Button } from "../ui/button"; +import { api } from "@/utils/api"; interface Props { children: React.ReactNode; } export const OnboardingLayout = ({ children }: Props) => { + const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery(); + const appName = whitelabel?.whitelabelAppName ?? "Dokploy"; + const logoUrl = + whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl; + return (
+ {whitelabel?.whitelabelLoginBackgroundImageUrl && ( +
+ )} - - Dokploy + + {appName}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 204bf9695..ee4372e24 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -21,8 +21,10 @@ import { Key, KeyRound, Loader2, + LogIn, type LucideIcon, Package, + Palette, PieChart, Server, ShieldCheck, @@ -30,7 +32,6 @@ import { Trash2, User, Users, - LogIn, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; @@ -416,6 +417,15 @@ const MENU: Menu = { isEnabled: ({ auth }) => !!(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: [ @@ -546,6 +556,7 @@ function SidebarLogo() { refetch, isLoading, } = api.organization.all.useQuery(); + const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery(); const { mutateAsync: deleteOrganization, isLoading: isRemoving } = api.organization.delete.useMutation(); const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } = @@ -611,7 +622,11 @@ function SidebarLogo() { "transition-all", state === "collapsed" ? "size-4" : "size-5", )} - logoUrl={activeOrganization?.logo || undefined} + logoUrl={ + activeOrganization?.logo || + whitelabel?.whitelabelLogoUrl || + undefined + } />

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

diff --git a/apps/dokploy/components/proprietary/whitelabelling/whitelabel-settings.tsx b/apps/dokploy/components/proprietary/whitelabelling/whitelabel-settings.tsx new file mode 100644 index 000000000..692fb8825 --- /dev/null +++ b/apps/dokploy/components/proprietary/whitelabelling/whitelabel-settings.tsx @@ -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; + +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({ + 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 ( +
+ + + Loading whitelabel settings... + +
+ ); + } + + return ( +
+
+
+ + Whitelabeling +
+ + Customize the application name, logos, and login page for your brand. + Leave URLs empty to use defaults. + +
+ +
+ +
+
+

Brand

+

+ Application name and main logo (sidebar, header). +

+
+ ( + + Application name + + + + + + )} + /> + ( + + Logo URL + + + + + Logo shown in the sidebar and header. + + + + )} + /> + ( + + Favicon URL + + + + + + )} + /> +
+ +
+
+

Login page

+

+ Customize the sign-in and registration screens. +

+
+ ( + + Login logo URL + + + + + Logo on the login and register pages. Falls back to the main + logo if empty. + + + + )} + /> + ( + + Login title + + + + + + )} + /> + ( + + Login subtitle + + + + + + )} + /> + ( + + Login background image URL + + + + + Optional background image for the login page. + + + + )} + /> +
+ +
+ +
+
+ +
+ ); +} diff --git a/apps/dokploy/pages/dashboard/settings/whitelabelling.tsx b/apps/dokploy/pages/dashboard/settings/whitelabelling.tsx new file mode 100644 index 000000000..f8f3f9e10 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/whitelabelling.tsx @@ -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 ( +
+
+ +
+
+ + + +
+
+
+
+
+ ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +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"])), + }, + }; +} diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index de4294581..cf99b60ce 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -59,6 +59,7 @@ interface Props { export default function Home({ IS_CLOUD }: Props) { const router = useRouter(); const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery(); + const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery(); const [isLoginLoading, setIsLoginLoading] = useState(false); const [isTwoFactorLoading, setIsTwoFactorLoading] = 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 ( <>

- - Sign in + + {loginTitle}

- Enter your email and password to sign in + {loginSubtitle}

{error && ( diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index c9d21e515..ef2999dfd 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -63,6 +63,7 @@ import { apiServerSchema, apiTraefikConfig, apiUpdateDockerCleanup, + apiUpdateWhitelabel, projects, server, } from "@/server/db/schema"; @@ -72,6 +73,7 @@ import { appRouter } from "../root"; import { adminProcedure, createTRPCRouter, + enterpriseProcedure, protectedProcedure, publicProcedure, } from "../trpc"; @@ -84,6 +86,57 @@ export const settingsRouter = createTRPCRouter({ const settings = await getWebServerSettings(); 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 = {}; + 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 () => { if (IS_CLOUD) { return true; diff --git a/packages/server/src/db/schema/web-server-settings.ts b/packages/server/src/db/schema/web-server-settings.ts index fe5cc5ad1..3dca33e3b 100644 --- a/packages/server/src/db/schema/web-server-settings.ts +++ b/packages/server/src/db/schema/web-server-settings.ts @@ -76,6 +76,14 @@ export const webServerSettings = pgTable("webServerSettings", { cleanupCacheOnCompose: boolean("cleanupCacheOnCompose") .notNull() .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(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); @@ -125,6 +133,18 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({ cleanupCacheApplications: z.boolean().optional(), cleanupCacheOnPreviews: 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 @@ -154,6 +174,21 @@ export const apiUpdateDockerCleanup = z.object({ 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({ metricsConfig: z .object({