From a87a5c06f0755465be8e09586676ccbb66bbc338 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 14 Sep 2026 11:01:47 -0600 Subject: [PATCH 1/2] 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 2/2] 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", - }); - } } };