mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge pull request #4769 from imrja8/fix/whitelabeling-fouc
fix: eliminate whitelabeling FOUC (Replaces #4753)
This commit is contained in:
commit
bf547e2fd0
@ -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 (
|
||||
<>
|
||||
<Head>
|
||||
{config?.metaTitle && <title>{config.metaTitle}</title>}
|
||||
<link rel="icon" href={faviconHref} key="app-favicon" />
|
||||
</Head>
|
||||
|
||||
{config?.customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: config.customCss,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -3,12 +3,10 @@ import "@/styles/globals.css";
|
||||
import type { NextPage } from "next";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
import Head from "next/head";
|
||||
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 { Analytics } from "@/components/shared/analytics";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@ -40,9 +38,6 @@ const MyApp = ({
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
</Head>
|
||||
<TooltipProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
@ -52,7 +47,6 @@ const MyApp = ({
|
||||
forcedTheme={Component.theme}
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<WhitelabelingProvider />
|
||||
<Analytics />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
|
||||
@ -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 (
|
||||
<Html lang="en" className="font-sans">
|
||||
<Head>
|
||||
<link rel="icon" href="/icon.svg" />
|
||||
{/* 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>{title}</title>
|
||||
<link rel="icon" href={faviconHref || "/icon.svg"} />
|
||||
{customCss && (
|
||||
<style
|
||||
id="whitelabeling-styles"
|
||||
dangerouslySetInnerHTML={{ __html: customCss }}
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
<body className="flex h-full w-full flex-col font-sans">
|
||||
<Main />
|
||||
@ -13,3 +42,67 @@ export default function Document() {
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const SETTINGS_CACHE_TTL = 60 * 1000; // 1 minute
|
||||
|
||||
declare global {
|
||||
var __SETTINGS_CACHE: {
|
||||
data: {
|
||||
metaTitle: string | null;
|
||||
faviconHref: string | null;
|
||||
customCss: string | null;
|
||||
};
|
||||
expiresAt: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
Document.getInitialProps = async (
|
||||
ctx: DocumentContext,
|
||||
): Promise<DocumentInitialProps & WhitelabelingDocumentProps> => {
|
||||
const initialProps = await NextDocument.getInitialProps(ctx);
|
||||
|
||||
let metaTitle: string | null = null;
|
||||
let faviconHref: string | null = null;
|
||||
let customCss: string | null = null;
|
||||
|
||||
if (
|
||||
globalThis.__SETTINGS_CACHE &&
|
||||
globalThis.__SETTINGS_CACHE.expiresAt > Date.now() &&
|
||||
globalThis.__SETTINGS_CACHE.data
|
||||
) {
|
||||
return {
|
||||
...initialProps,
|
||||
...globalThis.__SETTINGS_CACHE.data,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getPublicWhitelabelingConfig();
|
||||
if (config) {
|
||||
metaTitle = config.metaTitle;
|
||||
// Remove any </style> 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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
};
|
||||
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,
|
||||
},
|
||||
|
||||
@ -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 <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
};
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@ -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 <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
};
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}),
|
||||
|
||||
|
||||
21
apps/dokploy/utils/create-server-helpers.ts
Normal file
21
apps/dokploy/utils/create-server-helpers.ts
Normal file
@ -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<any>,
|
||||
) => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user