diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx deleted file mode 100644 index 05e0b517a..000000000 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import Head from "next/head"; -import { useTheme } from "next-themes"; -import { api } from "@/utils/api"; - -export function WhitelabelingProvider() { - const { resolvedTheme } = useTheme(); - const { data: config } = api.whitelabeling.getPublic.useQuery(undefined, { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }); - - const faviconHref = - config?.faviconUrl ?? - (resolvedTheme === "dark" - ? "/icon-dark.svg" - : resolvedTheme === "light" - ? "/icon-light.svg" - : "/icon.svg"); - - return ( - <> - - {config?.metaTitle && {config.metaTitle}} - - - - {config?.customCss && ( - - - Dokploy - - diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 120bb827e..88e2af9a4 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -1,10 +1,39 @@ -import { Head, Html, Main, NextScript } from "next/document"; +import { getPublicWhitelabelingConfig } from "@dokploy/server"; +import NextDocument, { + type DocumentContext, + type DocumentInitialProps, + Head, + Html, + Main, + NextScript, +} from "next/document"; -export default function Document() { +interface WhitelabelingDocumentProps { + metaTitle: string | null; + faviconHref: string | null; + customCss: string | null; +} + +export default function Document({ + metaTitle, + faviconHref, + customCss, +}: WhitelabelingDocumentProps) { + const title = metaTitle || "Dokploy"; return ( - + {/* Rendered on the server so the correct branding is present on first + paint (and for social scrapers), avoiding a flash of / fallback to + the default Dokploy branding. */} + {title} + + {customCss && ( + tags to prevent XSS breakout + customCss = config.customCss + ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "") + : null; + faviconHref = config.faviconUrl || null; + } + } catch { + // Fall back to defaults if settings can't be read (e.g. DB not ready) + } + + globalThis.__SETTINGS_CACHE = { + data: { + metaTitle, + faviconHref, + customCss, + }, + expiresAt: Date.now() + SETTINGS_CACHE_TTL, + }; + + return { + ...initialProps, + metaTitle, + faviconHref, + customCss, + }; +}; diff --git a/apps/dokploy/pages/index.tsx b/apps/dokploy/pages/index.tsx index 8f1adae72..3e41abae3 100644 --- a/apps/dokploy/pages/index.tsx +++ b/apps/dokploy/pages/index.tsx @@ -5,6 +5,7 @@ import { } from "@dokploy/server"; import { validateRequest } from "@dokploy/server/lib/auth"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Fingerprint } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; @@ -46,6 +47,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"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; @@ -132,8 +134,7 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) { return; } - // @ts-ignore - if (data?.twoFactorRedirect as boolean) { + if (data && "twoFactorRedirect" in data && data.twoFactorRedirect) { setTwoFactorCode(""); setIsTwoFactor(true); toast.info("Please enter your 2FA code"); @@ -493,6 +494,11 @@ Home.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(context: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the login/onboarding logo and app name + // render correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + if (IS_CLOUD) { try { const { user } = await validateRequest(context.req); @@ -508,6 +514,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), IS_CLOUD: IS_CLOUD, enforceSSO: false, }, @@ -539,6 +546,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), hasAdmin, enforceSSO: webServerSettings?.enforceSSO ?? false, }, diff --git a/apps/dokploy/pages/invitation.tsx b/apps/dokploy/pages/invitation.tsx index 4e78303f4..da6482219 100644 --- a/apps/dokploy/pages/invitation.tsx +++ b/apps/dokploy/pages/invitation.tsx @@ -1,5 +1,6 @@ import { getUserByToken, IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -23,6 +24,7 @@ import { import { Input } from "@/components/ui/input"; import { pushToDataLayer } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; @@ -330,6 +332,11 @@ Invitation.getLayout = (page: ReactElement) => { return {page}; }; export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, ctx); + // Prefetch the public branding so the invitation logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + const { query } = ctx; const token = query.token; @@ -358,6 +365,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { if (invitation.userAlreadyExists) { return { props: { + trpcState: helpers.dehydrate(), isCloud: IS_CLOUD, token: token, invitation: invitation, @@ -377,6 +385,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { return { props: { + trpcState: helpers.dehydrate(), isCloud: IS_CLOUD, token: token, invitation: invitation, diff --git a/apps/dokploy/pages/register.tsx b/apps/dokploy/pages/register.tsx index 524db4dba..20c95bea6 100644 --- a/apps/dokploy/pages/register.tsx +++ b/apps/dokploy/pages/register.tsx @@ -1,5 +1,6 @@ import { IS_CLOUD, isAdminPresent, validateRequest } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import { AlertTriangle } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; @@ -27,6 +28,7 @@ import { import { Input } from "@/components/ui/input"; import { pushToDataLayer } from "@/lib/analytics"; import { authClient } from "@/lib/auth-client"; +import { appRouter } from "@/server/api/root"; import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; const registerSchema = z @@ -305,6 +307,11 @@ Register.getLayout = (page: ReactElement) => { ); }; export async function getServerSideProps(context: GetServerSidePropsContext) { + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the onboarding logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + if (IS_CLOUD) { const { user } = await validateRequest(context.req); @@ -318,6 +325,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { props: { + trpcState: helpers.dehydrate(), isCloud: true, }, }; @@ -334,6 +342,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } return { props: { + trpcState: helpers.dehydrate(), isCloud: false, }, }; diff --git a/apps/dokploy/pages/send-reset-password.tsx b/apps/dokploy/pages/send-reset-password.tsx index 7d3c47d51..2f41e50c9 100644 --- a/apps/dokploy/pages/send-reset-password.tsx +++ b/apps/dokploy/pages/send-reset-password.tsx @@ -1,5 +1,6 @@ import { IS_CLOUD } from "@dokploy/server"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { generateServerSideHelper } from "@/utils/create-server-helpers"; import type { GetServerSidePropsContext } from "next"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -22,6 +23,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 { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling"; const loginSchema = z.object({ @@ -165,7 +167,7 @@ export default function Home() { Home.getLayout = (page: ReactElement) => { return {page}; }; -export async function getServerSideProps(_context: GetServerSidePropsContext) { +export async function getServerSideProps(context: GetServerSidePropsContext) { if (!IS_CLOUD) { return { redirect: { @@ -175,7 +177,14 @@ export async function getServerSideProps(_context: GetServerSidePropsContext) { }; } + const helpers = generateServerSideHelper(appRouter, context); + // Prefetch the public branding so the logo and app name render + // correctly on the server (no flash of default branding). + await helpers.whitelabeling.getPublic.prefetch(); + return { - props: {}, + props: { + trpcState: helpers.dehydrate(), + }, }; } diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts index bca5a14e1..bc38c8ff1 100644 --- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts +++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts @@ -14,6 +14,11 @@ import { publicProcedure, } from "../../trpc"; +/** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */ +function clearBrandingSSRCache() { + globalThis.__SETTINGS_CACHE = null; +} + export const whitelabelingRouter = createTRPCRouter({ get: protectedProcedure.query(async ({ ctx }) => { if (IS_CLOUD) { @@ -47,6 +52,9 @@ export const whitelabelingRouter = createTRPCRouter({ whitelabelingConfig: input.whitelabelingConfig, }); + // Clear the cache so Next.js SSR applies changes immediately + clearBrandingSSRCache(); + return { success: true }; }), @@ -82,6 +90,9 @@ export const whitelabelingRouter = createTRPCRouter({ }, }); + // Clear the cache so Next.js SSR applies changes immediately + clearBrandingSSRCache(); + return { success: true }; }), diff --git a/apps/dokploy/utils/create-server-helpers.ts b/apps/dokploy/utils/create-server-helpers.ts new file mode 100644 index 000000000..429bc67cf --- /dev/null +++ b/apps/dokploy/utils/create-server-helpers.ts @@ -0,0 +1,21 @@ +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import superjson from "superjson"; +import type { AppRouter } from "@/server/api/root"; + +export const generateServerSideHelper = ( + router: AppRouter, + context: GetServerSidePropsContext, +) => { + return createServerSideHelpers({ + router, + ctx: { + req: context.req as any, + res: context.res as any, + db: null as any, + session: null as any, + user: null as any, + }, + transformer: superjson, + }); +};