From 43f9f545c684e067e367ad4d07f0307734291ca4 Mon Sep 17 00:00:00 2001 From: Yandi Date: Sun, 22 Feb 2026 19:56:29 +0700 Subject: [PATCH 001/419] fix: use lazy initializer for useState with function calls Calling getTerminalKey() and getDefaultDateRange() directly in useState re-runs them on every render. Wrap in an arrow function so they execute only once during component initialization. --- apps/dokploy/components/dashboard/requests/show-requests.tsx | 2 +- .../dashboard/settings/web-server/terminal-modal.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index cc4f1764a..076ed33cc 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -63,7 +63,7 @@ export const ShowRequests = () => { const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined; - }>(getDefaultDateRange()); + }>(() => getDefaultDateRange()); // Check if logs exist to determine if traefik has been reloaded // Only fetch when active to minimize network calls diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx index 2647e1dc0..e90ba51f8 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx @@ -32,7 +32,9 @@ export const TerminalModal = ({ serverId, asButton = false, }: Props) => { - const [terminalKey, setTerminalKey] = useState(getTerminalKey()); + const [terminalKey, setTerminalKey] = useState(() => + getTerminalKey(), + ); const [isOpen, setIsOpen] = useState(false); const isLocalServer = serverId === "local"; From eb80f79862b751029de4ba3a4fe655003926850a Mon Sep 17 00:00:00 2001 From: Gabriel Vinhaes Date: Wed, 1 Apr 2026 16:37:56 -0300 Subject: [PATCH 002/419] fix: preserve server schedule failures through tee pipeline --- packages/server/src/utils/schedules/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index 64657c9a6..3330acb52 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -159,7 +159,7 @@ export const runCommand = async (scheduleId: string) => { const { SCHEDULES_PATH } = paths(true); const fullPath = path.join(SCHEDULES_PATH, appName || ""); const command = ` - set -e + set -euo pipefail echo "Running script" >> ${deployment.logPath}; bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || { echo "❌ Command failed" >> ${deployment.logPath}; From 3e1189776238cd6fd24b75ec531146f210bc67bf Mon Sep 17 00:00:00 2001 From: Rizki Putra Date: Tue, 7 Apr 2026 08:53:47 +0700 Subject: [PATCH 003/419] fix: return a valid docker host for docker desktop --- apps/dokploy/server/utils/docker.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/server/utils/docker.ts b/apps/dokploy/server/utils/docker.ts index 07e4e26e1..35144913a 100644 --- a/apps/dokploy/server/utils/docker.ts +++ b/apps/dokploy/server/utils/docker.ts @@ -11,10 +11,20 @@ export const isWSL = async () => { } }; +/** Returns if running in Docker Desktop. */ +export const isDockerDesktop = async () => { + try { + const { stdout } = await execAsync("getent hosts host.docker.internal"); + return !!stdout.trim(); + } catch { + return false; + } +}; + /** Returns the Docker host IP address. */ export const getDockerHost = async (): Promise => { if (process.env.NODE_ENV === "production") { - if (process.platform === "linux" && !(await isWSL())) { + if (process.platform === "linux" && !(await isWSL() || await isDockerDesktop())) { try { // Try to get the Docker bridge IP first const { stdout } = await execAsync( From e802342f5711aef5c64692cf5517139567a0e142 Mon Sep 17 00:00:00 2001 From: rpoetrap Date: Tue, 7 Apr 2026 17:04:38 +0700 Subject: [PATCH 004/419] fix sequential awaits --- apps/dokploy/server/utils/docker.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/server/utils/docker.ts b/apps/dokploy/server/utils/docker.ts index 35144913a..084315241 100644 --- a/apps/dokploy/server/utils/docker.ts +++ b/apps/dokploy/server/utils/docker.ts @@ -24,7 +24,11 @@ export const isDockerDesktop = async () => { /** Returns the Docker host IP address. */ export const getDockerHost = async (): Promise => { if (process.env.NODE_ENV === "production") { - if (process.platform === "linux" && !(await isWSL() || await isDockerDesktop())) { + const isLinux = process.platform === "linux" + ? (await Promise.all([isWSL(), isDockerDesktop()])).every((v) => !v) + : false; + + if (isLinux) { try { // Try to get the Docker bridge IP first const { stdout } = await execAsync( From f21931da60fcd5e9f7116a6bde1d39e2dc55b5b5 Mon Sep 17 00:00:00 2001 From: Daniel Abramov Date: Wed, 8 Apr 2026 18:29:29 +0200 Subject: [PATCH 005/419] fix: add unzip to arch server set up --- packages/server/src/setup/server-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index e2d7fc3e5..620a4e62b 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -466,7 +466,7 @@ const installUtilities = () => ` case "$OS_TYPE" in arch) - $SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true + $SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true ;; alpine) $SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories From 24cc4551686bf288d0094e604bcbf6ba4736d588 Mon Sep 17 00:00:00 2001 From: Romain PINSOLLE Date: Thu, 9 Apr 2026 13:17:09 +0200 Subject: [PATCH 006/419] fix: improve responsive layout for application logs page Co-Authored-By: Claude Sonnet 4.6 --- .../components/dashboard/application/logs/show.tsx | 2 +- .../dashboard/docker/logs/docker-logs-id.tsx | 10 +++++----- .../components/dashboard/docker/logs/terminal-line.tsx | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/logs/show.tsx b/apps/dokploy/components/dashboard/application/logs/show.tsx index 06b257766..37ca6c6fb 100644 --- a/apps/dokploy/components/dashboard/application/logs/show.tsx +++ b/apps/dokploy/components/dashboard/application/logs/show.tsx @@ -104,7 +104,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => { -
+
diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 59b939008..7e330e20e 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -337,11 +337,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
)} + {canDeploy && + data?.composeType === "docker-compose" && ( + { + await deploy({ + composeId: composeId, + freshVolumes: true, + }) + .then(() => { + toast.success( + "Compose deployed with fresh volumes", + ); + refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); + }) + .catch(() => { + toast.error("Error deploying compose"); + }); + }} + > + + + )} {canDeploy && ( composeId: job.data.composeId, titleLog: job.data.titleLog, descriptionLog: job.data.descriptionLog, + freshVolumes: job.data.freshVolumes, }); } else if (job.data.type === "redeploy") { await rebuildCompose({ composeId: job.data.composeId, titleLog: job.data.titleLog, descriptionLog: job.data.descriptionLog, + freshVolumes: job.data.freshVolumes, }); } } else if (job.data.applicationType === "application-preview") { diff --git a/apps/dokploy/server/queues/queue-types.ts b/apps/dokploy/server/queues/queue-types.ts index 1000725ad..5c469dd9e 100644 --- a/apps/dokploy/server/queues/queue-types.ts +++ b/apps/dokploy/server/queues/queue-types.ts @@ -16,6 +16,7 @@ type DeployJob = type: "deploy" | "redeploy"; applicationType: "compose"; serverId?: string; + freshVolumes?: boolean; } | { applicationId: string; diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 7803cb0a7..0416e5ea3 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -198,12 +198,14 @@ export const apiDeployCompose = z.object({ composeId: z.string().min(1), title: z.string().optional(), description: z.string().optional(), + freshVolumes: z.boolean().optional(), }); export const apiRedeployCompose = z.object({ composeId: z.string().min(1), title: z.string().optional(), description: z.string().optional(), + freshVolumes: z.boolean().optional(), }); export const apiDeleteCompose = z.object({ diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index 0cec3418b..d78e63a41 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -209,10 +209,12 @@ export const deployCompose = async ({ composeId, titleLog = "Manual deployment", descriptionLog = "", + freshVolumes = false, }: { composeId: string; titleLog: string; descriptionLog: string; + freshVolumes?: boolean; }) => { const compose = await findComposeById(composeId); @@ -266,6 +268,16 @@ export const deployCompose = async ({ } } + if (freshVolumes && compose.composeType === "docker-compose") { + const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; + if (compose.serverId) { + await execAsyncRemote(compose.serverId, downWithLog); + } else { + await execAsync(downWithLog); + } + } + command = "set -e;"; command += await getBuildComposeCommand(entity); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; @@ -339,10 +351,12 @@ export const rebuildCompose = async ({ composeId, titleLog = "Rebuild deployment", descriptionLog = "", + freshVolumes = false, }: { composeId: string; titleLog: string; descriptionLog: string; + freshVolumes?: boolean; }) => { const compose = await findComposeById(composeId); @@ -380,6 +394,16 @@ export const rebuildCompose = async ({ } } + if (freshVolumes && compose.composeType === "docker-compose") { + const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; + if (compose.serverId) { + await execAsyncRemote(compose.serverId, downWithLog); + } else { + await execAsync(downWithLog); + } + } + command = "set -e;"; command += await getBuildComposeCommand(compose); commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`; From c668f6c7bd75cf5ef717193a827538897512e08e Mon Sep 17 00:00:00 2001 From: Laode Muhammad Al Fatih Date: Fri, 10 Apr 2026 16:26:24 +0700 Subject: [PATCH 008/419] fix: add env -i PATH prefix to fresh volumes down command Consistent with the rest of the codebase where every docker compose invocation uses `env -i PATH="$PATH"` for a clean environment. --- packages/server/src/services/compose.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index d78e63a41..2ee0e0fd6 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -269,7 +269,7 @@ export const deployCompose = async ({ } if (freshVolumes && compose.composeType === "docker-compose") { - const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; if (compose.serverId) { await execAsyncRemote(compose.serverId, downWithLog); @@ -395,7 +395,7 @@ export const rebuildCompose = async ({ } if (freshVolumes && compose.composeType === "docker-compose") { - const downCommand = `set -e; docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; + const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`; const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`; if (compose.serverId) { await execAsyncRemote(compose.serverId, downWithLog); From 9be96d4195d80819b6824c8d77fc22100bf2a2d8 Mon Sep 17 00:00:00 2001 From: Guillaume LECOMTE Date: Tue, 14 Apr 2026 11:58:21 +0200 Subject: [PATCH 009/419] fix(ui): update favicon dynamically based on app theme Add icon-light.svg and icon-dark.svg with fixed fills (black/white) instead of relying on CSS prefers-color-scheme media query. Update WhitelabelingProvider to use useTheme() and switch favicon based on resolvedTheme (dark/light/unknown). When custom faviconUrl is set, it takes precedence. Closes #4100. --- .../proprietary/whitelabeling/whitelabeling-provider.tsx | 9 ++++++++- apps/dokploy/public/icon-dark.svg | 5 +++++ apps/dokploy/public/icon-light.svg | 5 +++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/public/icon-dark.svg create mode 100644 apps/dokploy/public/icon-light.svg diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx index a1a327561..23c99a78e 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx @@ -1,21 +1,28 @@ "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"); + if (!config) return null; return ( <> {config.metaTitle && {config.metaTitle}} - {config.faviconUrl && } + {config.customCss && ( diff --git a/apps/dokploy/public/icon-dark.svg b/apps/dokploy/public/icon-dark.svg new file mode 100644 index 000000000..5faafdb37 --- /dev/null +++ b/apps/dokploy/public/icon-dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/dokploy/public/icon-light.svg b/apps/dokploy/public/icon-light.svg new file mode 100644 index 000000000..6121a89dd --- /dev/null +++ b/apps/dokploy/public/icon-light.svg @@ -0,0 +1,5 @@ + + + + + From 689ae51ee75db7704f977d8e66b77687d095a867 Mon Sep 17 00:00:00 2001 From: Guillaume LECOMTE Date: Tue, 14 Apr 2026 12:16:57 +0200 Subject: [PATCH 010/419] fix(ui): remove early return to render theme-aware favicon immediately The previous implementation returned null when config was not yet loaded, preventing the theme-aware favicon from rendering during initial page load. Now uses optional chaining (config?.metaTitle, config?.customCss) throughout so the always renders with the correct theme-aware favicon regardless of config loading state. Fixes P1 issue from code review. --- .../proprietary/whitelabeling/whitelabeling-provider.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx index 23c99a78e..7fce2a935 100644 --- a/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx +++ b/apps/dokploy/components/proprietary/whitelabeling/whitelabeling-provider.tsx @@ -16,16 +16,14 @@ export function WhitelabelingProvider() { : resolvedTheme === "light" ? "/icon-light.svg" : "/icon.svg"); - if (!config) return null; - return ( <> - {config.metaTitle && {config.metaTitle}} + {config?.metaTitle && {config.metaTitle}} - {config.customCss && ( + {config?.customCss && ( - - Dokploy - - {getLayout()} diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 120bb827e..bfe175c80 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -1,10 +1,104 @@ -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; +} + +// Cache the resolved favicon (inlined as a data URI) so we don't re-fetch the +// remote image on every server render. Keyed by the configured favicon URL. +const FAVICON_CACHE_TTL = 60 * 60 * 1000; // 1 hour + +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; + var __FAVICON_CACHE: Map; +} + +const faviconCache = + globalThis.__FAVICON_CACHE || + new Map(); +globalThis.__FAVICON_CACHE = faviconCache; + +/** + * Resolve the favicon to an inline data URI so it is present in the initial + * HTML and renders without a network round-trip (no flash of the default + * favicon). Falls back to the raw URL if the image can't be fetched. + */ +async function resolveFaviconHref( + faviconUrl: string | null | undefined, +): Promise { + if (!faviconUrl) return null; + + const cached = faviconCache.get(faviconUrl); + if (cached && cached.expiresAt > Date.now()) { + return cached.href; + } + + // Default to the raw URL so the custom favicon still loads if inlining fails. + let href = faviconUrl; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const response = await fetch(faviconUrl, { signal: controller.signal }); + clearTimeout(timeout); + + if (response.ok) { + const buffer = Buffer.from(await response.arrayBuffer()); + // Avoid embedding very large images directly in the HTML. + if (buffer.byteLength <= 512 * 1024) { + const contentType = response.headers.get("content-type") || "image/png"; + href = `data:${contentType};base64,${buffer.toString("base64")}`; + } + } + } catch { + // Keep the raw URL fallback. + } + + faviconCache.set(faviconUrl, { + href, + expiresAt: Date.now() + FAVICON_CACHE_TTL, + }); + return href; +} + +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. - Remove SSR favicon fetching to prevent SSRF and internal network scanning, falling back to client-side fetching. --- apps/dokploy/pages/_document.tsx | 81 ++++--------------- .../api/routers/proprietary/whitelabeling.ts | 3 - 2 files changed, 16 insertions(+), 68 deletions(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index bfe175c80..2046b6f9f 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -14,70 +14,7 @@ interface WhitelabelingDocumentProps { customCss: string | null; } -// Cache the resolved favicon (inlined as a data URI) so we don't re-fetch the -// remote image on every server render. Keyed by the configured favicon URL. -const FAVICON_CACHE_TTL = 60 * 60 * 1000; // 1 hour -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; - var __FAVICON_CACHE: Map; -} - -const faviconCache = - globalThis.__FAVICON_CACHE || - new Map(); -globalThis.__FAVICON_CACHE = faviconCache; - -/** - * Resolve the favicon to an inline data URI so it is present in the initial - * HTML and renders without a network round-trip (no flash of the default - * favicon). Falls back to the raw URL if the image can't be fetched. - */ -async function resolveFaviconHref( - faviconUrl: string | null | undefined, -): Promise { - if (!faviconUrl) return null; - - const cached = faviconCache.get(faviconUrl); - if (cached && cached.expiresAt > Date.now()) { - return cached.href; - } - - // Default to the raw URL so the custom favicon still loads if inlining fails. - let href = faviconUrl; - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const response = await fetch(faviconUrl, { signal: controller.signal }); - clearTimeout(timeout); - - if (response.ok) { - const buffer = Buffer.from(await response.arrayBuffer()); - // Avoid embedding very large images directly in the HTML. - if (buffer.byteLength <= 512 * 1024) { - const contentType = response.headers.get("content-type") || "image/png"; - href = `data:${contentType};base64,${buffer.toString("base64")}`; - } - } - } catch { - // Keep the raw URL fallback. - } - - faviconCache.set(faviconUrl, { - href, - expiresAt: Date.now() + FAVICON_CACHE_TTL, - }); - return href; -} export default function Document({ metaTitle, @@ -108,6 +45,19 @@ export default function Document({ ); } +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 => { @@ -132,8 +82,9 @@ Document.getInitialProps = async ( const config = await getPublicWhitelabelingConfig(); if (config) { metaTitle = config.metaTitle; - customCss = config.customCss; - faviconHref = await resolveFaviconHref(config.faviconUrl); + // Remove any tags to prevent XSS breakout + customCss = config.customCss ? config.customCss.replace(/<\/style>/gi, "") : null; + faviconHref = config.faviconUrl || null; } } catch { // Fall back to defaults if settings can't be read (e.g. DB not ready) diff --git a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts index 6bc0d0f8f..bc38c8ff1 100644 --- a/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts +++ b/apps/dokploy/server/api/routers/proprietary/whitelabeling.ts @@ -17,9 +17,6 @@ import { /** Invalidate the SSR branding caches in _document.tsx so the next request picks up fresh settings. */ function clearBrandingSSRCache() { globalThis.__SETTINGS_CACHE = null; - if (globalThis.__FAVICON_CACHE) { - globalThis.__FAVICON_CACHE.clear(); - } } export const whitelabelingRouter = createTRPCRouter({ From 58f3714ddc5ad256bb039801623691d9c83b7d78 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:01:38 +0000 Subject: [PATCH 307/419] [autofix.ci] apply automated fixes --- apps/dokploy/pages/_document.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 2046b6f9f..127a153d1 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -14,8 +14,6 @@ interface WhitelabelingDocumentProps { customCss: string | null; } - - export default function Document({ metaTitle, faviconHref, @@ -83,7 +81,9 @@ Document.getInitialProps = async ( if (config) { metaTitle = config.metaTitle; // Remove any tags to prevent XSS breakout - customCss = config.customCss ? config.customCss.replace(/<\/style>/gi, "") : null; + customCss = config.customCss + ? config.customCss.replace(/<\/style>/gi, "") + : null; faviconHref = config.faviconUrl || null; } } catch { From 5606a2bd1ebe1278cadae974aa70e70460fd1ace Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 2 Sep 2026 01:44:46 +0530 Subject: [PATCH 308/419] fix: account for whitespace in style tag regex to prevent XSS --- apps/dokploy/pages/_document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index 127a153d1..b50fd6f12 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -82,7 +82,7 @@ Document.getInitialProps = async ( metaTitle = config.metaTitle; // Remove any tags to prevent XSS breakout customCss = config.customCss - ? config.customCss.replace(/<\/style>/gi, "") + ? config.customCss.replace(/<\/\s*style\s*>/gi, "") : null; faviconHref = config.faviconUrl || null; } From 13bb1ac5d155775ad30d591955d96935801f864b Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Wed, 2 Sep 2026 01:49:35 +0530 Subject: [PATCH 309/419] fix: account for all style end-tag variants like --- apps/dokploy/pages/_document.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/pages/_document.tsx b/apps/dokploy/pages/_document.tsx index b50fd6f12..88e2af9a4 100644 --- a/apps/dokploy/pages/_document.tsx +++ b/apps/dokploy/pages/_document.tsx @@ -82,7 +82,7 @@ Document.getInitialProps = async ( metaTitle = config.metaTitle; // Remove any tags to prevent XSS breakout customCss = config.customCss - ? config.customCss.replace(/<\/\s*style\s*>/gi, "") + ? config.customCss.replace(/<\/\s*style[^>]*>/gi, "") : null; faviconHref = config.faviconUrl || null; } From 6d51c100ae76469ef464c7b39e0bd1bb2d2725ee Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:02:55 +0000 Subject: [PATCH 310/419] [autofix.ci] apply automated fixes --- apps/dokploy/components/layouts/side.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index cd38dc53e..66504d7e2 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -29,7 +29,6 @@ import { type LucideIcon, Package, Palette, - Server, ShieldCheck, Smartphone, @@ -755,7 +754,6 @@ function SidebarLogo() { : "Set as default" } > - {isDefault ? ( Date: Wed, 2 Sep 2026 01:55:12 +0530 Subject: [PATCH 311/419] fix: resolve SVG unicode base64 encoding error and image upload race conditions --- .../organization/handle-organization.tsx | 69 +++++++++++++++---- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index bc2e015d0..81fc12534 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -1,7 +1,7 @@ import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; import DOMPurify from "dompurify"; import { GlobeIcon, PenBoxIcon, Plus, X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; @@ -49,7 +49,12 @@ const sanitizeSvg = (svgContent: string): string | null => { ADD_TAGS: ["use"], }); if (!clean) return null; - return `data:image/svg+xml;base64,${btoa(clean)}`; + const bytes = new TextEncoder().encode(clean); + let binString = ""; + for (let i = 0; i < bytes.length; i++) { + binString += String.fromCharCode(bytes[i]!); + } + return `data:image/svg+xml;base64,${btoa(binString)}`; }; const resizeImage = (file: File, maxSize: number): Promise => { @@ -97,6 +102,8 @@ export function AddOrganization({ }: Props) { const [internalOpen, setInternalOpen] = useState(false); const [uploadedFileName, setUploadedFileName] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const uploadCounter = useRef(0); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen; @@ -124,6 +131,8 @@ export function AddOrganization({ useEffect(() => { if (organization) { + uploadCounter.current++; + setIsUploading(false); form.reset({ name: organization.name, logo: organization.logo || "", @@ -133,6 +142,7 @@ export function AddOrganization({ }, [organization, form]); const onSubmit = async (values: OrganizationFormValues) => { + if (isUploading) return; await mutateAsync({ name: values.name, logo: values.logo, @@ -165,6 +175,9 @@ export function AddOrganization({ const file = files[0]; if (!file) return; + const currentUploadId = ++uploadCounter.current; + setIsUploading(true); + const allowedTypes = [ "image/jpeg", "image/jpg", @@ -180,42 +193,68 @@ export function AddOrganization({ !allowedExtensions.includes(fileExtension || "") ) { toast.error("Only JPG, JPEG, PNG, WEBP, and SVG files are allowed"); + setIsUploading(false); return; } if (file.size > 2 * 1024 * 1024) { toast.error("Image size must be less than 2MB"); + setIsUploading(false); return; } const isSvg = file.type === "image/svg+xml" || fileExtension === "svg"; if (isSvg) { - const text = await file.text(); - const sanitizedDataUrl = sanitizeSvg(text); - if (!sanitizedDataUrl) { - toast.error("Invalid SVG file"); - return; + try { + const text = await file.text(); + const sanitizedDataUrl = sanitizeSvg(text); + if (currentUploadId !== uploadCounter.current) return; + if (!sanitizedDataUrl) { + toast.error("Invalid SVG file"); + return; + } + form.setValue("logo", sanitizedDataUrl); + form.trigger("logo"); + setUploadedFileName(file.name); + } catch (error) { + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing SVG"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } } - form.setValue("logo", sanitizedDataUrl); - form.trigger("logo"); - setUploadedFileName(file.name); return; } // Resize raster images to max 256x256 and convert to WebP to save space try { const resizedDataUrl = await resizeImage(file, 256); + if (currentUploadId !== uploadCounter.current) return; form.setValue("logo", resizedDataUrl); form.trigger("logo"); setUploadedFileName(file.name); } catch (error) { - toast.error("Error processing image"); + if (currentUploadId === uploadCounter.current) { + toast.error("Error processing image"); + } + } finally { + if (currentUploadId === uploadCounter.current) { + setIsUploading(false); + } } }; return ( - + { + if (!val) { + uploadCounter.current++; + setIsUploading(false); + } + setOpen(val); + }}> {organizationId ? ( From 3893150f5cd1685d7271c90915cb22ad90da5f76 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:57:36 +0000 Subject: [PATCH 312/419] [autofix.ci] apply automated fixes --- .../organization/handle-organization.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/dokploy/components/dashboard/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index 81fc12534..5c7b20194 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -248,13 +248,16 @@ export function AddOrganization({ }; return ( - { - if (!val) { - uploadCounter.current++; - setIsUploading(false); - } - setOpen(val); - }}> + { + if (!val) { + uploadCounter.current++; + setIsUploading(false); + } + setOpen(val); + }} + > {organizationId ? ( + )} +
+ + +
    + {visibleStepIds.map((id, index) => { + const step = steps.find((s) => s.id === id)!; + const isDone = index < visibleIndex; + const isCurrent = stepper.current.id === step.id; + return ( +
  1. +
    + + {isDone ? ( + + ) : ( + String(index + 1).padStart(2, "0") + )} + + {index < visibleStepIds.length - 1 && ( + + )} +
    + {isDone ? ( + + ) : ( +

    + {step.title} +

    + )} +
  2. + ); + })} +
+
+ +
+ {!isLastVisible && ( + + )} +
+ + + + + + + + + + + + + +
+
+
+ + +
+
+ {stepper.switch({ + welcome: () => , + plan: () => , + project: () => ( + { + setProjectId(project.projectId); + setEnvironmentId(project.environmentId); + setOnboardingState({ + projectId: project.projectId, + environmentId: project.environmentId, + }); + goToNextVisible(); + }} + /> + ), + server: () => , + deploy: () => ( + + ), + complete: () => ( + + ), + })} +
+
+
+ + + + + Skip onboarding? + + If this is your first time using Dokploy, we recommend going + through these steps — it only takes a couple of minutes and gives + you a feel for how projects, servers and deployments fit together. + + + + + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx new file mode 100644 index 000000000..7378ab57d --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx @@ -0,0 +1,133 @@ +import { + BookIcon, + DatabaseIcon, + GitMergeIcon, + GlobeIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import ConfettiExplosion from "react-confetti-explosion"; +import { Button } from "@/components/ui/button"; +import { displayFont } from "../font"; + +interface Props { + projectId?: string; + environmentId?: string; + onFinish: () => void | Promise; +} + +const features = [ + { + icon: DatabaseIcon, + title: "Databases", + description: "Postgres, MySQL, MongoDB, Redis and more, one click away.", + }, + { + icon: GlobeIcon, + title: "Custom domains", + description: "Attach your own domains and get automatic HTTPS.", + }, + { + icon: GitMergeIcon, + title: "CI/CD", + description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.", + }, + { + icon: UsersIcon, + title: "Team collaboration", + description: "Invite teammates with fine-grained permissions.", + }, +]; + +export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => { + const router = useRouter(); + const [showConfetti, setShowConfetti] = useState(false); + const [isFinishing, setIsFinishing] = useState(false); + + useEffect(() => { + setShowConfetti(true); + }, []); + + const projectHref = + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects"; + + const handleFinish = async () => { + setIsFinishing(true); + await onFinish(); + router.push(projectHref); + }; + + return ( +
+
+ {showConfetti && ( + + )} +
+ +
+ + Done + +

+ You're all set. +

+

+ This is just the beginning — here's some of what else you can do. +

+
+ +
+ {features.map((feature, index) => ( +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ + + {feature.title} + + + {feature.description} + +
+
+ ))} +
+ +
+ + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx new file mode 100644 index 000000000..141aef2d3 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx @@ -0,0 +1,295 @@ +import copy from "copy-to-clipboard"; +import { + ArrowUpRightIcon, + CheckCircle2, + CopyIcon, + Loader2, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +interface Props { + environmentId?: string; + onNext: () => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +const CURATED_TEMPLATES = [ + { id: "wordpress", name: "WordPress", logo: "wordpress.png" }, + { id: "ghost", name: "Ghost", logo: "ghost.jpeg" }, + { id: "n8n", name: "n8n", logo: "n8n.png" }, + { id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" }, +]; + +type Deploying = { kind: "app" | "template"; id: string; url: string }; + +export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const serverId = servers?.[0]?.serverId; + + const [deploying, setDeploying] = useState(null); + const [starting, setStarting] = useState(null); + + const { mutateAsync: deployNginx } = + api.application.deployNginxQuickstart.useMutation(); + const { mutateAsync: deployTemplate } = + api.compose.deployTemplate.useMutation(); + const { mutateAsync: deployCompose } = api.compose.deploy.useMutation(); + const utils = api.useUtils(); + + const { data: application } = api.application.one.useQuery( + { applicationId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "app", + refetchInterval: (query) => { + const status = query.state.data?.applicationStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + const { data: compose } = api.compose.one.useQuery( + { composeId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "template", + refetchInterval: (query) => { + const status = query.state.data?.composeStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + + const status = + deploying?.kind === "app" + ? application?.applicationStatus + : compose?.composeStatus; + + if (!environmentId || (isCloud && !serverId)) { + return ( +
+
+ + First deploy + +

Deploy something.

+
+ + {isCloud + ? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard." + : "You'll need a project before deploying — you can do that anytime from the dashboard."} + + +
+ ); + } + + const handleNginx = async () => { + setStarting("nginx"); + try { + const res = await deployNginx({ environmentId, serverId }); + setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the app", + ); + } finally { + setStarting(null); + } + }; + + const handleTemplate = async (id: string) => { + setStarting(id); + try { + const composeResult = await deployTemplate({ + environmentId, + serverId, + id, + }); + await deployCompose({ composeId: composeResult.composeId }); + const domains = await utils.domain.byComposeId.fetch({ + composeId: composeResult.composeId, + }); + const host = domains?.[0]?.host; + setDeploying({ + kind: "template", + id: composeResult.composeId, + url: host ? `http://${host}` : "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the template", + ); + } finally { + setStarting(null); + } + }; + + if (deploying) { + const isDone = status === "done"; + const isError = status === "error"; + + return ( +
+
+ + {isDone ? ( + + ) : isError ? ( + + ) : ( + + )} + {isDone ? "Live" : isError ? "Deploy failed" : "Deploying"} + +

+ {isDone + ? "It's live." + : isError + ? "Something went wrong." + : "Building your app..."} +

+

+ {isDone + ? "Your app is up and reachable at:" + : isError + ? "The deployment failed — you can check the logs from the dashboard later." + : "This usually takes a minute or two the first time."} +

+
+ + {isDone && deploying.url ? ( +
+
+ + + Reachable now + +
+
+ + {deploying.url} + +
+ + +
+
+
+ ) : !isDone && !isError ? ( +
+
+ + Waiting for the build to finish... +
+
+ ) : null} + + +
+ ); + } + + return ( +
+
+ + First deploy + +

Ship something.

+

+ Pick a quickstart — we'll generate a domain and deploy it for you. +

+
+ +
+ + +
+
+

Docker Compose template

+

+ Popular open source stacks, one click away. +

+
+
+ {CURATED_TEMPLATES.map((template) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx new file mode 100644 index 000000000..1f563854f --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -0,0 +1,207 @@ +import { loadStripe } from "@stripe/stripe-js"; +import { ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + calculatePriceHobby, + calculatePriceStartup, + STARTUP_SERVERS_INCLUDED, +} from "@/components/dashboard/settings/billing/show-billing"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +interface Props { + onNext: () => void; +} + +export const PlanStep = ({ onNext }: Props) => { + const [loadingTier, setLoadingTier] = useState< + "hobby" | "startup" | "trial" | null + >(null); + const { data } = api.stripe.getProducts.useQuery(); + const { mutateAsync: createCheckoutSession } = + api.stripe.createCheckoutSession.useMutation(); + const { mutateAsync: startFreeTrial } = api.stripe.startFreeTrial.useMutation(); + const utils = api.useUtils(); + + const handleCheckout = async (tier: "hobby" | "startup") => { + if (!data) return; + const productId = + tier === "hobby" ? data.hobbyProductId : data.startupProductId; + if (!productId) return; + setLoadingTier(tier); + try { + const stripe = await stripePromise; + const session = await createCheckoutSession({ + tier, + productId, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await stripe?.redirectToCheckout({ sessionId: session.sessionId }); + } catch { + toast.error("Error starting checkout"); + setLoadingTier(null); + } + }; + + const handleTrial = async () => { + setLoadingTier("trial"); + try { + await startFreeTrial(); + await utils.project.onboardingStatus.invalidate(); + toast.success("Your 14-day trial has started"); + onNext(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error starting trial", + ); + } finally { + setLoadingTier(null); + } + }; + + return ( +
+
+ + Billing + +

+ Start free, upgrade when ready. +

+

+ No credit card for the trial — add one only if you decide to stay. +

+
+ +
+
+
+ + Recommended + +

+ 14-day free trial +

+

+ No card required — cancel anytime. +

+
    + {["1 server included", "Unlimited apps & databases", "Community support"].map( + (f) => ( +
  • + + {f} +
  • + ), + )} +
+
+ +
+
+ +
+
+
+

Hobby

+

+ For individual developers +

+

+ ${calculatePriceHobby(1, false).toFixed(2)} + + {" "} + /mo + +

+
    + {[ + "1 server included", + "Unlimited apps & databases", + "2 environments", + "Community support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+ +
+
+

Startup

+

+ For small to mid-size teams +

+

+ ${calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(2)} + + {" "} + /mo + +

+
    + {[ + `${STARTUP_SERVERS_INCLUDED} servers included`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx new file mode 100644 index 000000000..24c37d9d9 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -0,0 +1,118 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + 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 { displayFont } from "../font"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), +}); +type Schema = z.infer; + +interface Props { + onNext: (project: { projectId: string; environmentId: string }) => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +export const ProjectStep = ({ onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { mutateAsync, isPending } = api.project.create.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + defaultValues: { name: "My First Project", description: "" }, + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: Schema) => { + try { + const result = await mutateAsync(data); + await utils.project.all.invalidate(); + toast.success("Project created"); + onNext({ + projectId: result.project.projectId, + environmentId: result.environment?.environmentId ?? "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error creating the project", + ); + } + }; + + return ( +
+
+ + Workspace + +

Create your first project.

+

+ Projects group your apps, databases and environments together. +

+
+ +
+ + ( + + Name + + + + + + )} + /> + ( + + Description (optional) + +