diff --git a/apps/dokploy/components/dashboard/settings/user-interface/user-interface-form.tsx b/apps/dokploy/components/dashboard/settings/user-interface/user-interface-form.tsx new file mode 100644 index 000000000..f3f015a5f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/user-interface/user-interface-form.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Palette } from "lucide-react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +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 { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; + +const userInterfaceSchema = z.object({ + loginPageImage: z.string().url().optional().or(z.literal("")), +}); + +type UserInterfaceForm = z.infer; + +export const UserInterfaceForm = () => { + const { data, refetch, isLoading } = + api.settings.getUserInterfaceSettings.useQuery(); + const { mutateAsync, isLoading: isUpdating } = + api.settings.updateUserInterfaceSettings.useMutation(); + + const form = useForm({ + defaultValues: { + loginPageImage: "", + }, + resolver: zodResolver(userInterfaceSchema), + }); + + useEffect(() => { + if (data) { + form.reset({ + loginPageImage: data.loginPageImage || "", + }); + } + }, [data, form]); + + const onSubmit = async (values: UserInterfaceForm) => { + try { + await mutateAsync({ + loginPageImage: values.loginPageImage || null, + }); + + toast.success("User interface settings updated successfully"); + await refetch(); + } catch (error) { + toast.error("Failed to update user interface settings"); + } + }; + + return ( +
+ +
+ + + + User interface settings + + + Customize your dashboard experience and interface preferences. + + + +
+ + ( + + Login page background image + + + + + URL of the background image to display on login page. + Leave empty to use default. + + + + )} + /> + + {form.watch("loginPageImage") && ( +
+

+ Preview: +

+
+ Login page background preview { + e.currentTarget.style.display = "none"; + ( + e.currentTarget.nextElementSibling as HTMLElement + ).style.display = "flex"; + }} + /> +
+ Failed to load image +
+
+
+ )} + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/layouts/onboarding-layout.tsx b/apps/dokploy/components/layouts/onboarding-layout.tsx index fff5413e0..0114a2371 100644 --- a/apps/dokploy/components/layouts/onboarding-layout.tsx +++ b/apps/dokploy/components/layouts/onboarding-layout.tsx @@ -1,6 +1,7 @@ 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"; @@ -9,20 +10,51 @@ interface Props { children: React.ReactNode; } export const OnboardingLayout = ({ children }: Props) => { + const { data: uiSettings } = api.settings.getUserInterfaceSettings.useQuery(); + const backgroundImage = uiSettings?.loginPageImage; + return (
-
+
+
+
- Dokploy + + Dokploy +
-

+

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

diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 45b6a7e3a..d13e3713f 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -22,6 +22,7 @@ import { KeyRound, Loader2, type LucideIcon, + Palette, Package, PieChart, Server, @@ -397,6 +398,12 @@ const MENU: Menu = { // Only enabled for admins in cloud environments isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), }, + { + isSingle: true, + title: "User interface", + url: "/dashboard/settings/user-interface", + icon: Palette, + }, ], help: [ diff --git a/apps/dokploy/pages/dashboard/settings/user-interface.tsx b/apps/dokploy/pages/dashboard/settings/user-interface.tsx new file mode 100644 index 000000000..5e6c3aab9 --- /dev/null +++ b/apps/dokploy/pages/dashboard/settings/user-interface.tsx @@ -0,0 +1,53 @@ +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 { UserInterfaceForm } from "@/components/dashboard/settings/user-interface/user-interface-form"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { appRouter } from "@/server/api/root"; +import { getLocale, serverSideTranslations } from "@/utils/i18n"; + +const Page = () => { + return ( +
+ +
+ ); +}; + +export default Page; + +Page.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps( + ctx: GetServerSidePropsContext<{ serviceId: string }>, +) { + const { req, res } = ctx; + const { user, session } = await validateRequest(req); + const locale = getLocale(req.cookies); + + 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.settings.isCloud.prefetch(); + await helpers.user.get.prefetch(); + + return { + props: { + trpcState: helpers.dehydrate(), + ...(await serverSideTranslations(locale, ["settings"])), + }, + }; +} diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index bd182527a..7c922962f 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -16,6 +16,7 @@ import { getDokployImageTag, getLogCleanupStatus, getUpdateData, + getUserInterfaceSettings, getWebServerSettings, IS_CLOUD, parseRawConfig, @@ -40,6 +41,7 @@ import { updateLetsEncryptEmail, updateServerById, updateServerTraefik, + updateUserInterfaceSettings, updateWebServerSettings, writeConfig, writeMainConfig, @@ -907,4 +909,34 @@ export const settingsRouter = createTRPCRouter({ const ips = process.env.DOKPLOY_CLOUD_IPS?.split(","); return ips; }), + + getUserInterfaceSettings: publicProcedure.query(async () => { + if (IS_CLOUD) { + return null; + } + const settings = await getUserInterfaceSettings(); + return { + loginPageImage: settings?.loginPageImage || null, + }; + }), + + updateUserInterfaceSettings: adminProcedure + .input( + z.object({ + loginPageImage: z.string().url().optional().nullable(), + }), + ) + .mutation(async ({ input }) => { + if (IS_CLOUD) { + return null; + } + + const settings = await updateUserInterfaceSettings({ + loginPageImage: input.loginPageImage, + }); + + return { + loginPageImage: settings?.loginPageImage || null, + }; + }), }); diff --git a/packages/server/src/db/schema/index.ts b/packages/server/src/db/schema/index.ts index ee3c03e93..98a37a88f 100644 --- a/packages/server/src/db/schema/index.ts +++ b/packages/server/src/db/schema/index.ts @@ -33,6 +33,7 @@ export * from "./session"; export * from "./shared"; export * from "./ssh-key"; export * from "./user"; +export * from "./user-interface-settings"; export * from "./utils"; export * from "./volume-backups"; export * from "./web-server-settings"; diff --git a/packages/server/src/db/schema/user-interface-settings.ts b/packages/server/src/db/schema/user-interface-settings.ts new file mode 100644 index 000000000..e4647ce58 --- /dev/null +++ b/packages/server/src/db/schema/user-interface-settings.ts @@ -0,0 +1,29 @@ +import { relations } from "drizzle-orm"; +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { createInsertSchema } from "drizzle-zod"; +import { nanoid } from "nanoid"; +import { z } from "zod"; + +export const userInterfaceSettings = pgTable("userInterfaceSettings", { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => nanoid()), + // UI Customization + loginPageImage: text("loginPageImage"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + +export const userInterfaceSettingsRelations = relations( + userInterfaceSettings, + () => ({}), +); + +const createSchema = createInsertSchema(userInterfaceSettings, { + id: z.string().min(1), +}); + +export const apiUpdateUserInterfaceSettings = createSchema.partial().extend({ + loginPageImage: z.string().url().optional().nullable(), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f28711dbf..6d64ad477 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -40,6 +40,7 @@ export * from "./services/server"; export * from "./services/settings"; export * from "./services/ssh-key"; export * from "./services/user"; +export * from "./services/user-interface-settings"; export * from "./services/volume-backups"; export * from "./services/web-server-settings"; export * from "./setup/config-paths"; diff --git a/packages/server/src/services/user-interface-settings.ts b/packages/server/src/services/user-interface-settings.ts new file mode 100644 index 000000000..5d01cc10f --- /dev/null +++ b/packages/server/src/services/user-interface-settings.ts @@ -0,0 +1,44 @@ +import { db } from "@dokploy/server/db"; +import { userInterfaceSettings } from "@dokploy/server/db/schema"; +import { eq } from "drizzle-orm"; + +/** + * Get the user interface settings (singleton - only one row should exist) + */ +export const getUserInterfaceSettings = async () => { + const settings = await db.query.userInterfaceSettings.findFirst({ + orderBy: (settings, { asc }) => [asc(settings.createdAt)], + }); + + if (!settings) { + // Create default settings if none exist + const [newSettings] = await db + .insert(userInterfaceSettings) + .values({}) + .returning(); + + return newSettings; + } + + return settings; +}; + +/** + * Update user interface settings + */ +export const updateUserInterfaceSettings = async ( + updates: Partial, +) => { + const current = await getUserInterfaceSettings(); + + const [updated] = await db + .update(userInterfaceSettings) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(userInterfaceSettings.id, current?.id ?? "")) + .returning(); + + return updated; +};