mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-04 21:45:26 +02:00
feat(whitelabel): implement whitelabeling features and database updates
- Added support for whitelabeling, allowing customization of app name, logo, favicon, tagline, and custom CSS. - Introduced new database columns to store whitelabel settings. - Updated various components to utilize whitelabel settings, including the logo and favicon in the UI. - Enhanced the onboarding layout and sidebar to reflect whitelabel configurations. - Integrated whitelabel settings into the application head for dynamic title and favicon updates.
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronDown, ImagePlus, Loader2, Palette, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string().max(256).optional().or(z.literal("")),
|
||||
tagline: z.string().max(512).optional().or(z.literal("")),
|
||||
logoUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.refine(
|
||||
(v) =>
|
||||
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
|
||||
"Must be a valid URL or uploaded image",
|
||||
),
|
||||
faviconUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.refine(
|
||||
(v) =>
|
||||
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
|
||||
"Must be a valid URL or uploaded image",
|
||||
),
|
||||
customCss: z.string().max(8192).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_FAVICON_SIZE_BYTES = 512 * 1024; // 512KB (favicons are small)
|
||||
|
||||
/** Reference of CSS variables used by the app (from globals.css). Use in :root for light, .dark for dark mode. Values: HSL without hsl() e.g. 220 70% 50% */
|
||||
const CSS_VARIABLES_REFERENCE = `/* General */
|
||||
--background, --foreground
|
||||
--card, --card-foreground
|
||||
--popover, --popover-foreground
|
||||
--primary, --primary-foreground
|
||||
--secondary, --secondary-foreground
|
||||
--muted, --muted-foreground
|
||||
--accent, --accent-foreground
|
||||
--destructive, --destructive-foreground
|
||||
--border, --input, --ring
|
||||
--radius (e.g. 0.5rem)
|
||||
--overlay (e.g. rgba(0,0,0,0.2))
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background, --sidebar-foreground
|
||||
--sidebar-primary, --sidebar-primary-foreground
|
||||
--sidebar-accent, --sidebar-accent-foreground
|
||||
--sidebar-border, --sidebar-ring
|
||||
|
||||
/* Charts */
|
||||
--chart-1, --chart-2, --chart-3, --chart-4, --chart-5`;
|
||||
|
||||
/** Default theme CSS (mirrors globals.css). Load into editor so user can edit variables without writing from scratch. */
|
||||
const DEFAULT_THEME_CSS = `:root {
|
||||
--terminal-paste: rgba(0, 0, 0, 0.2);
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
--overlay: rgba(0, 0, 0, 0.2);
|
||||
--chart-1: 173 58% 39%;
|
||||
--chart-2: 12 76% 61%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--terminal-paste: rgba(255, 255, 255, 0.2);
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 4% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 4% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 4% 10%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--overlay: rgba(0, 0, 0, 0.5);
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 340 75% 55%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 160 60% 45%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const WhitelabelForm = () => {
|
||||
const logoFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const faviconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { data, isLoading } = api.whitelabel.get.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading: isPending } =
|
||||
api.whitelabel.update.useMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
appName: "",
|
||||
tagline: "",
|
||||
logoUrl: "",
|
||||
faviconUrl: "",
|
||||
customCss: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
appName: data.appName ?? "",
|
||||
tagline: data.tagline ?? "",
|
||||
logoUrl: data.logoUrl ?? "",
|
||||
faviconUrl: data.faviconUrl ?? "",
|
||||
customCss: data.customCss ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await mutateAsync({
|
||||
appName: values.appName?.trim() || null,
|
||||
tagline: values.tagline?.trim() || null,
|
||||
logoUrl: values.logoUrl?.trim() || null,
|
||||
faviconUrl: values.faviconUrl?.trim() || null,
|
||||
customCss: values.customCss?.trim() || null,
|
||||
})
|
||||
.then(() => {
|
||||
utils.whitelabel.get.invalidate();
|
||||
toast.success("Whitelabel settings saved");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message ?? "Failed to save");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">Whitelabelling</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Customize the app name and logos for your self-hosted instance.
|
||||
These will appear on the login page, sidebar, and browser tab.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. My Company DevOps" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Replaces "Dokploy" in the UI when set.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tagline"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tagline</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. The Open Source alternative to Netlify, Vercel, Heroku."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Quote shown on the login/onboarding side panel. Leave empty
|
||||
for default.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
{field.value?.startsWith("data:") ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
|
||||
<Logo
|
||||
className="size-12 shrink-0"
|
||||
logoUrl={field.value}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Uploaded image
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Image is stored and will appear in sidebar and
|
||||
login.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
logoFileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
<ImagePlus className="mr-1 size-4" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => field.onChange("")}
|
||||
>
|
||||
<X className="mr-1 size-4" />
|
||||
Use URL instead
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => logoFileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="mr-2 size-4" />
|
||||
Upload image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={logoFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_LOGO_SIZE_BYTES) {
|
||||
toast.error("Image size must be less than 2MB");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
field.onChange(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Paste a logo URL or upload an image (max 2MB). Used in
|
||||
sidebar and login.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
{field.value?.startsWith("data:") ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
|
||||
{field.value && (
|
||||
// biome-ignore lint/performance/noImgElement: favicon preview from data URL
|
||||
<img
|
||||
src={field.value}
|
||||
alt="Favicon"
|
||||
className="size-8 shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Uploaded favicon
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown in the browser tab.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
faviconFileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
<ImagePlus className="mr-1 size-4" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => field.onChange("")}
|
||||
>
|
||||
<X className="mr-1 size-4" />
|
||||
Use URL instead
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
{...field}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => faviconFileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="mr-2 size-4" />
|
||||
Upload image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={faviconFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FAVICON_SIZE_BYTES) {
|
||||
toast.error(
|
||||
"Favicon size must be less than 512KB",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
field.onChange(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Paste a favicon URL or upload an image (max 512KB). Shown in
|
||||
the browser tab.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCss"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>Custom CSS</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("customCss", DEFAULT_THEME_CSS, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
toast.success("Default theme loaded. Edit and save.");
|
||||
}}
|
||||
>
|
||||
Load default theme
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="css"
|
||||
lineWrapping
|
||||
lineNumbers={false}
|
||||
wrapperClassName="min-h-[180px] rounded-md border"
|
||||
placeholder={`:root {
|
||||
--primary: 220 70% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
}
|
||||
.dark {
|
||||
--primary: 220 70% 50%;
|
||||
}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Override theme colors using CSS variables. Use{" "}
|
||||
<code className="rounded bg-muted px-1">:root</code> for
|
||||
light mode and{" "}
|
||||
<code className="rounded bg-muted px-1">.dark</code> for
|
||||
dark mode. Values in HSL without "hsl()".
|
||||
Don't use quotes around colors (e.g.{" "}
|
||||
<code className="rounded bg-muted px-1">red</code>, not
|
||||
"red"). Max 8KB.
|
||||
</FormDescription>
|
||||
<Collapsible className="mt-2 group">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
Available CSS variables
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 rounded-md border bg-muted p-3 text-xs font-mono overflow-x-auto whitespace-pre text-muted-foreground">
|
||||
{CSS_VARIABLES_REFERENCE}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("logoUrl") && (
|
||||
<div className="rounded-lg border p-4 bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Logo preview
|
||||
</p>
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={form.watch("logoUrl") || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { GithubIcon } from "../icons/data-tools-icons";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const DEFAULT_TAGLINE =
|
||||
"The Open Source alternative to Netlify, Vercel, Heroku.";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
const appName = whitelabel?.appName ?? "Dokploy";
|
||||
const logoUrl = whitelabel?.logoUrl ?? undefined;
|
||||
const tagline = whitelabel?.tagline ?? DEFAULT_TAGLINE;
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
||||
@@ -17,15 +26,12 @@ export const OnboardingLayout = ({ children }: Props) => {
|
||||
href="https://dokploy.com"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" />
|
||||
Dokploy
|
||||
<Logo className="size-10" logoUrl={logoUrl} />
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">
|
||||
“The Open Source alternative to Netlify, Vercel,
|
||||
Heroku.”
|
||||
</p>
|
||||
<p className="text-lg text-primary">“{tagline}”</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
@@ -406,6 +407,15 @@ const MENU: Menu = {
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Whitelabelling",
|
||||
url: "/dashboard/settings/whitelabelling",
|
||||
icon: Palette,
|
||||
// Proprietary: only in non-cloud, admins only
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "SSO",
|
||||
@@ -538,6 +548,9 @@ function LogoWrapper() {
|
||||
function SidebarLogo() {
|
||||
const { state } = useSidebar();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery(undefined, {
|
||||
enabled: !isCloud,
|
||||
});
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const {
|
||||
@@ -610,7 +623,11 @@ function SidebarLogo() {
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-4" : "size-5",
|
||||
)}
|
||||
logoUrl={activeOrganization?.logo || undefined}
|
||||
logoUrl={
|
||||
activeOrganization?.logo ||
|
||||
whitelabel?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -620,7 +637,9 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
{activeOrganization?.name ??
|
||||
whitelabel?.appName ??
|
||||
"Select Organization"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
27
apps/dokploy/components/proprietary/whitelabel-head.tsx
Normal file
27
apps/dokploy/components/proprietary/whitelabel-head.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import Head from "next/head";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/** When whitelabel is configured (non-cloud), overrides document title, favicon, and optional custom CSS. */
|
||||
export function WhitelabelHead() {
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
// Always render so we can override favicon (same key as _app default replaces it)
|
||||
return (
|
||||
<Head>
|
||||
{whitelabel?.appName && <title>{whitelabel.appName}</title>}
|
||||
{whitelabel?.faviconUrl ? (
|
||||
<link rel="icon" href={whitelabel.faviconUrl} key="favicon" />
|
||||
) : (
|
||||
<link rel="icon" href="/icon.svg" key="favicon" />
|
||||
)}
|
||||
{whitelabel?.customCss?.trim() && (
|
||||
<style
|
||||
id="whitelabel-custom-css"
|
||||
dangerouslySetInnerHTML={{ __html: whitelabel.customCss.trim() }}
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
@@ -130,7 +131,7 @@ function dockerComposeComplete(
|
||||
interface Props extends ReactCodeMirrorProps {
|
||||
wrapperClassName?: string;
|
||||
disabled?: boolean;
|
||||
language?: "yaml" | "json" | "properties" | "shell";
|
||||
language?: "yaml" | "json" | "properties" | "shell" | "css";
|
||||
lineWrapping?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
||||
if (logoUrl) {
|
||||
export const Logo = ({ className = "size-14" }: Props) => {
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
if (whitelabel?.logoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
src={whitelabel?.logoUrl}
|
||||
alt="Organization Logo"
|
||||
className={cn(className, "object-contain rounded-sm")}
|
||||
/>
|
||||
|
||||
3
apps/dokploy/drizzle/0144_flaky_psylocke.sql
Normal file
3
apps/dokploy/drizzle/0144_flaky_psylocke.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelAppName" text;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelLogoUrl" text;--> statement-breakpoint
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelFaviconUrl" text;
|
||||
1
apps/dokploy/drizzle/0145_modern_risque.sql
Normal file
1
apps/dokploy/drizzle/0145_modern_risque.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelTagline" text;
|
||||
1
apps/dokploy/drizzle/0146_last_kitty_pryde.sql
Normal file
1
apps/dokploy/drizzle/0146_last_kitty_pryde.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "webServerSettings" ADD COLUMN "whitelabelCustomCss" text;
|
||||
7309
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
7309
apps/dokploy/drizzle/meta/0144_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7315
apps/dokploy/drizzle/meta/0145_snapshot.json
Normal file
7315
apps/dokploy/drizzle/meta/0145_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7321
apps/dokploy/drizzle/meta/0146_snapshot.json
Normal file
7321
apps/dokploy/drizzle/meta/0146_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1009,6 +1009,27 @@
|
||||
"when": 1770961667210,
|
||||
"tag": "0143_brown_ultron",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 144,
|
||||
"version": "7",
|
||||
"when": 1771222951607,
|
||||
"tag": "0144_flaky_psylocke",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 145,
|
||||
"version": "7",
|
||||
"when": 1771226249421,
|
||||
"tag": "0145_modern_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 146,
|
||||
"version": "7",
|
||||
"when": 1771226541422,
|
||||
"tag": "0146_last_kitty_pryde",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -49,6 +49,7 @@
|
||||
"@ai-sdk/openai": "^2.0.16",
|
||||
"@ai-sdk/openai-compatible": "^1.0.10",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.2.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { WhitelabelHead } from "@/components/proprietary/whitelabel-head";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Languages } from "@/lib/languages";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -26,9 +27,10 @@ type AppPropsWithLayout = AppProps & {
|
||||
|
||||
const MyApp = ({
|
||||
Component,
|
||||
pageProps: { ...pageProps },
|
||||
pageProps: { whitelabelFaviconUrl, ...pageProps },
|
||||
}: AppPropsWithLayout) => {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
const faviconHref = whitelabelFaviconUrl ?? "/icon.svg";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,7 +43,9 @@ const MyApp = ({
|
||||
</style>
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
<link rel="icon" href={faviconHref} key="favicon" />
|
||||
</Head>
|
||||
<WhitelabelHead />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function Document() {
|
||||
return (
|
||||
<Html lang="en" className="font-sans">
|
||||
<Head>
|
||||
<link rel="icon" href="/icon.svg" />
|
||||
{/* Default favicon; WhitelabelHead overrides with key="favicon" when custom favicon is set */}
|
||||
</Head>
|
||||
<body className="flex h-full w-full flex-col font-sans">
|
||||
<Main />
|
||||
|
||||
@@ -90,6 +90,7 @@ const Service = (
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.project?.projectId || "",
|
||||
@@ -121,7 +122,8 @@ const Service = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Application: {data?.name} - {data?.environment.project.name} | Dokploy
|
||||
Application: {data?.name} - {data?.environment.project.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
@@ -165,7 +167,7 @@ const Service = (
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
{data?.server?.name || "Dokploy Server"}
|
||||
{data?.server?.name || `${whitelabel?.appName ?? "Dokploy"} Server`}
|
||||
</Badge>
|
||||
{data?.server?.serverStatus === "inactive" && (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
@@ -79,6 +79,7 @@ const Service = (
|
||||
const { data } = api.compose.one.useQuery({ composeId });
|
||||
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
projectId: data?.environment?.projectId || "",
|
||||
@@ -110,7 +111,8 @@ const Service = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Compose: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -59,6 +59,7 @@ const Mariadb = (
|
||||
const [tab, setSab] = useState<TabState>(activeTab);
|
||||
const { data } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
@@ -93,8 +94,8 @@ const Mariadb = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">
|
||||
|
||||
@@ -59,6 +59,7 @@ const Mongo = (
|
||||
const { data } = api.mongo.one.useQuery({ mongoId });
|
||||
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
@@ -91,7 +92,8 @@ const Mongo = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -58,6 +58,7 @@ const MySql = (
|
||||
const [tab, setSab] = useState<TabState>(activeTab);
|
||||
const { data } = api.mysql.one.useQuery({ mysqlId });
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
@@ -91,8 +92,8 @@ const MySql = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |
|
||||
Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -58,6 +58,7 @@ const Postgresql = (
|
||||
const [tab, setSab] = useState<TabState>(activeTab);
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
@@ -90,7 +91,8 @@ const Postgresql = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
@@ -58,6 +58,7 @@ const Redis = (
|
||||
const { data } = api.redis.one.useQuery({ redisId });
|
||||
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||
@@ -90,7 +91,8 @@ const Redis = (
|
||||
/>
|
||||
<Head>
|
||||
<title>
|
||||
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
|
||||
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="w-full">
|
||||
|
||||
96
apps/dokploy/pages/dashboard/settings/whitelabelling.tsx
Normal file
96
apps/dokploy/pages/dashboard/settings/whitelabelling.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { WhitelabelForm } from "@/components/dashboard/settings/whitelabelling/whitelabel-form";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="p-6">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Whitelabelling",
|
||||
description:
|
||||
"Customise app name and logos (whitelabelling) is part of Dokploy Enterprise. Add a valid license to use it.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<WhitelabelForm />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => (
|
||||
<DashboardLayout metaName="Whitelabelling">{page}</DashboardLayout>
|
||||
);
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<Record<string, string>>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const locale = await getLocale(req.cookies);
|
||||
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { user } = await validateRequest(req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role !== "owner" && user.role !== "admin") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/settings/profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: null as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.whitelabel.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
...(await serverSideTranslations(locale, ["settings"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import { REGEXP_ONLY_DIGITS } from "input-otp";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
@@ -8,6 +9,7 @@ import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
} from "@/components/ui/input-otp";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
@@ -59,6 +62,7 @@ interface Props {
|
||||
export default function Home({ IS_CLOUD }: Props) {
|
||||
const router = useRouter();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||
@@ -435,9 +439,25 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: context.req as any,
|
||||
res: context.res as any,
|
||||
db: null as any,
|
||||
session: null,
|
||||
user: null,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.whitelabel.get.prefetch();
|
||||
const whitelabel = await helpers.whitelabel.get.fetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
hasAdmin,
|
||||
trpcState: helpers.dehydrate(),
|
||||
whitelabelFaviconUrl: whitelabel?.faviconUrl ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import superjson from "superjson";
|
||||
import { z } from "zod";
|
||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const registerSchema = z
|
||||
@@ -91,6 +94,7 @@ const Invitation = ({
|
||||
initialData: invitation,
|
||||
},
|
||||
);
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
const form = useForm<Register>({
|
||||
defaultValues: {
|
||||
@@ -346,6 +350,21 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
try {
|
||||
const invitation = await getUserByToken(token);
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: ctx.req as any,
|
||||
res: ctx.res as any,
|
||||
db: null as any,
|
||||
session: null,
|
||||
user: null,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
if (!IS_CLOUD) {
|
||||
await helpers.whitelabel.get.prefetch();
|
||||
}
|
||||
|
||||
if (invitation.userAlreadyExists) {
|
||||
return {
|
||||
props: {
|
||||
@@ -353,6 +372,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
token: token,
|
||||
invitation: invitation,
|
||||
userAlreadyExists: true,
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -371,6 +391,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
isCloud: IS_CLOUD,
|
||||
token: token,
|
||||
invitation: invitation,
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z
|
||||
@@ -48,6 +49,7 @@ export default function Home() {
|
||||
});
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const _router = useRouter();
|
||||
const form = useForm<Login>({
|
||||
@@ -82,7 +84,9 @@ export default function Home() {
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<Link href="/" className="flex flex-row items-center gap-2">
|
||||
<Logo />
|
||||
<span className="font-medium text-sm">Dokploy</span>
|
||||
<span className="font-medium text-sm">
|
||||
{whitelabel?.appName ?? "Dokploy"}
|
||||
</span>
|
||||
</Link>
|
||||
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelRouter } from "./routers/proprietary/whitelabel";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
import { previewDeploymentRouter } from "./routers/preview-deployment";
|
||||
@@ -86,6 +87,7 @@ export const appRouter = createTRPCRouter({
|
||||
organization: organizationRouter,
|
||||
licenseKey: licenseKeyRouter,
|
||||
sso: ssoRouter,
|
||||
whitelabel: whitelabelRouter,
|
||||
schedule: scheduleRouter,
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
|
||||
61
apps/dokploy/server/api/routers/proprietary/whitelabel.ts
Normal file
61
apps/dokploy/server/api/routers/proprietary/whitelabel.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { getWebServerSettings, updateWebServerSettings } from "@dokploy/server";
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { apiUpdateWhitelabelSettings } from "@dokploy/server/db/schema";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
enterpriseProcedure,
|
||||
publicProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
|
||||
export const whitelabelRouter = createTRPCRouter({
|
||||
get: publicProcedure.query(async () => {
|
||||
if (IS_CLOUD) return null;
|
||||
|
||||
const settings = await getWebServerSettings();
|
||||
if (!settings) return null;
|
||||
|
||||
return {
|
||||
appName: settings.whitelabelAppName ?? null,
|
||||
logoUrl: settings.whitelabelLogoUrl ?? null,
|
||||
faviconUrl: settings.whitelabelFaviconUrl ?? null,
|
||||
tagline: settings.whitelabelTagline ?? null,
|
||||
customCss: settings.whitelabelCustomCss ?? null,
|
||||
};
|
||||
}),
|
||||
update: enterpriseProcedure
|
||||
.input(apiUpdateWhitelabelSettings)
|
||||
.mutation(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
throw new Error(
|
||||
"Whitelabelling is only available in self-hosted (non-cloud) installations.",
|
||||
);
|
||||
}
|
||||
|
||||
await updateWebServerSettings({
|
||||
...(input.appName !== undefined && {
|
||||
whitelabelAppName: input.appName,
|
||||
}),
|
||||
...(input.tagline !== undefined && {
|
||||
whitelabelTagline: input.tagline,
|
||||
}),
|
||||
...(input.logoUrl !== undefined && {
|
||||
whitelabelLogoUrl: input.logoUrl,
|
||||
}),
|
||||
...(input.faviconUrl !== undefined && {
|
||||
whitelabelFaviconUrl: input.faviconUrl,
|
||||
}),
|
||||
...(input.customCss !== undefined && {
|
||||
whitelabelCustomCss: input.customCss,
|
||||
}),
|
||||
});
|
||||
|
||||
const settings = await getWebServerSettings();
|
||||
return {
|
||||
appName: settings?.whitelabelAppName ?? null,
|
||||
logoUrl: settings?.whitelabelLogoUrl ?? null,
|
||||
faviconUrl: settings?.whitelabelFaviconUrl ?? null,
|
||||
tagline: settings?.whitelabelTagline ?? null,
|
||||
customCss: settings?.whitelabelCustomCss ?? null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -76,6 +76,12 @@ export const webServerSettings = pgTable("webServerSettings", {
|
||||
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
|
||||
.notNull()
|
||||
.default(false),
|
||||
// Whitelabelling (non-cloud only)
|
||||
whitelabelAppName: text("whitelabelAppName"),
|
||||
whitelabelLogoUrl: text("whitelabelLogoUrl"),
|
||||
whitelabelFaviconUrl: text("whitelabelFaviconUrl"),
|
||||
whitelabelTagline: text("whitelabelTagline"),
|
||||
whitelabelCustomCss: text("whitelabelCustomCss"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -125,6 +131,59 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
|
||||
cleanupCacheApplications: z.boolean().optional(),
|
||||
cleanupCacheOnPreviews: z.boolean().optional(),
|
||||
cleanupCacheOnCompose: z.boolean().optional(),
|
||||
whitelabelAppName: z.string().max(256).optional().nullable(),
|
||||
whitelabelLogoUrl: z
|
||||
.union([
|
||||
z.string().url(),
|
||||
z
|
||||
.string()
|
||||
.startsWith("data:"), // uploaded image as data URL
|
||||
z.literal(""),
|
||||
])
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
whitelabelFaviconUrl: z
|
||||
.union([
|
||||
z.string().url(),
|
||||
z
|
||||
.string()
|
||||
.startsWith("data:"), // uploaded image as data URL
|
||||
z.literal(""),
|
||||
])
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
whitelabelTagline: z.string().max(512).optional().nullable(),
|
||||
whitelabelCustomCss: z.string().max(8192).optional().nullable(),
|
||||
});
|
||||
|
||||
export const apiUpdateWhitelabelSettings = z.object({
|
||||
appName: z.string().max(256).optional().nullable(),
|
||||
tagline: z.string().max(512).optional().nullable(),
|
||||
logoUrl: z
|
||||
.union([
|
||||
z.string().url(),
|
||||
z
|
||||
.string()
|
||||
.startsWith("data:"), // uploaded image as data URL
|
||||
z.literal(""),
|
||||
])
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
faviconUrl: z
|
||||
.union([
|
||||
z.string().url(),
|
||||
z
|
||||
.string()
|
||||
.startsWith("data:"), // uploaded image as data URL
|
||||
z.literal(""),
|
||||
])
|
||||
.optional()
|
||||
.nullable()
|
||||
.transform((v) => (v === "" ? null : v)),
|
||||
customCss: z.string().max(8192).optional().nullable(),
|
||||
});
|
||||
|
||||
export const apiAssignDomain = z
|
||||
|
||||
@@ -5,9 +5,9 @@ import { db } from "../../db/index";
|
||||
import { user as userSchema } from "../../db/schema/user";
|
||||
|
||||
export const LICENSE_KEY_URL =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:4002"
|
||||
: "https://licenses-api.dokploy.com";
|
||||
// process.env.NODE_ENV === "development"
|
||||
// ? "http://localhost:4002"
|
||||
"https://licenses-api.dokploy.com";
|
||||
|
||||
export const initEnterpriseBackupCronJobs = async () => {
|
||||
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -115,6 +115,9 @@ importers:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: ^6.18.6
|
||||
version: 6.18.6
|
||||
'@codemirror/lang-css':
|
||||
specifier: ^6.2.1
|
||||
version: 6.3.1
|
||||
'@codemirror/lang-json':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
@@ -1170,6 +1173,9 @@ packages:
|
||||
'@codemirror/commands@6.8.1':
|
||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
|
||||
|
||||
'@codemirror/lang-json@6.0.1':
|
||||
resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==}
|
||||
|
||||
@@ -2207,6 +2213,9 @@ packages:
|
||||
'@lezer/common@1.2.3':
|
||||
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
|
||||
|
||||
'@lezer/css@1.3.0':
|
||||
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||
|
||||
@@ -8861,6 +8870,14 @@ snapshots:
|
||||
'@codemirror/view': 6.29.0
|
||||
'@lezer/common': 1.2.3
|
||||
|
||||
'@codemirror/lang-css@6.3.1':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.6
|
||||
'@codemirror/language': 6.11.0
|
||||
'@codemirror/state': 6.5.2
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/css': 1.3.0
|
||||
|
||||
'@codemirror/lang-json@6.0.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.0
|
||||
@@ -9542,6 +9559,12 @@ snapshots:
|
||||
|
||||
'@lezer/common@1.2.3': {}
|
||||
|
||||
'@lezer/css@1.3.0':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.2
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.3
|
||||
|
||||
Reference in New Issue
Block a user