From f5c21db1ef47b47f0a1bf3180eaed60373f13c4b Mon Sep 17 00:00:00 2001 From: Souvik Kumar Date: Sun, 9 Aug 2026 21:05:37 +0530 Subject: [PATCH 01/28] fix: mark session cookie as Secure when served over HTTPS Self-hosted better-auth is configured with secure cookies off so plain HTTP access (http://:3000) keeps working, but that also meant the session cookie never got the Secure attribute even behind HTTPS. A cookie without Secure can be replayed over plain HTTP. Add Secure to the auth response cookies per request when X-Forwarded-Proto is https (what Traefik sends on TLS), leaving plain-HTTP requests untouched. Fixes #4709 --- .../__test__/lib/secure-cookies.test.ts | 46 +++++++++++++++++++ apps/dokploy/lib/secure-cookies.ts | 25 ++++++++++ apps/dokploy/pages/api/auth/[...all].ts | 25 +++++++++- 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/lib/secure-cookies.test.ts create mode 100644 apps/dokploy/lib/secure-cookies.ts diff --git a/apps/dokploy/__test__/lib/secure-cookies.test.ts b/apps/dokploy/__test__/lib/secure-cookies.test.ts new file mode 100644 index 000000000..c47633b4a --- /dev/null +++ b/apps/dokploy/__test__/lib/secure-cookies.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; + +describe("isHttpsRequest", () => { + it("returns true when X-Forwarded-Proto is https", () => { + expect(isHttpsRequest("https")).toBe(true); + }); + + it("returns false for http or a missing header", () => { + expect(isHttpsRequest("http")).toBe(false); + expect(isHttpsRequest(undefined)).toBe(false); + }); + + it("uses the left-most value of a proxy chain", () => { + expect(isHttpsRequest("https, http")).toBe(true); + expect(isHttpsRequest("http, https")).toBe(false); + }); + + it("handles an array header value and is case-insensitive", () => { + expect(isHttpsRequest(["HTTPS", "http"])).toBe(true); + }); +}); + +describe("secureSetCookie", () => { + it("adds Secure to a cookie that lacks it", () => { + expect( + secureSetCookie("better-auth.session_token=abc; Path=/; HttpOnly"), + ).toBe("better-auth.session_token=abc; Path=/; HttpOnly; Secure"); + }); + + it("does not duplicate Secure when already present", () => { + const cookie = "better-auth.session_token=abc; Path=/; Secure; HttpOnly"; + expect(secureSetCookie(cookie)).toBe(cookie); + }); + + it("applies to every cookie in an array", () => { + expect(secureSetCookie(["a=1; Path=/", "b=2; Path=/; Secure"])).toEqual([ + "a=1; Path=/; Secure", + "b=2; Path=/; Secure", + ]); + }); + + it("passes a numeric header value through untouched", () => { + expect(secureSetCookie(123)).toBe(123); + }); +}); diff --git a/apps/dokploy/lib/secure-cookies.ts b/apps/dokploy/lib/secure-cookies.ts new file mode 100644 index 000000000..ab11b52ce --- /dev/null +++ b/apps/dokploy/lib/secure-cookies.ts @@ -0,0 +1,25 @@ +export const isHttpsRequest = ( + forwardedProto: string | string[] | undefined, +): boolean => { + if (!forwardedProto) return false; + const value = Array.isArray(forwardedProto) + ? forwardedProto[0] + : forwardedProto; + // take the first value in case a proxy chain sent a list + return value?.split(",")[0]?.trim().toLowerCase() === "https"; +}; + +const hasSecureAttribute = (cookie: string): boolean => + /;\s*secure(?:\s*;|\s*$)/i.test(cookie); + +const withSecure = (cookie: string): string => + hasSecureAttribute(cookie) ? cookie : `${cookie}; Secure`; + +// Add Secure to a Set-Cookie header value (string or string[]). +export const secureSetCookie = ( + value: string | number | readonly string[], +): string | number | string[] => { + if (typeof value === "number") return value; + if (Array.isArray(value)) return value.map(withSecure); + return withSecure(value as string); +}; diff --git a/apps/dokploy/pages/api/auth/[...all].ts b/apps/dokploy/pages/api/auth/[...all].ts index 48aa03706..7edbb9847 100644 --- a/apps/dokploy/pages/api/auth/[...all].ts +++ b/apps/dokploy/pages/api/auth/[...all].ts @@ -1,7 +1,30 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; import { auth } from "@dokploy/server/index"; import { toNodeHandler } from "better-auth/node"; +import { isHttpsRequest, secureSetCookie } from "@/lib/secure-cookies"; // Disallow body parsing, we will parse it manually export const config = { api: { bodyParser: false } }; -export default toNodeHandler(auth.handler); +const handler = toNodeHandler(auth.handler); + +// Mark the session cookie as Secure when the request comes over HTTPS +export default function authHandler(req: IncomingMessage, res: ServerResponse) { + if (isHttpsRequest(req.headers["x-forwarded-proto"])) { + const setHeader = res.setHeader.bind(res); + res.setHeader = (name, value) => + String(name).toLowerCase() === "set-cookie" + ? setHeader(name, secureSetCookie(value)) + : setHeader(name, value); + + const appendHeader = res.appendHeader?.bind(res); + if (appendHeader) { + res.appendHeader = (name, value) => + String(name).toLowerCase() === "set-cookie" + ? appendHeader(name, secureSetCookie(value) as string | string[]) + : appendHeader(name, value); + } + } + + return handler(req, res); +} From 26317e9126d20eca1aed1b0d095fbacc5b1c1075 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sat, 12 Sep 2026 15:50:08 +0530 Subject: [PATCH 02/28] feat(ui): inline deployments filter bar and fix layout jumping --- .../deployments/show-deployments-table.tsx | 295 ++++++++---------- .../overview/show-overview-deployments.tsx | 62 +++- 2 files changed, 188 insertions(+), 169 deletions(-) diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx index e479d9275..ad860745a 100644 --- a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -1,10 +1,8 @@ "use client"; import { - type ColumnFiltersState, flexRender, getCoreRowModel, - getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, @@ -27,7 +25,6 @@ import Link from "next/link"; import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -97,14 +94,21 @@ function getServiceInfo(d: DeploymentRow) { return null; } -export function ShowDeploymentsTable() { +interface ShowDeploymentsTableProps { + globalFilter: string; + statusFilter: string; + typeFilter: string; +} + +export function ShowDeploymentsTable({ + globalFilter, + statusFilter, + typeFilter, +}: ShowDeploymentsTableProps) { const [sorting, setSorting] = useState([ { id: "createdAt", desc: true }, ]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50, @@ -446,177 +450,140 @@ export function ShowDeploymentsTable() { columns, state: { sorting, - columnFilters, - globalFilter, pagination, }, onSortingChange: setSorting, - onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), }); return ( -
-
- setGlobalFilter(e.target.value)} - className="max-w-xs" - /> - - -
-
- {isLoading ? ( -
- - Loading deployments... -
- ) : ( - <> -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - +
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + ))} + )) + ) : ( + + +
+ +

No deployments found

+

+ Deployments from applications and compose will appear + here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries +
-
-
- - Rows per page - - - - Showing{" "} - {filteredData.length === 0 - ? 0 - : pagination.pageIndex * pagination.pageSize + 1}{" "} - to{" "} - {Math.min( - (pagination.pageIndex + 1) * pagination.pageSize, - filteredData.length, - )}{" "} - of {filteredData.length} entries - -
-
- - -
+
+ +
- - )} -
+
+ + )}
); } diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx index be12da353..28b433fa6 100644 --- a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -1,5 +1,6 @@ import { Rocket } from "lucide-react"; import { useRouter } from "next/router"; +import { useState } from "react"; import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; import { @@ -8,6 +9,14 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const SUBTAB_VALUES = ["deployments", "queue"] as const; @@ -26,6 +35,10 @@ export const ShowOverviewDeployments = () => { ? router.query.subtab : DEFAULT_SUBTAB; + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const setSubtab = (value: string) => { if (!isValidSubtab(value)) return; const { subtab: _current, ...query } = router.query; @@ -59,12 +72,51 @@ export const ShowOverviewDeployments = () => { onValueChange={setSubtab} className="w-full min-w-0" > - - Deployments - Queue - + {/* Responsive layout: Tabs on top and filters wrap below on mobile, inline single-row on md+ */} +
+ + Deployments + Queue + + {subtab === "deployments" && ( +
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+ )} +
- + From 1e9873b3e4fcb6a3dbf2231f17cf3dac796f8170 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Sat, 12 Sep 2026 15:59:18 +0530 Subject: [PATCH 03/28] fix(ui): change filter layout breakpoint to lg to prevent overflow on tablets --- .../dashboard/overview/show-overview-deployments.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx index 28b433fa6..7075f6bc6 100644 --- a/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx +++ b/apps/dokploy/components/dashboard/overview/show-overview-deployments.tsx @@ -72,14 +72,14 @@ export const ShowOverviewDeployments = () => { onValueChange={setSubtab} className="w-full min-w-0" > - {/* Responsive layout: Tabs on top and filters wrap below on mobile, inline single-row on md+ */} -
+ {/* Responsive layout: Tabs on top and filters wrap below on mobile/tablet, inline single-row on lg+ */} +
Deployments Queue {subtab === "deployments" && ( -
+
Date: Sun, 13 Sep 2026 22:22:36 +0530 Subject: [PATCH 04/28] fix: preserve application status when saving provider settings --- .../application/provider-status.test.ts | 73 +++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 7 -- 2 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 apps/dokploy/__test__/application/provider-status.test.ts diff --git a/apps/dokploy/__test__/application/provider-status.test.ts b/apps/dokploy/__test__/application/provider-status.test.ts new file mode 100644 index 000000000..c4190a1d6 --- /dev/null +++ b/apps/dokploy/__test__/application/provider-status.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateApplication = vi.hoisted(() => vi.fn()); + +vi.mock("@dokploy/server/services/permission", () => { + return { + checkServicePermissionAndAccess: vi.fn(async () => undefined), + }; +}); + +vi.mock("@dokploy/server/index", () => ({ + IS_CLOUD: true, + hasValidLicense: vi.fn(async () => false), + updateApplication: mockUpdateApplication, + findApplicationById: vi.fn(async () => ({ + applicationId: "app-1", + appName: "example-app", + })), +})); + +vi.mock("@dokploy/server/lib/auth", () => ({ + validateRequest: vi.fn(), +})); + +vi.mock("@/server/api/utils/audit", () => ({ + audit: vi.fn(async () => undefined), +})); + +const { applicationRouter } = await import("@/server/api/routers/application"); + +const caller = applicationRouter.createCaller({ + session: { activeOrganizationId: "org-1" }, + user: { id: "user-1", email: "user@example.com", role: "owner" }, +} as Parameters[0]); + +describe("saving an application provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(["done", "running"])( + "keeps the %s application status when GitHub settings change", + async (status) => { + let savedStatus = status; + mockUpdateApplication.mockImplementation(async (_id, update) => { + if (update.applicationStatus !== undefined) { + savedStatus = update.applicationStatus; + } + }); + + await caller.saveGithubProvider({ + applicationId: "app-1", + repository: "example-repo", + owner: "example-owner", + branch: "main", + buildPath: "/", + githubId: "provider-1", + watchPaths: ["src/**"], + triggerType: "push", + enableSubmodules: false, + }); + + expect(savedStatus).toBe(status); + expect(mockUpdateApplication).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ + watchPaths: ["src/**"], + sourceType: "github", + }), + ); + }, + ); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 01a9534d9..79f029360 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -545,7 +545,6 @@ export const applicationRouter = createTRPCRouter({ sourceType: "github", owner: input.owner, buildPath: input.buildPath, - applicationStatus: "idle", githubId: input.githubId, watchPaths: input.watchPaths, triggerType: input.triggerType, @@ -572,7 +571,6 @@ export const applicationRouter = createTRPCRouter({ gitlabBranch: input.gitlabBranch, gitlabBuildPath: input.gitlabBuildPath, sourceType: "gitlab", - applicationStatus: "idle", gitlabId: input.gitlabId, gitlabProjectId: input.gitlabProjectId, gitlabPathNamespace: input.gitlabPathNamespace, @@ -601,7 +599,6 @@ export const applicationRouter = createTRPCRouter({ bitbucketBranch: input.bitbucketBranch, bitbucketBuildPath: input.bitbucketBuildPath, sourceType: "bitbucket", - applicationStatus: "idle", bitbucketId: input.bitbucketId, watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, @@ -627,7 +624,6 @@ export const applicationRouter = createTRPCRouter({ giteaBranch: input.giteaBranch, giteaBuildPath: input.giteaBuildPath, sourceType: "gitea", - applicationStatus: "idle", giteaId: input.giteaId, watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, @@ -652,7 +648,6 @@ export const applicationRouter = createTRPCRouter({ username: input.username, password: input.password, sourceType: "docker", - applicationStatus: "idle", registryUrl: input.registryUrl, }); const application = await findApplicationById(input.applicationId); @@ -676,7 +671,6 @@ export const applicationRouter = createTRPCRouter({ customGitUrl: input.customGitUrl, customGitSSHKeyId: input.customGitSSHKeyId, sourceType: "git", - applicationStatus: "idle", watchPaths: input.watchPaths, enableSubmodules: input.enableSubmodules, }); @@ -729,7 +723,6 @@ export const applicationRouter = createTRPCRouter({ customGitSSHKeyId: null, sourceType: "github", // Reset to default - applicationStatus: "idle", watchPaths: null, enableSubmodules: false, }); From e120340e77ed180ae58b6ecf1c6dada5e21021f1 Mon Sep 17 00:00:00 2001 From: Roshan Singh Date: Sun, 13 Sep 2026 22:28:06 +0530 Subject: [PATCH 05/28] test: cover status preservation for every provider save --- .../application/provider-status.test.ts | 133 ++++++++++++++---- 1 file changed, 108 insertions(+), 25 deletions(-) diff --git a/apps/dokploy/__test__/application/provider-status.test.ts b/apps/dokploy/__test__/application/provider-status.test.ts index c4190a1d6..427b6e6dd 100644 --- a/apps/dokploy/__test__/application/provider-status.test.ts +++ b/apps/dokploy/__test__/application/provider-status.test.ts @@ -33,22 +33,12 @@ const caller = applicationRouter.createCaller({ user: { id: "user-1", email: "user@example.com", role: "owner" }, } as Parameters[0]); -describe("saving an application provider", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it.each(["done", "running"])( - "keeps the %s application status when GitHub settings change", - async (status) => { - let savedStatus = status; - mockUpdateApplication.mockImplementation(async (_id, update) => { - if (update.applicationStatus !== undefined) { - savedStatus = update.applicationStatus; - } - }); - - await caller.saveGithubProvider({ +const providerChanges = [ + { + name: "GitHub", + sourceType: "github", + save: () => + caller.saveGithubProvider({ applicationId: "app-1", repository: "example-repo", owner: "example-owner", @@ -58,16 +48,109 @@ describe("saving an application provider", () => { watchPaths: ["src/**"], triggerType: "push", enableSubmodules: false, - }); + }), + }, + { + name: "GitLab", + sourceType: "gitlab", + save: () => + caller.saveGitlabProvider({ + applicationId: "app-1", + gitlabRepository: "example-repo", + gitlabOwner: "example-owner", + gitlabBranch: "main", + gitlabBuildPath: "/", + gitlabId: "provider-1", + gitlabProjectId: 1, + gitlabPathNamespace: "example-owner/example-repo", + }), + }, + { + name: "Bitbucket", + sourceType: "bitbucket", + save: () => + caller.saveBitbucketProvider({ + applicationId: "app-1", + bitbucketRepository: "example-repo", + bitbucketRepositorySlug: "example-repo", + bitbucketOwner: "example-owner", + bitbucketBranch: "main", + bitbucketBuildPath: "/", + bitbucketId: "provider-1", + }), + }, + { + name: "Gitea", + sourceType: "gitea", + save: () => + caller.saveGiteaProvider({ + applicationId: "app-1", + giteaRepository: "example-repo", + giteaOwner: "example-owner", + giteaBranch: "main", + giteaBuildPath: "/", + giteaId: "provider-1", + }), + }, + { + name: "Docker", + sourceType: "docker", + save: () => + caller.saveDockerProvider({ + applicationId: "app-1", + dockerImage: "nginx:alpine", + username: "", + password: "", + registryUrl: "", + }), + }, + { + name: "custom Git", + sourceType: "git", + save: () => + caller.saveGitProvider({ + applicationId: "app-1", + customGitUrl: "https://example.com/repo.git", + customGitBranch: "main", + customGitBuildPath: "/", + customGitSSHKeyId: "key-1", + watchPaths: ["src/**"], + enableSubmodules: false, + }), + }, + { + name: "disconnect Git provider", + sourceType: "github", + save: () => caller.disconnectGitProvider({ applicationId: "app-1" }), + }, +]; - expect(savedStatus).toBe(status); - expect(mockUpdateApplication).toHaveBeenCalledWith( - "app-1", - expect.objectContaining({ - watchPaths: ["src/**"], - sourceType: "github", - }), - ); +describe("saving an application provider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(providerChanges)( + "keeps the status when $name settings change", + async ({ save, sourceType }) => { + for (const status of ["done", "running"]) { + mockUpdateApplication.mockClear(); + let savedStatus = status; + mockUpdateApplication.mockImplementation(async (_id, update) => { + if (update.applicationStatus !== undefined) { + savedStatus = update.applicationStatus; + } + }); + + await save(); + + expect(savedStatus).toBe(status); + expect(mockUpdateApplication).toHaveBeenCalledTimes(1); + expect(mockUpdateApplication).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ sourceType }), + ); + } }, ); }); From a87a5c06f0755465be8e09586676ccbb66bbc338 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 14 Sep 2026 11:01:47 -0600 Subject: [PATCH 06/28] feat: account deletion for Dokploy Cloud Self-service deletion from Settings > Profile confirmed with the account password (when present) plus a 6-digit code sent by email, and a root-only "delete by email" tool in the impersonation bar to fulfil GDPR requests. Both paths run the same flow: cancel every Stripe subscription (customer is kept for invoicing), delete the user and all owned organizations in one transaction, anonymize audit trail left in other organizations, reassign pending invitations to the org owner, remove the HubSpot contact when HUBSPOT_ACCESS_TOKEN is configured, and log a JSON deletion record. Also blocks better-auth's raw /admin/remove-user endpoint and makes the Stripe webhook tolerate subscription/customer events for already deleted users. --- .../__test__/utils/account-deletion.test.ts | 182 +++++++++++++++ .../impersonation/delete-account-by-email.tsx | 143 ++++++++++++ .../impersonation/impersonation-bar.tsx | 6 +- .../settings/profile/delete-account.tsx | 220 ++++++++++++++++++ apps/dokploy/pages/api/stripe/webhook.ts | 14 +- .../pages/dashboard/settings/profile.tsx | 2 + apps/dokploy/server/api/routers/user.ts | 146 +++++++++++- apps/dokploy/server/utils/account-deletion.ts | 44 ++++ apps/dokploy/server/utils/billing.ts | 33 +++ .../emails/emails/account-deletion-code.tsx | 87 +++++++ packages/server/src/index.ts | 1 + packages/server/src/lib/auth.ts | 6 + .../server/src/services/account-deletion.ts | 179 ++++++++++++++ packages/server/src/services/user.ts | 10 + packages/server/src/utils/tracking/hubspot.ts | 45 ++++ .../verification/send-verification-email.tsx | 26 +++ 16 files changed, 1122 insertions(+), 22 deletions(-) create mode 100644 apps/dokploy/__test__/utils/account-deletion.test.ts create mode 100644 apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx create mode 100644 apps/dokploy/components/dashboard/settings/profile/delete-account.tsx create mode 100644 apps/dokploy/server/utils/account-deletion.ts create mode 100644 packages/server/src/emails/emails/account-deletion-code.tsx create mode 100644 packages/server/src/services/account-deletion.ts diff --git a/apps/dokploy/__test__/utils/account-deletion.test.ts b/apps/dokploy/__test__/utils/account-deletion.test.ts new file mode 100644 index 000000000..b83ad0b7f --- /dev/null +++ b/apps/dokploy/__test__/utils/account-deletion.test.ts @@ -0,0 +1,182 @@ +import { + checkDeletionCode, + generateDeletionCode, + serializeDeletionCode, +} from "@dokploy/server/services/account-deletion"; +import { deleteHubSpotContactByEmail } from "@dokploy/server/utils/tracking/hubspot"; +import Stripe from "stripe"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { cancelStripeSubscriptions } from "@/server/utils/billing"; + +const makeStripe = ( + subscriptions: Array<{ id: string; status: string }>, + listError?: Error, +) => { + const stripe = { + subscriptions: { + list: vi.fn(() => + listError + ? Promise.reject(listError) + : Promise.resolve({ data: subscriptions }), + ), + cancel: vi.fn(() => Promise.resolve({})), + }, + }; + return stripe as unknown as Stripe & typeof stripe; +}; + +describe("cancelStripeSubscriptions", () => { + test("cancels every non-terminal subscription without invoicing", async () => { + const stripe = makeStripe([ + { id: "sub_active", status: "active" }, + { id: "sub_trial", status: "trialing" }, + { id: "sub_past_due", status: "past_due" }, + { id: "sub_canceled", status: "canceled" }, + { id: "sub_expired", status: "incomplete_expired" }, + ]); + + const result = await cancelStripeSubscriptions("cus_1", stripe); + + expect(stripe.subscriptions.list).toHaveBeenCalledWith({ + customer: "cus_1", + status: "all", + limit: 100, + }); + expect(stripe.subscriptions.cancel).toHaveBeenCalledTimes(3); + expect(stripe.subscriptions.cancel).toHaveBeenCalledWith("sub_active", { + invoice_now: false, + prorate: false, + }); + expect(result).toEqual({ + cancelledSubscriptions: ["sub_active", "sub_trial", "sub_past_due"], + }); + }); + + test("treats a missing customer as having nothing to cancel", async () => { + const missing = new Stripe.errors.StripeInvalidRequestError({ + type: "invalid_request_error", + code: "resource_missing", + message: "No such customer", + }); + const stripe = makeStripe([], missing); + + const result = await cancelStripeSubscriptions("cus_missing", stripe); + + expect(stripe.subscriptions.cancel).not.toHaveBeenCalled(); + expect(result).toEqual({ cancelledSubscriptions: [] }); + }); + + test("propagates unexpected Stripe errors", async () => { + const failure = new Stripe.errors.StripeAPIError({ + type: "api_error", + message: "boom", + }); + const stripe = makeStripe([], failure); + + await expect(cancelStripeSubscriptions("cus_1", stripe)).rejects.toBe( + failure, + ); + }); +}); + +describe("deleteHubSpotContactByEmail", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("is skipped when no access token is configured", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", undefined)).toBe( + "skipped", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("calls the GDPR delete endpoint with the email as id", async () => { + const fetchMock = vi.fn(() => + Promise.resolve(new Response(null, { status: 204 })), + ); + vi.stubGlobal("fetch", fetchMock); + + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "deleted", + ); + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(url).toBe( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + ); + expect(init.method).toBe("POST"); + expect((init.headers as Record).Authorization).toBe( + "Bearer token", + ); + expect(JSON.parse(init.body as string)).toEqual({ + idProperty: "email", + objectId: "a@b.com", + }); + }); + + test("maps 404 to not_found and other errors to failed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response(null, { status: 404 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "not_found", + ); + + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(new Response("nope", { status: 500 }))), + ); + expect(await deleteHubSpotContactByEmail("a@b.com", "token")).toBe( + "failed", + ); + }); +}); + +describe("account deletion codes", () => { + test("generates six-digit numeric codes", () => { + for (let i = 0; i < 50; i++) { + expect(generateDeletionCode()).toMatch(/^\d{6}$/); + } + }); + + test("accepts the right code, ignoring surrounding whitespace", () => { + const value = serializeDeletionCode("482913"); + + expect(checkDeletionCode(value, "482913")).toEqual({ + valid: true, + attempts: 0, + }); + expect(checkDeletionCode(value, " 482913 ").valid).toBe(true); + }); + + test("counts failed attempts without revealing the hash", () => { + const value = serializeDeletionCode("482913"); + + const first = checkDeletionCode(value, "000000"); + expect(first.valid).toBe(false); + if (first.valid) throw new Error("unreachable"); + expect(first.attempts).toBe(1); + expect(JSON.parse(first.nextValue)).toEqual({ + ...JSON.parse(value), + attempts: 1, + }); + + const second = checkDeletionCode(first.nextValue, "999999"); + expect(second.valid).toBe(false); + if (second.valid) throw new Error("unreachable"); + expect(second.attempts).toBe(2); + + expect(checkDeletionCode(second.nextValue, "482913")).toEqual({ + valid: true, + attempts: 2, + }); + expect(value).not.toContain("482913"); + }); +}); diff --git a/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx b/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx new file mode 100644 index 000000000..5f940dd5b --- /dev/null +++ b/apps/dokploy/components/dashboard/impersonation/delete-account-by-email.tsx @@ -0,0 +1,143 @@ +import copy from "copy-to-clipboard"; +import { Copy, Loader2, Trash2 } 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const CONFIRMATION_WORD = "DELETE"; + +export const DeleteAccountByEmail = () => { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [record, setRecord] = useState(null); + const { mutateAsync, isPending } = + api.user.deleteAccountByEmail.useMutation(); + + const canConfirm = + email.trim().length > 0 && confirmation.trim() === CONFIRMATION_WORD; + + const reset = () => { + setEmail(""); + setConfirmation(""); + setRecord(null); + }; + + const handleDelete = async () => { + try { + const result = await mutateAsync({ email: email.trim() }); + setRecord(JSON.stringify(result, null, 2)); + toast.success(`Account ${result.email} deleted`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deleting account", + ); + } + }; + + return ( + { + setOpen(value); + if (!value) reset(); + }} + > + + + + + + Delete an account by email + + Cancels every Stripe subscription and deletes the user with all the + organizations they own. Invoices stay in Stripe for accounting. Use + it to fulfil data deletion requests. + + + {record ? ( +
+ + Deletion completed. Keep this record as evidence of the request + being fulfilled. + +
+							{record}
+						
+ +
+ ) : ( +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setConfirmation(e.target.value)} + /> +
+
+ )} + + {record ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx index c6c72fae6..5ab262ec8 100644 --- a/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx +++ b/apps/dokploy/components/dashboard/impersonation/impersonation-bar.tsx @@ -46,6 +46,7 @@ import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { api } from "@/utils/api"; import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling"; +import { DeleteAccountByEmail } from "./delete-account-by-email"; type User = typeof authClient.$Infer.Session.user; @@ -78,12 +79,12 @@ export const ImpersonationBar = () => { }); const filteredUsers = response.data?.users.filter( - // @ts-ignore + // @ts-expect-error (user) => user.allowImpersonation && data?.user?.email !== user.email, ); if (!response.error) { - // @ts-ignore + // @ts-expect-error setUsers(filteredUsers || []); } } catch (error) { @@ -282,6 +283,7 @@ export const ImpersonationBar = () => { Impersonate +
) : (
diff --git a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx new file mode 100644 index 000000000..9c18831ee --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx @@ -0,0 +1,220 @@ +import { Loader2, Trash2 } from "lucide-react"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth-client"; +import { api } from "@/utils/api"; + +const errorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : fallback; + +export const DeleteAccount = () => { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"request" | "confirm">("request"); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [expiresInMinutes, setExpiresInMinutes] = useState(10); + const { data } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: hasPassword } = api.user.hasPassword.useQuery(); + const requestCode = api.user.requestAccountDeletionCode.useMutation(); + const deleteAccount = api.user.deleteAccount.useMutation(); + + const email = data?.user?.email ?? ""; + const isBusy = requestCode.isPending || deleteAccount.isPending; + + if ( + !data || + hasPassword === undefined || + (!isCloud && data.role === "owner") + ) { + return null; + } + + const reset = () => { + setStep("request"); + setPassword(""); + setCode(""); + }; + + const handleRequestCode = async () => { + try { + const result = await requestCode.mutateAsync( + hasPassword ? { password } : {}, + ); + setExpiresInMinutes(result.expiresInMinutes); + setCode(""); + setStep("confirm"); + toast.success(`We sent a confirmation code to ${email}`); + } catch (error) { + toast.error(errorMessage(error, "Error sending the confirmation code")); + } + }; + + const handleDelete = async () => { + try { + await deleteAccount.mutateAsync({ code }); + toast.success("Your account has been deleted"); + await authClient.signOut().catch(() => undefined); + router.push("/"); + } catch (error) { + toast.error(errorMessage(error, "Error deleting account")); + } + }; + + return ( + + + + + Delete account + + + Permanently remove your account and all the data associated with it. + + + +
    + {isCloud && ( +
  • + Active subscriptions are cancelled immediately. Invoices already + issued are kept for accounting. +
  • + )} +
  • + Every organization you own is deleted, including its projects, + services, environments, domains and server connections. +
  • +
  • + In organizations you belong to but do not own, only your membership, + API keys and the Git providers you connected are removed. + Applications using those providers keep running but must be + reconnected to redeploy. +
  • +
  • Containers already running on your servers are not touched.
  • +
  • + We will email a confirmation code to{" "} + {email} before + anything is deleted. +
  • +
  • This action cannot be undone.
  • +
+
+ { + setOpen(next); + if (!next) reset(); + }} + > + + + + + + Delete your account? + + {step === "request" + ? `This will permanently delete your account and every organization you own. We will send a confirmation code to ${email}.` + : `Enter the ${expiresInMinutes}-minute code we sent to ${email} to confirm the deletion.`} + + +
+ {step === "request" && hasPassword && ( + <> + + setPassword(e.target.value)} + /> + + )} + {step === "confirm" && ( + <> + + + setCode(e.target.value.replace(/\D/g, "")) + } + /> + + + )} +
+ + Cancel + {step === "request" ? ( + + ) : ( + + )} + +
+
+
+
+
+ ); +}; diff --git a/apps/dokploy/pages/api/stripe/webhook.ts b/apps/dokploy/pages/api/stripe/webhook.ts index ef8f90632..9cd16e5d9 100644 --- a/apps/dokploy/pages/api/stripe/webhook.ts +++ b/apps/dokploy/pages/api/stripe/webhook.ts @@ -150,11 +150,9 @@ export default async function handler( newSubscription.customer as string, ); - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } - - if (admin.isEnterpriseCloud) { + // The account may already be gone when the deletion flow cancelled + // the subscription; nothing left to disable. + if (!admin || admin.isEnterpriseCloud) { break; } @@ -297,11 +295,7 @@ export default async function handler( const customer = event.data.object as Stripe.Customer; const admin = await findUserByStripeCustomerId(customer.id); - if (!admin) { - return res.status(400).send("Webhook Error: Admin not found"); - } - - if (admin.isEnterpriseCloud) { + if (!admin || admin.isEnterpriseCloud) { break; } diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index 95ff7aef2..a767658f3 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -5,6 +5,7 @@ import type { ReactElement } from "react"; import superjson from "superjson"; import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys"; import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account"; +import { DeleteAccount } from "@/components/dashboard/settings/profile/delete-account"; import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { appRouter } from "@/server/api/root"; @@ -20,6 +21,7 @@ const Page = () => { {isCloud && } {permissions?.api.read && } +
); diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 0dd06c6e0..10d55cacd 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -1,6 +1,10 @@ import { + ACCOUNT_DELETION_CODE_TTL_MINUTES, + consumeAccountDeletionCode, + createAccountDeletionCode, createApiKey, createOrganizationUserWithCredentials, + findCredentialAccount, findNotificationById, findOrganizationById, findPasskeysByUserId, @@ -11,6 +15,7 @@ import { IS_CLOUD, removeUserById, renderInvitationEmail, + sendAccountDeletionCodeEmail, sendEmailNotification, sendResendNotification, updateUser, @@ -24,6 +29,7 @@ import { apiUpdateUser, invitation, member, + organization, session, user, } from "@dokploy/server/db/schema"; @@ -38,6 +44,7 @@ import { and, asc, desc, eq, gt, ne } from "drizzle-orm"; import { z } from "zod"; import { apiKeyNameSchema } from "@/lib/api-keys"; import { audit } from "@/server/api/utils/audit"; +import { deleteAccount } from "@/server/utils/account-deletion"; import { adminProcedure, createTRPCRouter, @@ -63,6 +70,43 @@ const apiCreateApiKey = z.object({ refillInterval: z.number().optional(), }); +const hasRootAccess = (ctx: { + user: { id: string }; + session: { impersonatedBy?: string } | null; +}) => { + const rootId = process.env.USER_ADMIN_ID; + if (!IS_CLOUD || !rootId) { + return false; + } + return rootId === ctx.user.id || ctx.session?.impersonatedBy === rootId; +}; + +const assertCanDeleteOwnAccount = async (ctx: { + user: { id: string }; + session: { impersonatedBy?: string }; +}) => { + if (ctx.session.impersonatedBy) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Accounts cannot be deleted while impersonating", + }); + } + + if (!IS_CLOUD) { + const ownedOrganization = await db.query.organization.findFirst({ + where: eq(organization.ownerId, ctx.user.id), + columns: { id: true }, + }); + if (ownedOrganization) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Transfer or delete the organizations you own before deleting your account", + }); + } + } +}; + export const userRouter = createTRPCRouter({ all: withPermission("member", "read").query(async ({ ctx }) => { return await db.query.member.findMany({ @@ -178,16 +222,7 @@ export const userRouter = createTRPCRouter({ return findPasskeysByUserId(ctx.user.id); }), haveRootAccess: protectedProcedure.query(async ({ ctx }) => { - if (!IS_CLOUD) { - return false; - } - if ( - process.env.USER_ADMIN_ID === ctx.user.id || - ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID - ) { - return true; - } - return false; + return hasRootAccess(ctx); }), getBackups: adminProcedure.query(async ({ ctx }) => { const memberResult = await db.query.member.findFirst({ @@ -458,6 +493,97 @@ export const userRouter = createTRPCRouter({ }); return result; }), + hasPassword: protectedProcedure.query(async ({ ctx }) => { + const credential = await findCredentialAccount(ctx.user.id); + return !!credential?.password; + }), + requestAccountDeletionCode: protectedProcedure + .input(z.object({ password: z.string().optional() })) + .mutation(async ({ ctx, input }) => { + await assertCanDeleteOwnAccount(ctx); + + const credential = await findCredentialAccount(ctx.user.id); + if ( + credential?.password && + !bcrypt.compareSync(input.password ?? "", credential.password) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The password is incorrect", + }); + } + + const code = await createAccountDeletionCode(ctx.user.id); + try { + await sendAccountDeletionCodeEmail({ + email: ctx.user.email, + userName: ctx.user.name || ctx.user.email, + code, + expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES, + }); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "The confirmation email could not be sent", + }); + } + + return { expiresInMinutes: ACCOUNT_DELETION_CODE_TTL_MINUTES }; + }), + deleteAccount: protectedProcedure + .input(z.object({ code: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + await assertCanDeleteOwnAccount(ctx); + + const { requestedAt } = await consumeAccountDeletionCode( + ctx.user.id, + input.code, + ); + + await audit(ctx, { + action: "delete", + resourceType: "user", + resourceId: ctx.user.id, + metadata: { selfService: true }, + }); + + return deleteAccount(ctx.user.id, ctx.user.id, { requestedAt }); + }), + deleteAccountByEmail: protectedProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ ctx, input }) => { + if (!hasRootAccess(ctx)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the root account can delete accounts by email", + }); + } + + const target = await db.query.user.findFirst({ + where: eq(user.email, input.email.trim().toLowerCase()), + columns: { id: true }, + }); + + if (!target) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "No account found with that email", + }); + } + + if (target.id === process.env.USER_ADMIN_ID) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The root account cannot be deleted", + }); + } + + return deleteAccount( + target.id, + ctx.session.impersonatedBy ?? ctx.user.id, + ); + }), assignPermissions: withPermission("member", "update") .input(apiAssignPermissions) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/utils/account-deletion.ts b/apps/dokploy/server/utils/account-deletion.ts new file mode 100644 index 000000000..cd6ce4689 --- /dev/null +++ b/apps/dokploy/server/utils/account-deletion.ts @@ -0,0 +1,44 @@ +import { + deleteHubSpotContactByEmail, + deleteUserAccountData, + findUserById, + IS_CLOUD, +} from "@dokploy/server"; +import { cancelStripeSubscriptions } from "./billing"; + +export const deleteAccount = async ( + userId: string, + requestedBy: string, + options?: { requestedAt?: Date | null }, +) => { + const target = await findUserById(userId); + const startedAt = new Date().toISOString(); + + const stripe = + IS_CLOUD && target.stripeCustomerId + ? { + customerId: target.stripeCustomerId, + ...(await cancelStripeSubscriptions(target.stripeCustomerId)), + } + : null; + + const { organizationsDeleted } = await deleteUserAccountData(userId); + + const hubspot = IS_CLOUD + ? await deleteHubSpotContactByEmail(target.email) + : "skipped"; + + const record = { + userId, + email: target.email, + requestedBy, + requestedAt: options?.requestedAt?.toISOString() ?? startedAt, + startedAt, + completedAt: new Date().toISOString(), + organizationsDeleted, + stripe, + hubspot, + }; + + return record; +}; diff --git a/apps/dokploy/server/utils/billing.ts b/apps/dokploy/server/utils/billing.ts index f2a98fa02..0faeace7f 100644 --- a/apps/dokploy/server/utils/billing.ts +++ b/apps/dokploy/server/utils/billing.ts @@ -152,3 +152,36 @@ export const getBillingStatus = async ( hasActiveAccess: plan !== null || !!trialingSub, }; }; + +const isMissingResource = (error: unknown) => + error instanceof Stripe.errors.StripeError && + error.code === "resource_missing"; + +export const cancelStripeSubscriptions = async ( + stripeCustomerId: string, + stripe: Stripe = getStripeClient(), +) => { + const subscriptions = await stripe.subscriptions + .list({ customer: stripeCustomerId, status: "all", limit: 100 }) + .catch((error: unknown) => { + if (isMissingResource(error)) return null; + throw error; + }); + + const cancelledSubscriptions: string[] = []; + for (const subscription of subscriptions?.data ?? []) { + if ( + subscription.status === "canceled" || + subscription.status === "incomplete_expired" + ) { + continue; + } + await stripe.subscriptions.cancel(subscription.id, { + invoice_now: false, + prorate: false, + }); + cancelledSubscriptions.push(subscription.id); + } + + return { cancelledSubscriptions }; +}; diff --git a/packages/server/src/emails/emails/account-deletion-code.tsx b/packages/server/src/emails/emails/account-deletion-code.tsx new file mode 100644 index 000000000..e86f00813 --- /dev/null +++ b/packages/server/src/emails/emails/account-deletion-code.tsx @@ -0,0 +1,87 @@ +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { emailTailwindConfig } from "../tailwind-config"; + +export type TemplateProps = { + userName: string; + code: string; + expiresInMinutes: number; +}; + +export const AccountDeletionCodeTemplate = ({ + userName = "User", + code = "123456", + expiresInMinutes = 10, +}: TemplateProps) => { + const previewText = `${code} is your account deletion code`; + return ( + + + {previewText} + + + +
+ Dokploy +
+ +
+ + Confirm your account deletion + + + Hello {userName}, we received a request to permanently delete + your Dokploy account. Enter the code below to confirm it. + + +
+ + {code} + +
+ + + This code expires in {expiresInMinutes} minutes. Once confirmed, + your subscriptions are cancelled and every organization you own + is deleted. This cannot be undone. + +
+ +
+ + If you did not request this, someone else may have access to + your account. Change your password and contact{" "} + + Dokploy support + + . + +
+
+ +
+ + ); +}; + +export default AccountDeletionCodeTemplate; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cd7bef41f..ad862de46 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -7,6 +7,7 @@ export * from "./db/validations/index"; export * from "./lib/auth"; export * from "./lib/logger"; export * from "./monitoring/utils"; +export * from "./services/account-deletion"; export * from "./services/admin"; export * from "./services/application"; export * from "./services/backup"; diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 10d62a5b0..752a8112f 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -126,6 +126,12 @@ const createBetterAuth = () => ...(await resolveTrustedOrigins()), ].filter(Boolean); + if (ctx.path === "/admin/remove-user") { + throw new APIError("FORBIDDEN", { + message: "Use the account deletion flow to remove users", + }); + } + const isBlockedAuthPath = ctx.path.startsWith("/sign-in/email") || ctx.path.startsWith("/sign-in/social") || diff --git a/packages/server/src/services/account-deletion.ts b/packages/server/src/services/account-deletion.ts new file mode 100644 index 000000000..48eb966f0 --- /dev/null +++ b/packages/server/src/services/account-deletion.ts @@ -0,0 +1,179 @@ +import { createHash, randomInt, timingSafeEqual } from "node:crypto"; +import { db } from "@dokploy/server/db"; +import { + auditLog, + backups, + invitation, + organization, + user, + verification, +} from "@dokploy/server/db/schema"; +import { TRPCError } from "@trpc/server"; +import { eq, inArray } from "drizzle-orm"; +import { nanoid } from "nanoid"; + +export const ANONYMIZED_EMAIL = "deleted-user"; +export const ACCOUNT_DELETION_CODE_TTL_MINUTES = 10; +const ACCOUNT_DELETION_CODE_RESEND_SECONDS = 60; +const ACCOUNT_DELETION_CODE_MAX_ATTEMPTS = 5; + +const codeIdentifier = (userId: string) => `account-deletion:${userId}`; + +const hashCode = (code: string) => + createHash("sha256").update(code.trim()).digest("hex"); + +export const generateDeletionCode = () => + randomInt(0, 1_000_000).toString().padStart(6, "0"); + +export const serializeDeletionCode = (code: string) => + JSON.stringify({ hash: hashCode(code), attempts: 0 }); + +export const checkDeletionCode = (value: string, code: string) => { + const stored = JSON.parse(value) as { hash: string; attempts: number }; + const expected = Buffer.from(stored.hash, "hex"); + const actual = Buffer.from(hashCode(code), "hex"); + + if (expected.length === actual.length && timingSafeEqual(expected, actual)) { + return { valid: true as const, attempts: stored.attempts }; + } + + const attempts = stored.attempts + 1; + return { + valid: false as const, + attempts, + nextValue: JSON.stringify({ ...stored, attempts }), + }; +}; + +export const createAccountDeletionCode = async (userId: string) => { + const identifier = codeIdentifier(userId); + const existing = await db.query.verification.findFirst({ + where: eq(verification.identifier, identifier), + }); + + if ( + existing?.createdAt && + Date.now() - existing.createdAt.getTime() < + ACCOUNT_DELETION_CODE_RESEND_SECONDS * 1000 + ) { + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `Wait ${ACCOUNT_DELETION_CODE_RESEND_SECONDS} seconds before requesting a new code`, + }); + } + + await db.delete(verification).where(eq(verification.identifier, identifier)); + + const code = generateDeletionCode(); + const now = new Date(); + await db.insert(verification).values({ + id: nanoid(), + identifier, + value: serializeDeletionCode(code), + expiresAt: new Date( + now.getTime() + ACCOUNT_DELETION_CODE_TTL_MINUTES * 60_000, + ), + createdAt: now, + updatedAt: now, + }); + + return code; +}; + +export const consumeAccountDeletionCode = async ( + userId: string, + code: string, +) => { + const row = await db.query.verification.findFirst({ + where: eq(verification.identifier, codeIdentifier(userId)), + }); + + if (!row) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Request a confirmation code first", + }); + } + + if (row.expiresAt.getTime() < Date.now()) { + await db.delete(verification).where(eq(verification.id, row.id)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The code has expired, request a new one", + }); + } + + const result = checkDeletionCode(row.value, code); + if (!result.valid) { + if (result.attempts >= ACCOUNT_DELETION_CODE_MAX_ATTEMPTS) { + await db.delete(verification).where(eq(verification.id, row.id)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Too many incorrect attempts, request a new code", + }); + } + await db + .update(verification) + .set({ value: result.nextValue, updatedAt: new Date() }) + .where(eq(verification.id, row.id)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The code is incorrect", + }); + } + + await db.delete(verification).where(eq(verification.id, row.id)); + + return { requestedAt: row.createdAt }; +}; + +export const deleteUserAccountData = async (userId: string) => { + return db.transaction(async (tx) => { + const target = await tx.query.user.findFirst({ + where: eq(user.id, userId), + columns: { id: true, email: true }, + }); + + if (!target) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const ownedOrganizations = await tx.query.organization.findMany({ + where: eq(organization.ownerId, userId), + columns: { id: true }, + }); + const organizationIds = ownedOrganizations.map((org) => org.id); + + if (organizationIds.length > 0) { + await tx + .delete(auditLog) + .where(inArray(auditLog.organizationId, organizationIds)); + } + + // Actions performed in organizations owned by someone else stay in + // their audit trail, but without the deleted user's PII. + await tx + .update(auditLog) + .set({ userEmail: ANONYMIZED_EMAIL }) + .where(eq(auditLog.userId, userId)); + + await tx.delete(invitation).where(eq(invitation.email, target.email)); + + // backups.userId has no ON DELETE clause, so a leftover reference would + // abort the user delete. + await tx + .update(backups) + .set({ userId: null }) + .where(eq(backups.userId, userId)); + + await tx.delete(user).where(eq(user.id, userId)); + + return { + email: target.email, + organizationsDeleted: organizationIds.length, + }; + }); +}; diff --git a/packages/server/src/services/user.ts b/packages/server/src/services/user.ts index dbc78c275..f34352b2a 100644 --- a/packages/server/src/services/user.ts +++ b/packages/server/src/services/user.ts @@ -566,3 +566,13 @@ export const createApiKey = async ( return result; }; + +export const findCredentialAccount = async (userId: string) => { + return db.query.account.findFirst({ + where: and( + eq(account.userId, userId), + eq(account.providerId, "credential"), + ), + columns: { id: true, password: true }, + }); +}; diff --git a/packages/server/src/utils/tracking/hubspot.ts b/packages/server/src/utils/tracking/hubspot.ts index d22a9591c..43d0a60aa 100644 --- a/packages/server/src/utils/tracking/hubspot.ts +++ b/packages/server/src/utils/tracking/hubspot.ts @@ -123,3 +123,48 @@ export async function submitToHubSpot( return false; } } + +export type HubSpotDeletionResult = + | "deleted" + | "not_found" + | "skipped" + | "failed"; + +export async function deleteHubSpotContactByEmail( + email: string, + accessToken = process.env.HUBSPOT_ACCESS_TOKEN, +): Promise { + if (!accessToken) { + return "skipped"; + } + + try { + const response = await fetch( + "https://api.hubapi.com/crm/v3/objects/contacts/gdpr-delete", + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ idProperty: "email", objectId: email }), + }, + ); + + if (response.ok) { + return "deleted"; + } + if (response.status === 404) { + return "not_found"; + } + console.error( + "HubSpot GDPR delete error:", + response.status, + await response.text(), + ); + return "failed"; + } catch (error) { + console.error("Error deleting HubSpot contact:", error); + return "failed"; + } +} diff --git a/packages/server/src/verification/send-verification-email.tsx b/packages/server/src/verification/send-verification-email.tsx index 3b6eb1fbb..5c4bee84b 100644 --- a/packages/server/src/verification/send-verification-email.tsx +++ b/packages/server/src/verification/send-verification-email.tsx @@ -1,4 +1,5 @@ import { render } from "@react-email/components"; +import AccountDeletionCodeTemplate from "../emails/emails/account-deletion-code"; import InvitationEmail from "../emails/emails/invitation"; import VerifyEmailTemplate from "../emails/emails/verify-email"; import { sendEmailNotification } from "../utils/notifications/utils"; @@ -91,3 +92,28 @@ export const sendInvitationEmail = async ({ text: html, }); }; + +export const sendAccountDeletionCodeEmail = async ({ + email, + userName, + code, + expiresInMinutes, +}: { + email: string; + userName: string; + code: string; + expiresInMinutes: number; +}) => { + const html = await render( + AccountDeletionCodeTemplate({ + userName: userName || "User", + code, + expiresInMinutes, + }), + ); + await sendEmail({ + email, + subject: `${code} is your account deletion code`, + text: html, + }); +}; From 318ad36967af64379b63c70176228a735581197c Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 14 Sep 2026 11:05:26 -0600 Subject: [PATCH 07/28] feat: restrict self-service account deletion to cloud organization owners --- .../settings/profile/delete-account.tsx | 16 ++++-------- apps/dokploy/server/api/routers/user.ts | 26 +++++++++++-------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx index 9c18831ee..18a9f1bd2 100644 --- a/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx +++ b/apps/dokploy/components/dashboard/settings/profile/delete-account.tsx @@ -44,11 +44,7 @@ export const DeleteAccount = () => { const email = data?.user?.email ?? ""; const isBusy = requestCode.isPending || deleteAccount.isPending; - if ( - !data || - hasPassword === undefined || - (!isCloud && data.role === "owner") - ) { + if (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) { return null; } @@ -96,12 +92,10 @@ export const DeleteAccount = () => {
    - {isCloud && ( -
  • - Active subscriptions are cancelled immediately. Invoices already - issued are kept for accounting. -
  • - )} +
  • + Active subscriptions are cancelled immediately. Invoices already + issued are kept for accounting. +
  • Every organization you own is deleted, including its projects, services, environments, domains and server connections. diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 10d55cacd..576548737 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -85,6 +85,13 @@ const assertCanDeleteOwnAccount = async (ctx: { user: { id: string }; session: { impersonatedBy?: string }; }) => { + if (!IS_CLOUD) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "This feature is only available in Dokploy Cloud", + }); + } + if (ctx.session.impersonatedBy) { throw new TRPCError({ code: "FORBIDDEN", @@ -92,18 +99,15 @@ const assertCanDeleteOwnAccount = async (ctx: { }); } - if (!IS_CLOUD) { - const ownedOrganization = await db.query.organization.findFirst({ - where: eq(organization.ownerId, ctx.user.id), - columns: { id: true }, + const ownedOrganization = await db.query.organization.findFirst({ + where: eq(organization.ownerId, ctx.user.id), + columns: { id: true }, + }); + if (!ownedOrganization) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only organization owners can delete their account", }); - if (ownedOrganization) { - throw new TRPCError({ - code: "FORBIDDEN", - message: - "Transfer or delete the organizations you own before deleting your account", - }); - } } }; From f2ba262499025f22913c77461f12476eadc5d5b1 Mon Sep 17 00:00:00 2001 From: Saleh Alghusson Date: Fri, 18 Sep 2026 13:26:27 -0500 Subject: [PATCH 08/28] feat(ai): add Perplexity integration attribution --- packages/server/src/utils/ai/select-ai-provider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/utils/ai/select-ai-provider.ts b/packages/server/src/utils/ai/select-ai-provider.ts index 2d20075e5..e50cbe55f 100644 --- a/packages/server/src/utils/ai/select-ai-provider.ts +++ b/packages/server/src/utils/ai/select-ai-provider.ts @@ -65,6 +65,7 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) { baseURL: config.apiUrl, headers: { Authorization: `Bearer ${config.apiKey}`, + "X-Pplx-Integration": "dokploy", }, }); case "mistral": From 143dfcafe3e2fd9a8804f991362b9060b3135f50 Mon Sep 17 00:00:00 2001 From: Yash Kumar Date: Mon, 21 Sep 2026 16:01:50 +0530 Subject: [PATCH 09/28] fix: correct add-invitation form reset logic and select state --- .../dashboard/settings/users/add-invitation.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 1c8952d43..7dbe5ac66 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -124,8 +124,11 @@ export const AddInvitation = () => { const mode = form.watch("mode"); useEffect(() => { - form.reset(); - }, [form, form.formState.isSubmitSuccessful, form.reset]); + if (!open) { + form.reset(); + setError(null); + } + }, [open, form, form.reset]); useEffect(() => { if (isCloud && form.getValues("mode") === "credentials") { @@ -225,7 +228,7 @@ export const AddInvitation = () => { Invite Method From c453ac3a2f491c753d6fbdc3e25ba935c525bb97 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Wed, 23 Sep 2026 15:06:54 -0600 Subject: [PATCH 10/28] fix(billing): allow switching billing interval during trial Switching plan mid-trial always forced the monthly price and there was no way to move to annual. Preserve trial_end on the Stripe update so changing the interval does not reset the billing cycle and end the trial early. --- .../settings/billing/show-billing.tsx | 75 +++++++++++++------ apps/dokploy/server/api/routers/stripe.ts | 3 + apps/dokploy/server/utils/billing.ts | 7 ++ 3 files changed, 64 insertions(+), 21 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx index b66a23ca4..fbb85695b 100644 --- a/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx +++ b/apps/dokploy/components/dashboard/settings/billing/show-billing.tsx @@ -111,14 +111,20 @@ export const ShowBilling = () => { const [trialTier, setTrialTier] = useState<"hobby" | "startup" | null>(null); const [switchingTrial, setSwitchingTrial] = useState(false); - const handleSwitchTrialPlan = async (tier: "hobby" | "startup") => { - const planName = tier === "startup" ? "Startup" : "Hobby"; + const handleSwitchTrialPlan = async ( + tier: "hobby" | "startup", + isAnnualPlan: boolean, + label?: string, + ) => { + const planName = + label ?? + `${tier === "startup" ? "Startup" : "Hobby"} ${isAnnualPlan ? "annual" : "monthly"}`; const switchPlan = async () => { await upgradeSubscription({ tier, serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, - isAnnual: false, + isAnnual: isAnnualPlan, }); await new Promise((resolve) => setTimeout(resolve, 3000)); await Promise.all([ @@ -133,7 +139,7 @@ export const ShowBilling = () => { await toast .promise(switchPlan(), { loading: `Switching to ${planName}...`, - success: `Your trial is now on the ${planName} plan`, + success: `Your trial is now on ${planName}`, error: (error) => error instanceof Error ? error.message : "Error switching plan", }) @@ -336,6 +342,11 @@ export const ShowBilling = () => { {billingStatus.plan ?? "Trial"} + {billingStatus.plan && ( + + {billingStatus.isAnnual ? "Annual" : "Monthly"} + + )}
{billingStatus.isOnTrial @@ -356,23 +367,45 @@ export const ShowBilling = () => {
{billingStatus.isOnTrial && useNewPricing && - billingStatus.plan && ( - + billingStatus.plan && + billingStatus.plan !== "legacy" && ( + <> + + + )}