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, + }); +};