diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index ba09c2c80a..aebf5e5b5f 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -61,13 +61,27 @@ const baseSettings: WebServerSettings = { errorPageTitle: null, errorPageDescription: null, metaTitle: null, + metaDescription: null, + ogImageUrl: null, footerText: null, + passwordResetGuide: null, + supportEmail: null, + brandingEnabled: false, + appearanceEnabled: false, + metadataEnabled: false, + errorPagesEnabled: false, + forgotPasswordEnabled: false, }, cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, remoteServersOnly: false, enforceSSO: false, + hideHelpLinks: false, + hideSocialLinks: false, + hideSSOLogin: false, + showSSOInSidebar: true, + showWhitelabelingInSidebar: true, createdAt: null, updatedAt: new Date(), }; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-help-links.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-help-links.tsx new file mode 100644 index 0000000000..c79428bc5e --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-help-links.tsx @@ -0,0 +1,48 @@ +import { HelpCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; + +export const ToggleHideHelpLinks = () => { + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); + const { mutateAsync } = api.settings.updateHideHelpLinks.useMutation(); + + const handleToggle = async (checked: boolean) => { + try { + await mutateAsync({ hideHelpLinks: checked }); + await refetch(); + toast.success("Hide Help Links updated"); + } catch { + toast.error("Error updating Hide Help Links"); + } + }; + + return ( +
+ + + + + + + +

+ When enabled, the Documentation and Support links are hidden from + the sidebar. +

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-social-links.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-social-links.tsx new file mode 100644 index 0000000000..9f642ad2b2 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-social-links.tsx @@ -0,0 +1,51 @@ +import { HelpCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; + +export const ToggleHideSocialLinks = () => { + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); + const { mutateAsync } = api.settings.updateHideSocialLinks.useMutation(); + + const handleToggle = async (checked: boolean) => { + try { + await mutateAsync({ hideSocialLinks: checked }); + await refetch(); + toast.success("Hide Social Links updated"); + } catch { + toast.error("Error updating Hide Social Links"); + } + }; + + return ( +
+ + + + + + + +

+ When enabled, the GitHub, X, and Discord links are hidden from the + login and onboarding pages. +

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-sso-login.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-sso-login.tsx new file mode 100644 index 0000000000..fa3e247d6f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-hide-sso-login.tsx @@ -0,0 +1,48 @@ +import { HelpCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; + +export const ToggleHideSSOLogin = () => { + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); + const { mutateAsync } = api.settings.updateHideSSOLogin.useMutation(); + + const handleToggle = async (checked: boolean) => { + try { + await mutateAsync({ hideSSOLogin: checked }); + await refetch(); + toast.success("Hide SSO Login updated"); + } catch { + toast.error("Error updating Hide SSO Login"); + } + }; + + return ( +
+ + + + + + + +

+ When enabled, the "Sign in with SSO" option is hidden from the + login page. Has no effect when "Enforce SSO" is on. +

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-show-sso.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-show-sso.tsx new file mode 100644 index 0000000000..65cc62199c --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-show-sso.tsx @@ -0,0 +1,48 @@ +import { HelpCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; + +export const ToggleShowSSO = () => { + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); + const { mutateAsync } = api.settings.updateShowSSOInSidebar.useMutation(); + + const handleToggle = async (checked: boolean) => { + try { + await mutateAsync({ showSSOInSidebar: checked }); + await refetch(); + toast.success("Single Sign-On (SSO) updated"); + } catch { + toast.error("Error updating Single Sign-On (SSO)"); + } + }; + + return ( +
+ + + + + + + +

When enabled, the SSO settings appear in the sidebar.

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/servers/actions/toggle-show-whitelabeling.tsx b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-show-whitelabeling.tsx new file mode 100644 index 0000000000..7e213e71d9 --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/servers/actions/toggle-show-whitelabeling.tsx @@ -0,0 +1,51 @@ +import { HelpCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; + +export const ToggleShowWhitelabeling = () => { + const { data, refetch } = api.settings.getWebServerSettings.useQuery(); + const { mutateAsync } = + api.settings.updateShowWhitelabelingInSidebar.useMutation(); + + const handleToggle = async (checked: boolean) => { + try { + await mutateAsync({ showWhitelabelingInSidebar: checked }); + await refetch(); + toast.success("Whitelabeling (Branding) updated"); + } catch { + toast.error("Error updating Whitelabeling (Branding)"); + } + }; + + return ( +
+ + + + + + + +

+ When enabled, the Whitelabeling settings appear in the sidebar. +

+
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index c76c920fde..e5d34c22e8 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -39,43 +39,45 @@ export const OnboardingLayout = ({ children }: Props) => {
{children}
-
- - - -
+ strokeWidth="0" + viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg" + className="size-5" + > + + + + + + + )} ); diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index ba99df5db4..64a085646d 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -102,6 +102,9 @@ type EnabledOpts = { auth?: AuthQueryOutput; permissions?: PermissionsOutput; isCloud: boolean; + isEnterpriseActive?: boolean; + showSSOInSidebar?: boolean; + showWhitelabelingInSidebar?: boolean; }; type SingleNavItem = { @@ -410,16 +413,35 @@ const MENU: Menu = { title: "SSO", url: "/dashboard/settings/sso", icon: LogIn, - // Enabled for admins in both cloud and self-hosted (enterprise) - isEnabled: ({ permissions }) => !!permissions?.organization.update, + // Enabled for admins. On cloud SSO is always available; on self-hosted + // it requires an active enterprise license. Can also be hidden via the + // License page. + isEnabled: ({ + permissions, + isCloud, + isEnterpriseActive, + showSSOInSidebar, + }) => + !!permissions?.organization.update && + (isCloud || !!isEnterpriseActive) && + showSSOInSidebar !== false, }, { isSingle: true, title: "Whitelabeling", url: "/dashboard/settings/whitelabeling", icon: Palette, - // Only enabled for owners in non-cloud environments (enterprise) - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + // Only enabled for owners in non-cloud environments with an active + // enterprise license. Can be hidden from the sidebar via the License page. + isEnabled: ({ + auth, + isCloud, + isEnterpriseActive, + showWhitelabelingInSidebar, + }) => + !!(auth?.role === "owner" && !isCloud) && + !!isEnterpriseActive && + showWhitelabelingInSidebar !== false, }, ], @@ -449,6 +471,10 @@ function createMenuForAuthUser(opts: { docsUrl?: string | null; supportUrl?: string | null; } | null; + hideHelpLinks?: boolean; + isEnterpriseActive?: boolean; + showSSOInSidebar?: boolean; + showWhitelabelingInSidebar?: boolean; }): Menu { const filterEnabled = < T extends { @@ -464,19 +490,26 @@ function createMenuForAuthUser(opts: { auth: opts.auth, permissions: opts.permissions, isCloud: opts.isCloud, + isEnterpriseActive: opts.isEnterpriseActive, + showSSOInSidebar: opts.showSSOInSidebar, + showWhitelabelingInSidebar: opts.showWhitelabelingInSidebar, }), ) as T[]; - // Apply whitelabeling URL overrides to help items - const helpItems = filterEnabled(MENU.help).map((item) => { - if (opts.whitelabeling?.docsUrl && item.name === "Documentation") { - return { ...item, url: opts.whitelabeling.docsUrl }; - } - if (opts.whitelabeling?.supportUrl && item.name === "Support") { - return { ...item, url: opts.whitelabeling.supportUrl }; - } - return item; - }); + // "Hide Help Links" is an enterprise restriction — only apply it when the + // license is active, otherwise always show the help links. + const helpItems = + opts.hideHelpLinks && opts.isEnterpriseActive + ? [] + : filterEnabled(MENU.help).map((item) => { + if (opts.whitelabeling?.docsUrl && item.name === "Documentation") { + return { ...item, url: opts.whitelabeling.docsUrl }; + } + if (opts.whitelabeling?.supportUrl && item.name === "Support") { + return { ...item, url: opts.whitelabeling.supportUrl }; + } + return item; + }); return { home: filterEnabled(MENU.home), @@ -592,7 +625,9 @@ function SidebarLogo() { )} > {/* Organization Logo and Selector */} - +
@@ -625,17 +660,17 @@ function SidebarLogo() {
-

+

{activeOrganization?.name ?? "Select Organization"}

@@ -907,6 +942,10 @@ export default function Page({ children }: Props) { staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, }); + const { data: webServerSettings } = + api.settings.getWebServerSettings.useQuery(); + const { data: haveValidLicense } = + api.licenseKey.haveValidLicenseKey.useQuery(); const includesProjects = pathname?.includes("/dashboard/project"); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -919,7 +958,16 @@ export default function Page({ children }: Props) { auth, permissions, isCloud: !!isCloud, - whitelabeling, + whitelabeling: whitelabeling?.metadataEnabled + ? { + docsUrl: whitelabeling.docsUrl, + supportUrl: whitelabeling.supportUrl, + } + : null, + hideHelpLinks: !!webServerSettings?.hideHelpLinks, + isEnterpriseActive: !!haveValidLicense, + showSSOInSidebar: webServerSettings?.showSSOInSidebar, + showWhitelabelingInSidebar: webServerSettings?.showWhitelabelingInSidebar, }); const activeItem = findActiveNavItem( @@ -943,8 +991,8 @@ export default function Page({ children }: Props) { }} style={ { - "--sidebar-width": "19.5rem", - "--sidebar-width-mobile": "19.5rem", + "--sidebar-width": "15rem", + "--sidebar-width-mobile": "18rem", } as React.CSSProperties } > @@ -1137,28 +1185,30 @@ export default function Page({ children }: Props) { })} - - Extra - - {help.map((item: ExternalLink) => ( - - - - - - - {item.name} - - - - ))} - - + {help.length > 0 && ( + + Extra + + {help.map((item: ExternalLink) => ( + + + + + + + {item.name} + + + + ))} + + + )} diff --git a/apps/dokploy/components/proprietary/license-keys/license-feature-settings.tsx b/apps/dokploy/components/proprietary/license-keys/license-feature-settings.tsx new file mode 100644 index 0000000000..652afdd7e7 --- /dev/null +++ b/apps/dokploy/components/proprietary/license-keys/license-feature-settings.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { ToggleEnforceSSO } from "@/components/dashboard/settings/servers/actions/toggle-enforce-sso"; +import { ToggleHideHelpLinks } from "@/components/dashboard/settings/servers/actions/toggle-hide-help-links"; +import { ToggleHideSocialLinks } from "@/components/dashboard/settings/servers/actions/toggle-hide-social-links"; +import { ToggleHideSSOLogin } from "@/components/dashboard/settings/servers/actions/toggle-hide-sso-login"; +import { ToggleRemoteServersOnly } from "@/components/dashboard/settings/servers/actions/toggle-remote-servers-only"; +import { ToggleShowSSO } from "@/components/dashboard/settings/servers/actions/toggle-show-sso"; +import { ToggleShowWhitelabeling } from "@/components/dashboard/settings/servers/actions/toggle-show-whitelabeling"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { api } from "@/utils/api"; + +/** + * Enterprise feature controls shown on the License page once a valid license is + * active. Self-hosted only (these settings don't apply to Dokploy Cloud). + */ +export function LicenseFeatureSettings() { + const { data: haveValidLicense } = + api.licenseKey.haveValidLicenseKey.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + + // Only relevant for self-hosted instances with an active license. + if (isCloud || !haveValidLicense) { + return null; + } + + return ( + <> + +
+ + Features + + Enable or disable enterprise features and control whether they + appear in the sidebar. + + + + + + +
+
+ + +
+ + Self-hosted Restrictions + + Control deployment targets and authentication behavior. + + + + + + + + + +
+
+ + ); +} diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx index abbaca4a58..c9c886ca05 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-settings.tsx @@ -3,6 +3,7 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import { Loader2, RotateCcw } from "lucide-react"; import { useEffect } from "react"; +import type { Control } from "react-hook-form"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -26,6 +27,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/utils/api"; import { WhitelabelingPreview } from "./whitelabeling-preview"; @@ -36,6 +38,12 @@ const safeUrlField = z message: "Only http:// and https:// URLs are allowed", }); +const safeEmailField = z + .string() + .refine((val) => val === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val), { + message: "Enter a valid email address", + }); + const formSchema = z.object({ appName: z.string(), appDescription: z.string(), @@ -48,11 +56,66 @@ const formSchema = z.object({ errorPageTitle: z.string(), errorPageDescription: z.string(), metaTitle: z.string(), + metaDescription: z.string(), + ogImageUrl: safeUrlField, footerText: z.string(), + passwordResetGuide: z.string(), + supportEmail: safeEmailField, + brandingEnabled: z.boolean(), + appearanceEnabled: z.boolean(), + metadataEnabled: z.boolean(), + errorPagesEnabled: z.boolean(), + forgotPasswordEnabled: z.boolean(), }); type FormSchema = z.infer; +type SectionToggleName = + | "brandingEnabled" + | "appearanceEnabled" + | "metadataEnabled" + | "errorPagesEnabled" + | "forgotPasswordEnabled"; + +/** + * Card header with the section title/description on the left and an + * enable/disable switch on the right. Section fields are rendered only when the + * switch is on (handled by the caller). + */ +function SectionToggleHeader({ + control, + name, + title, + description, +}: { + control: Control; + name: SectionToggleName; + title: string; + description: string; +}) { + return ( + +
+
+ {title} + {description} +
+ ( + + )} + /> +
+
+ ); +} + const DEFAULT_CSS_TEMPLATE = `/* ============================================ Dokploy Default Theme - CSS Variables Modify these values to customize your instance. @@ -186,7 +249,16 @@ export function WhitelabelingSettings() { errorPageTitle: "", errorPageDescription: "", metaTitle: "", + metaDescription: "", + ogImageUrl: "", footerText: "", + passwordResetGuide: "", + supportEmail: "", + brandingEnabled: false, + appearanceEnabled: false, + metadataEnabled: false, + errorPagesEnabled: false, + forgotPasswordEnabled: false, }, resolver: zodResolver(formSchema), }); @@ -205,7 +277,16 @@ export function WhitelabelingSettings() { errorPageTitle: data.errorPageTitle ?? "", errorPageDescription: data.errorPageDescription ?? "", metaTitle: data.metaTitle ?? "", + metaDescription: data.metaDescription ?? "", + ogImageUrl: data.ogImageUrl ?? "", footerText: data.footerText ?? "", + passwordResetGuide: data.passwordResetGuide ?? "", + supportEmail: data.supportEmail ?? "", + brandingEnabled: data.brandingEnabled ?? false, + appearanceEnabled: data.appearanceEnabled ?? false, + metadataEnabled: data.metadataEnabled ?? false, + errorPagesEnabled: data.errorPagesEnabled ?? false, + forgotPasswordEnabled: data.forgotPasswordEnabled ?? false, }); } }, [data, form]); @@ -235,7 +316,16 @@ export function WhitelabelingSettings() { errorPageTitle: values.errorPageTitle || null, errorPageDescription: values.errorPageDescription || null, metaTitle: values.metaTitle || null, + metaDescription: values.metaDescription || null, + ogImageUrl: values.ogImageUrl || null, footerText: values.footerText || null, + passwordResetGuide: values.passwordResetGuide || null, + supportEmail: values.supportEmail || null, + brandingEnabled: values.brandingEnabled, + appearanceEnabled: values.appearanceEnabled, + metadataEnabled: values.metadataEnabled, + errorPagesEnabled: values.errorPagesEnabled, + forgotPasswordEnabled: values.forgotPasswordEnabled, }, }) .then(async () => { @@ -243,6 +333,9 @@ export function WhitelabelingSettings() { await refetch(); await utils.whitelabeling.getPublic.invalidate(); await utils.whitelabeling.get.invalidate(); + // Branding (title, favicon, custom CSS) is rendered server-side, so + // reload to apply the new values across the app immediately. + window.location.reload(); }) .catch((error) => { toast.error( @@ -258,6 +351,8 @@ export function WhitelabelingSettings() { await refetch(); await utils.whitelabeling.getPublic.invalidate(); await utils.whitelabeling.get.invalidate(); + // Branding is rendered server-side, so reload to apply the reset. + window.location.reload(); }) .catch((error) => { toast.error(error?.message || "Failed to reset whitelabeling settings"); @@ -273,292 +368,403 @@ export function WhitelabelingSettings() { > {/* Branding Section */} - - Branding - - Customize the application name, logos, and favicon to match your - brand identity. - - - - ( - - Application Name - - - - - Replaces "Dokploy" across the entire interface. - - - - )} - /> - - ( - - Application Description - - - - - Tagline shown on the login/onboarding pages. Defaults to - the standard Dokploy description if empty. - - - - )} - /> - - ( - - Logo URL - - - - - Main logo shown in the sidebar and header. Recommended - size: 128x128px. - - - - )} - /> - - ( - - Login Page Logo URL - - - - - Logo displayed on the login page. If empty, the main logo - is used. - - - - )} - /> - - ( - - Favicon URL - - - - - Browser tab icon. Supports .ico, .png, and .svg formats. - - - - )} - /> - + + {form.watch("brandingEnabled") && ( + + ( + + Application Name + + + + + Replaces "Dokploy" across the entire interface. + + + + )} + /> + + ( + + Application Description + + + + + Tagline shown on the login/onboarding pages. Defaults to + the standard Dokploy description if empty. + + + + )} + /> + + ( + + Logo URL + + + + + Main logo shown in the sidebar and header. Recommended + size: 128x128px. + + + + )} + /> + + ( + + Login Page Logo URL + + + + + Logo displayed on the login page. If empty, the main + logo is used. + + + + )} + /> + + ( + + Favicon URL + + + + + Browser tab icon. Supports .ico, .png, and .svg formats. + + + + )} + /> + + )} - {/* Appearance Section */} + {/* Metadata & Links Section */} - - Appearance - - Customize the look and feel of the application with custom CSS. - - - - ( - -
- Custom CSS - -
- -
- + {form.watch("metadataEnabled") && ( + + ( + + Page Title + + + + + Browser tab title and social share title (og:title). + Defaults to "Dokploy" if empty. + + + + )} + /> + + ( + + Meta Description + +