mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
feat: implement whitelabeling features including settings, preview, and provider components
This commit is contained in:
@@ -48,6 +48,21 @@ const baseSettings: WebServerSettings = {
|
||||
urlCallback: "",
|
||||
},
|
||||
},
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
primaryColor: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
|
||||
@@ -45,10 +45,12 @@ import {
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type User = typeof authClient.$Infer.Session.user;
|
||||
|
||||
export const ImpersonationBar = () => {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
@@ -180,7 +182,10 @@ export const ImpersonationBar = () => {
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
|
||||
<Logo className="w-10 h-10" />
|
||||
<Logo
|
||||
className="w-10 h-10"
|
||||
logoUrl={whitelabeling?.logoUrl || undefined}
|
||||
/>
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
import { GithubIcon } from "../icons/data-tools-icons";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
@@ -9,23 +10,28 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const appDescription =
|
||||
whitelabeling?.appDescription ||
|
||||
"\u201CThe Open Source alternative to Netlify, Vercel, Heroku.\u201D";
|
||||
const logoUrl =
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined;
|
||||
|
||||
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">
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
href="/"
|
||||
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">
|
||||
“The Open Source alternative to Netlify, Vercel,
|
||||
Heroku.”
|
||||
</p>
|
||||
<p className="text-lg text-primary">{appDescription}</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Palette,
|
||||
Package,
|
||||
PieChart,
|
||||
Rocket,
|
||||
@@ -422,6 +423,15 @@ const MENU: Menu = {
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
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),
|
||||
},
|
||||
],
|
||||
|
||||
help: [
|
||||
@@ -445,38 +455,33 @@ const MENU: Menu = {
|
||||
function createMenuForAuthUser(opts: {
|
||||
auth?: AuthQueryOutput;
|
||||
isCloud: boolean;
|
||||
whitelabeling?: {
|
||||
docsUrl?: string | null;
|
||||
supportUrl?: string | null;
|
||||
} | null;
|
||||
}): Menu {
|
||||
const filterEnabled = <T extends { isEnabled?: (o: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean }>(items: readonly T[]): T[] =>
|
||||
items.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({ auth: opts.auth, isCloud: opts.isCloud }),
|
||||
) 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;
|
||||
});
|
||||
|
||||
return {
|
||||
// Filter the home items based on the user's role and permissions
|
||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
||||
home: MENU.home.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({
|
||||
auth: opts.auth,
|
||||
isCloud: opts.isCloud,
|
||||
}),
|
||||
),
|
||||
// Filter the settings items based on the user's role and permissions
|
||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
||||
settings: MENU.settings.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({
|
||||
auth: opts.auth,
|
||||
isCloud: opts.isCloud,
|
||||
}),
|
||||
),
|
||||
// Filter the help items based on the user's role and permissions
|
||||
// Calls the `isEnabled` function if it exists to determine if the item should be displayed
|
||||
help: MENU.help.filter((item) =>
|
||||
!item.isEnabled
|
||||
? true
|
||||
: item.isEnabled({
|
||||
auth: opts.auth,
|
||||
isCloud: opts.isCloud,
|
||||
}),
|
||||
),
|
||||
home: filterEnabled(MENU.home),
|
||||
settings: filterEnabled(MENU.settings),
|
||||
help: helpItems,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -885,6 +890,10 @@ export default function Page({ children }: Props) {
|
||||
const pathname = usePathname();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
const { data: whitelabeling } = api.whitelabeling.getPublic.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const includesProjects = pathname?.includes("/dashboard/project");
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
@@ -893,7 +902,7 @@ export default function Page({ children }: Props) {
|
||||
home: filteredHome,
|
||||
settings: filteredSettings,
|
||||
help,
|
||||
} = createMenuForAuthUser({ auth, isCloud: !!isCloud });
|
||||
} = createMenuForAuthUser({ auth, isCloud: !!isCloud, whitelabeling });
|
||||
|
||||
const activeItem = findActiveNavItem(
|
||||
[...filteredHome, ...filteredSettings],
|
||||
@@ -1141,6 +1150,11 @@ export default function Page({ children }: Props) {
|
||||
<SidebarMenuItem>
|
||||
<UserNav />
|
||||
</SidebarMenuItem>
|
||||
{whitelabeling?.footerText && (
|
||||
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||
{whitelabeling.footerText}
|
||||
</div>
|
||||
)}
|
||||
{dokployVersion && (
|
||||
<>
|
||||
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface WhitelabelingPreviewProps {
|
||||
config: {
|
||||
appName?: string;
|
||||
logoUrl?: string;
|
||||
primaryColor?: string;
|
||||
footerText?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function WhitelabelingPreview({ config }: WhitelabelingPreviewProps) {
|
||||
const appName = config.appName || "Dokploy";
|
||||
const primaryColor = config.primaryColor || "hsl(var(--primary))";
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Live Preview</CardTitle>
|
||||
<CardDescription>
|
||||
A quick preview of how your branding changes will look.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
{/* Simulated sidebar header */}
|
||||
<div className="flex items-center gap-3 p-4 border-b bg-sidebar">
|
||||
{config.logoUrl ? (
|
||||
<img
|
||||
src={config.logoUrl}
|
||||
alt="Preview Logo"
|
||||
className="size-8 rounded-sm object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="size-8 rounded-sm flex items-center justify-center text-white font-bold text-sm"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
{appName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="font-semibold text-sm">{appName}</span>
|
||||
</div>
|
||||
|
||||
{/* Simulated content area */}
|
||||
<div className="p-4 bg-background">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div
|
||||
className="h-2 w-16 rounded-full"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
/>
|
||||
<div className="h-2 w-24 rounded-full bg-muted" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="px-3 py-1.5 rounded-md text-xs text-white font-medium"
|
||||
style={{ backgroundColor: primaryColor }}
|
||||
>
|
||||
Button
|
||||
</div>
|
||||
<div className="px-3 py-1.5 rounded-md text-xs border font-medium">
|
||||
Secondary
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simulated footer */}
|
||||
{config.footerText && (
|
||||
<div className="px-4 py-2 border-t text-xs text-muted-foreground text-center bg-sidebar">
|
||||
{config.footerText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Head from "next/head";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function WhitelabelingProvider() {
|
||||
const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
{config.metaTitle && <title>{config.metaTitle}</title>}
|
||||
{config.faviconUrl && <link rel="icon" href={config.faviconUrl} />}
|
||||
</Head>
|
||||
|
||||
{(config.customCss || config.primaryColor) && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: [
|
||||
config.primaryColor
|
||||
? `:root { --primary: ${hexToHSL(config.primaryColor)}; }`
|
||||
: "",
|
||||
config.customCss || "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a hex color string to HSL values (without the hsl() wrapper)
|
||||
* matching the format used by shadcn/ui CSS variables (e.g., "262 83% 58%")
|
||||
*/
|
||||
function hexToHSL(hex: string): string {
|
||||
// Remove # prefix if present
|
||||
const cleanHex = hex.replace(/^#/, "");
|
||||
|
||||
if (
|
||||
!/^[0-9a-fA-F]{6}$/.test(cleanHex) &&
|
||||
!/^[0-9a-fA-F]{3}$/.test(cleanHex)
|
||||
) {
|
||||
return hex; // Return as-is if not a valid hex color (might be HSL already)
|
||||
}
|
||||
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (cleanHex.length === 3) {
|
||||
r = Number.parseInt(cleanHex[0]! + cleanHex[0], 16);
|
||||
g = Number.parseInt(cleanHex[1]! + cleanHex[1], 16);
|
||||
b = Number.parseInt(cleanHex[2]! + cleanHex[2], 16);
|
||||
} else {
|
||||
r = Number.parseInt(cleanHex.slice(0, 2), 16);
|
||||
g = Number.parseInt(cleanHex.slice(2, 4), 16);
|
||||
b = Number.parseInt(cleanHex.slice(4, 6), 16);
|
||||
}
|
||||
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
if (max === r) {
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
} else if (max === g) {
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
} else {
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
}
|
||||
}
|
||||
|
||||
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { WhitelabelingPreview } from "./whitelabeling-preview";
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string(),
|
||||
appDescription: z.string(),
|
||||
logoUrl: z.string(),
|
||||
faviconUrl: z.string(),
|
||||
customCss: z.string(),
|
||||
loginLogoUrl: z.string(),
|
||||
errorPageTitle: z.string(),
|
||||
errorPageDescription: z.string(),
|
||||
metaTitle: z.string(),
|
||||
footerText: z.string(),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
const DEFAULT_CSS_TEMPLATE = `/* ============================================
|
||||
Dokploy Default Theme - CSS Variables
|
||||
Modify these values to customize your instance.
|
||||
============================================ */
|
||||
|
||||
/* ---------- Light Mode ---------- */
|
||||
:root {
|
||||
--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;
|
||||
|
||||
/* Sidebar */
|
||||
--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%;
|
||||
|
||||
/* Charts */
|
||||
--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%;
|
||||
}
|
||||
|
||||
/* ---------- Dark Mode ---------- */
|
||||
.dark {
|
||||
--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%;
|
||||
|
||||
/* Sidebar */
|
||||
--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%;
|
||||
|
||||
/* Charts */
|
||||
--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%;
|
||||
}
|
||||
|
||||
/* ---------- Custom Styles ---------- */
|
||||
/* Add your own CSS rules below */
|
||||
`;
|
||||
|
||||
export function WhitelabelingSettings() {
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
data,
|
||||
isPending: isLoading,
|
||||
refetch,
|
||||
} = api.whitelabeling.get.useQuery();
|
||||
|
||||
const { mutateAsync: updateWhitelabeling, isPending: isUpdating } =
|
||||
api.whitelabeling.update.useMutation();
|
||||
|
||||
const { mutateAsync: resetWhitelabeling, isPending: isResetting } =
|
||||
api.whitelabeling.reset.useMutation();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
defaultValues: {
|
||||
appName: "",
|
||||
appDescription: "",
|
||||
logoUrl: "",
|
||||
faviconUrl: "",
|
||||
customCss: "",
|
||||
loginLogoUrl: "",
|
||||
errorPageTitle: "",
|
||||
errorPageDescription: "",
|
||||
metaTitle: "",
|
||||
footerText: "",
|
||||
},
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
appName: data.appName ?? "",
|
||||
appDescription: data.appDescription ?? "",
|
||||
logoUrl: data.logoUrl ?? "",
|
||||
faviconUrl: data.faviconUrl ?? "",
|
||||
customCss: data.customCss ?? "",
|
||||
loginLogoUrl: data.loginLogoUrl ?? "",
|
||||
errorPageTitle: data.errorPageTitle ?? "",
|
||||
errorPageDescription: data.errorPageDescription ?? "",
|
||||
metaTitle: data.metaTitle ?? "",
|
||||
footerText: data.footerText ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading whitelabeling settings...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const onSubmit = async (values: FormSchema) => {
|
||||
await updateWhitelabeling({
|
||||
whitelabelingConfig: {
|
||||
appName: values.appName || null,
|
||||
appDescription: values.appDescription || null,
|
||||
logoUrl: values.logoUrl || null,
|
||||
faviconUrl: values.faviconUrl || null,
|
||||
primaryColor: null,
|
||||
customCss: values.customCss || null,
|
||||
loginLogoUrl: values.loginLogoUrl || null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: values.errorPageTitle || null,
|
||||
errorPageDescription: values.errorPageDescription || null,
|
||||
metaTitle: values.metaTitle || null,
|
||||
footerText: values.footerText || null,
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Whitelabeling settings updated");
|
||||
await refetch();
|
||||
await utils.whitelabeling.getPublic.invalidate();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message || "Failed to update whitelabeling settings",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
await resetWhitelabeling()
|
||||
.then(async () => {
|
||||
toast.success("Whitelabeling settings reset to defaults");
|
||||
await refetch();
|
||||
await utils.whitelabeling.getPublic.invalidate();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error?.message || "Failed to reset whitelabeling settings");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{/* Branding Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Branding</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the application name, logos, and favicon to match your
|
||||
brand identity.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Replaces "Dokploy" across the entire interface.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="The Open Source alternative to Netlify, Vercel, Heroku."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Tagline shown on the login/onboarding pages. Defaults to
|
||||
the standard Dokploy description if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Main logo shown in the sidebar and header. Recommended
|
||||
size: 128x128px.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="loginLogoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login Page Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo.svg"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Logo displayed on the login page. If empty, the main logo
|
||||
is used.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab icon. Supports .ico, .png, and .svg formats.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the look and feel of the application with custom CSS.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCss"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Custom CSS</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("customCss", DEFAULT_CSS_TEMPLATE);
|
||||
}}
|
||||
>
|
||||
Load Default Styles
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="max-h-[350px] overflow-auto">
|
||||
<CodeEditor
|
||||
language="css"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="/* Click 'Load Default Styles' to start with the base theme variables */"
|
||||
lineWrapping
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Inject custom CSS styles globally. Click "Load Default
|
||||
Styles" to get the base theme CSS variables as a starting
|
||||
point.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata & Links Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the page title and footer text.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metaTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Dokploy" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Browser tab title. Defaults to "Dokploy" if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="footerText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Footer Text</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Powered by Your Company" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Custom text displayed in the footer area.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Pages Section */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Error Pages</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the error page messages shown to users.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="errorPageTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Error Page Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Something went wrong" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="errorPageDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Error Page Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="We're sorry, but an unexpected error occurred. Please try again later."
|
||||
className="min-h-[80px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogAction
|
||||
title="Reset Whitelabeling"
|
||||
description="Are you sure you want to reset all whitelabeling settings to their defaults? This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<Button variant="outline" type="button" isLoading={isResetting}>
|
||||
<RotateCcw className="size-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
<Button type="submit" isLoading={isUpdating} disabled={isUpdating}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* Live Preview */}
|
||||
<WhitelabelingPreview config={form.watch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -131,7 +132,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;
|
||||
}
|
||||
@@ -162,6 +163,8 @@ export const CodeEditor = ({
|
||||
? yaml()
|
||||
: language === "json"
|
||||
? json()
|
||||
: language === "css"
|
||||
? css()
|
||||
: language === "shell"
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define(properties),
|
||||
|
||||
1
apps/dokploy/drizzle/0148_strong_karma.sql
Normal file
1
apps/dokploy/drizzle/0148_strong_karma.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelingConfig" jsonb DEFAULT '{"appName":null,"logoUrl":null,"faviconUrl":null,"primaryColor":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"metaTitle":null,"footerText":null}'::jsonb;
|
||||
1
apps/dokploy/drizzle/0149_omniscient_bug.sql
Normal file
1
apps/dokploy/drizzle/0149_omniscient_bug.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ALTER COLUMN "whitelabelingConfig" SET DEFAULT '{"appName":null,"appDescription":null,"logoUrl":null,"faviconUrl":null,"primaryColor":null,"customCss":null,"loginLogoUrl":null,"supportUrl":null,"docsUrl":null,"errorPageTitle":null,"errorPageDescription":null,"metaTitle":null,"footerText":null}'::jsonb;
|
||||
7467
apps/dokploy/drizzle/meta/0148_snapshot.json
Normal file
7467
apps/dokploy/drizzle/meta/0148_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7467
apps/dokploy/drizzle/meta/0149_snapshot.json
Normal file
7467
apps/dokploy/drizzle/meta/0149_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1037,6 +1037,20 @@
|
||||
"when": 1771830695385,
|
||||
"tag": "0147_right_lake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 148,
|
||||
"version": "7",
|
||||
"when": 1773039642819,
|
||||
"tag": "0148_strong_karma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 149,
|
||||
"version": "7",
|
||||
"when": 1773064075434,
|
||||
"tag": "0149_omniscient_bug",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -39,8 +39,6 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^6.0.2",
|
||||
"@better-auth/sso": "1.5.0-beta.16",
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
"@ai-sdk/azure": "^3.0.30",
|
||||
"@ai-sdk/cohere": "^3.0.21",
|
||||
@@ -48,7 +46,9 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@better-auth/sso": "1.5.0-beta.16",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
@@ -103,7 +103,6 @@
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.67.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
@@ -140,6 +139,9 @@
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-markdown": "^9.1.0",
|
||||
"recharts": "^2.15.3",
|
||||
"resend": "^6.0.2",
|
||||
"semver": "7.7.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "1.15.0",
|
||||
@@ -155,12 +157,9 @@
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"zod": "^4.3.6",
|
||||
"zod-form-data": "^3.0.1",
|
||||
"semver": "7.7.3"
|
||||
"zod-form-data": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
@@ -172,6 +171,8 @@
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/ssh2": "1.15.1",
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
|
||||
@@ -8,6 +8,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 { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -48,6 +49,7 @@ const MyApp = ({
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<WhitelabelingProvider />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextPageContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
interface Props {
|
||||
statusCode: number;
|
||||
@@ -10,18 +11,20 @@ interface Props {
|
||||
|
||||
export default function Custom404({ statusCode, error }: Props) {
|
||||
const displayStatusCode = statusCode || 400;
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const logoUrl = whitelabeling?.logoUrl || undefined;
|
||||
const errorTitle = whitelabeling?.errorPageTitle;
|
||||
const errorDescription = whitelabeling?.errorPageDescription;
|
||||
|
||||
return (
|
||||
<div className="h-screen">
|
||||
<div className="max-w-[50rem] flex flex-col mx-auto size-full">
|
||||
<header className="mb-auto flex justify-center z-50 w-full py-4">
|
||||
<nav className="px-4 sm:px-6 lg:px-8" aria-label="Global">
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<Logo />
|
||||
<span className="font-medium text-sm">Dokploy</span>
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo logoUrl={logoUrl} />
|
||||
<span className="font-medium text-sm">{appName}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
@@ -30,19 +33,18 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
<h1 className="block text-7xl font-bold text-primary sm:text-9xl">
|
||||
{displayStatusCode}
|
||||
</h1>
|
||||
{/* <AlertBlock className="max-w-xs mx-auto">
|
||||
<p className="text-muted-foreground">
|
||||
Oops, something went wrong.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Sorry, we couldn't find your page.
|
||||
</p>
|
||||
</AlertBlock> */}
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
{statusCode === 404
|
||||
? "Sorry, we couldn't find your page."
|
||||
: "Oops, something went wrong."}
|
||||
{errorTitle
|
||||
? errorTitle
|
||||
: statusCode === 404
|
||||
? "Sorry, we couldn't find your page."
|
||||
: "Oops, something went wrong."}
|
||||
</p>
|
||||
{errorDescription && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
{errorDescription}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-3 text-red-500">
|
||||
<p>{error.message}</p>
|
||||
@@ -80,13 +82,17 @@ export default function Custom404({ statusCode, error }: Props) {
|
||||
<footer className="mt-auto text-center py-5">
|
||||
<div className="max-w-[85rem] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<p className="text-sm text-gray-500">
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/issues"
|
||||
target="_blank"
|
||||
className="underline hover:text-primary transition-colors"
|
||||
>
|
||||
Submit Log in issue on Github
|
||||
</Link>
|
||||
{whitelabeling?.footerText ? (
|
||||
whitelabeling.footerText
|
||||
) : (
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/issues"
|
||||
target="_blank"
|
||||
className="underline hover:text-primary transition-colors"
|
||||
>
|
||||
Submit Log in issue on Github
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
export type Services = {
|
||||
serverId?: string | null;
|
||||
@@ -370,6 +371,8 @@ const EnvironmentPage = (
|
||||
{ projectId: selectedTargetProject },
|
||||
{ enabled: !!selectedTargetProject },
|
||||
);
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
|
||||
const emptyServices =
|
||||
!currentEnvironment ||
|
||||
@@ -871,7 +874,8 @@ const EnvironmentPage = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Environment: {currentEnvironment.name} | {projectData?.name} | Dokploy
|
||||
Environment: {currentEnvironment.name} | {projectData?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState =
|
||||
| "projects"
|
||||
@@ -95,6 +96,8 @@ const Service = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.project?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -122,7 +125,8 @@ const Service = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Application: {data?.name} - {data?.environment.project.name} | Dokploy
|
||||
Application: {data?.name} - {data?.environment.project.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState =
|
||||
| "projects"
|
||||
@@ -84,6 +85,8 @@ const Service = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -111,7 +114,7 @@ const Service = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} | {appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -65,6 +66,8 @@ const Mariadb = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -94,7 +97,7 @@ const Mariadb = (
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -64,6 +65,8 @@ const Mongo = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -91,7 +94,8 @@ const Mongo = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -63,6 +64,8 @@ const MySql = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -92,7 +95,7 @@ const MySql = (
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -45,6 +45,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "backups" | "advanced";
|
||||
|
||||
@@ -63,6 +64,8 @@ const Postgresql = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -90,7 +93,8 @@ const Postgresql = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -44,6 +44,7 @@ import { UseKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
type TabState = "projects" | "monitoring" | "settings" | "advanced";
|
||||
|
||||
@@ -63,6 +64,8 @@ const Redis = (
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
});
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const environmentDropdownItems =
|
||||
environments?.map((env) => ({
|
||||
name: env.name,
|
||||
@@ -90,7 +93,8 @@ const Redis = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{appName}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
81
apps/dokploy/pages/dashboard/settings/whitelabeling.tsx
Normal file
81
apps/dokploy/pages/dashboard/settings/whitelabeling.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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 { WhitelabelingSettings } from "@/components/proprietary/whitelabeling/whitelabeling-settings";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="p-6">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Enterprise Whitelabeling",
|
||||
description:
|
||||
"Whitelabeling allows you to fully customize logos, colors, CSS, error pages, and more. Add a valid license to configure it.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<WhitelabelingSettings />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role !== "owner") {
|
||||
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: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -58,6 +59,7 @@ interface Props {
|
||||
}
|
||||
export default function Home({ IS_CLOUD }: Props) {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
@@ -216,7 +218,14 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Logo className="size-12" />
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
Sign in
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
@@ -82,6 +83,7 @@ const Invitation = ({
|
||||
userAlreadyExists,
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const { data } = api.user.getUserByToken.useQuery(
|
||||
{
|
||||
token,
|
||||
@@ -148,12 +150,15 @@ const Invitation = ({
|
||||
<div className="flex h-screen w-full items-center justify-center ">
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Invitation
|
||||
</CardTitle>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const registerSchema = z
|
||||
.object({
|
||||
@@ -77,6 +78,7 @@ interface Props {
|
||||
|
||||
const Register = ({ isCloud }: Props) => {
|
||||
const router = useRouter();
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<any>(null);
|
||||
@@ -123,12 +125,15 @@ const Register = ({ isCloud }: Props) => {
|
||||
<div className="flex w-full items-center justify-center ">
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2"
|
||||
>
|
||||
<Logo className="size-12" />
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
{isCloud ? "Sign Up" : "Setup the server"}
|
||||
</CardTitle>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z
|
||||
.object({
|
||||
@@ -53,6 +54,7 @@ interface Props {
|
||||
tokenResetPassword: string;
|
||||
}
|
||||
export default function Home({ tokenResetPassword }: Props) {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [token, setToken] = useState<string | null>(tokenResetPassword);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -97,7 +99,14 @@ export default function Home({ tokenResetPassword }: Props) {
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<CardTitle className="text-2xl font-bold flex flex-row gap-2 items-center">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo className="size-12" />
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl ||
|
||||
whitelabeling?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
Reset Password
|
||||
</CardTitle>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
@@ -42,6 +43,7 @@ type AuthResponse = {
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const [temp, _setTemp] = useState<AuthResponse>({
|
||||
is2FAEnabled: false,
|
||||
authId: "",
|
||||
@@ -81,8 +83,14 @@ export default function Home() {
|
||||
<div className="flex w-full items-center justify-center ">
|
||||
<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>
|
||||
<Logo
|
||||
logoUrl={
|
||||
whitelabeling?.loginLogoUrl || whitelabeling?.logoUrl || undefined
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{whitelabeling?.appName || "Dokploy"}
|
||||
</span>
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||
@@ -87,6 +88,7 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
whitelabeling: whitelabelingRouter,
|
||||
schedule: scheduleRouter,
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
|
||||
88
apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
Normal file
88
apps/dokploy/server/api/routers/proprietary/whitelabeling.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
updateWebServerSettings,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { apiUpdateWhitelabeling } from "@/server/db/schema";
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const whitelabelingRouter = createTRPCRouter({
|
||||
get: adminProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
return settings?.whitelabelingConfig ?? null;
|
||||
}),
|
||||
|
||||
update: adminProcedure
|
||||
.input(apiUpdateWhitelabeling)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Whitelabeling is not available in Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the owner can update whitelabeling settings",
|
||||
});
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
whitelabelingConfig: input.whitelabelingConfig,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
reset: adminProcedure.mutation(async ({ ctx }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Whitelabeling is not available in Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the owner can reset whitelabeling settings",
|
||||
});
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
primaryColor: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Public endpoint so the whitelabeling config can be applied globally
|
||||
// (including on the login page before auth)
|
||||
getPublic: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
return settings?.whitelabelingConfig ?? null;
|
||||
}),
|
||||
});
|
||||
@@ -101,7 +101,10 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
return memberResult;
|
||||
}),
|
||||
session: protectedProcedure.query(async ({ ctx }) => {
|
||||
session: publicProcedure.query(async ({ ctx }) => {
|
||||
if (!ctx.user || !ctx.session || !ctx.session.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: ctx.user.id,
|
||||
|
||||
13
apps/dokploy/utils/hooks/use-whitelabeling.ts
Normal file
13
apps/dokploy/utils/hooks/use-whitelabeling.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/**
|
||||
* Hook to access the public whitelabeling config.
|
||||
* Can be used on any page (including login) since it uses the public endpoint.
|
||||
*/
|
||||
export function useWhitelabelingPublic() {
|
||||
const { data, ...rest } = api.whitelabeling.getPublic.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
return { config: data ?? null, ...rest };
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -66,6 +72,38 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Whitelabeling Configuration (Enterprise / Proprietary)
|
||||
whitelabelingConfig: jsonb("whitelabelingConfig")
|
||||
.$type<{
|
||||
appName: string | null;
|
||||
appDescription: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
primaryColor: string | null;
|
||||
customCss: string | null;
|
||||
loginLogoUrl: string | null;
|
||||
supportUrl: string | null;
|
||||
docsUrl: string | null;
|
||||
errorPageTitle: string | null;
|
||||
errorPageDescription: string | null;
|
||||
metaTitle: string | null;
|
||||
footerText: string | null;
|
||||
}>()
|
||||
.default({
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
primaryColor: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
}),
|
||||
// Cache Cleanup Configuration
|
||||
cleanupCacheApplications: boolean("cleanupCacheApplications")
|
||||
.notNull()
|
||||
@@ -154,6 +192,27 @@ export const apiUpdateDockerCleanup = z.object({
|
||||
serverId: z.string().optional(),
|
||||
});
|
||||
|
||||
// Whitelabeling validation schemas
|
||||
export const whitelabelingConfigSchema = z.object({
|
||||
appName: z.string().nullable(),
|
||||
appDescription: z.string().nullable(),
|
||||
logoUrl: z.string().nullable(),
|
||||
faviconUrl: z.string().nullable(),
|
||||
primaryColor: z.string().nullable(),
|
||||
customCss: z.string().nullable(),
|
||||
loginLogoUrl: z.string().nullable(),
|
||||
supportUrl: z.string().nullable(),
|
||||
docsUrl: z.string().nullable(),
|
||||
errorPageTitle: z.string().nullable(),
|
||||
errorPageDescription: z.string().nullable(),
|
||||
metaTitle: z.string().nullable(),
|
||||
footerText: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabeling = z.object({
|
||||
whitelabelingConfig: whitelabelingConfigSchema,
|
||||
});
|
||||
|
||||
export const apiUpdateWebServerMonitoring = z.object({
|
||||
metricsConfig: z
|
||||
.object({
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -119,6 +119,9 @@ importers:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.18.6
|
||||
version: 6.20.0
|
||||
'@codemirror/lang-css':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.2
|
||||
@@ -1264,6 +1267,9 @@ packages:
|
||||
'@codemirror/commands@6.10.2':
|
||||
resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==}
|
||||
|
||||
@@ -1816,6 +1822,9 @@ packages:
|
||||
'@lezer/common@1.5.1':
|
||||
resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==}
|
||||
|
||||
'@lezer/css@1.3.1':
|
||||
resolution: {integrity: sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==}
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
|
||||
|
||||
@@ -8803,6 +8812,14 @@ snapshots:
|
||||
'@codemirror/view': 6.39.15
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.0
|
||||
'@codemirror/language': 6.12.1
|
||||
'@codemirror/state': 6.5.4
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/css': 1.3.1
|
||||
|
||||
'@codemirror/lang-json@6.0.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.12.1
|
||||
@@ -9294,6 +9311,12 @@ snapshots:
|
||||
|
||||
'@lezer/common@1.5.1': {}
|
||||
|
||||
'@lezer/css@1.3.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
'@lezer/highlight': 1.2.3
|
||||
'@lezer/lr': 1.4.8
|
||||
|
||||
'@lezer/highlight@1.2.3':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.1
|
||||
|
||||
Reference in New Issue
Block a user