mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 05:11:26 +00:00
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.
This commit is contained in:
@@ -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<string, string>).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");
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
setOpen(value);
|
||||
if (!value) reset();
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" className="gap-2">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete account
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete an account by email</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{record ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<AlertBlock type="success">
|
||||
Deletion completed. Keep this record as evidence of the request
|
||||
being fulfilled.
|
||||
</AlertBlock>
|
||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-64">
|
||||
{record}
|
||||
</pre>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 self-end"
|
||||
onClick={() => {
|
||||
copy(record);
|
||||
toast.success("Deletion record copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy record
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="delete-account-email">Account email</Label>
|
||||
<Input
|
||||
id="delete-account-email"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="delete-account-word">
|
||||
Type <span className="font-mono">{CONFIRMATION_WORD}</span> to
|
||||
confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-account-word"
|
||||
autoComplete="off"
|
||||
placeholder={CONFIRMATION_WORD}
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
{record ? (
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!canConfirm || isPending}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Delete account
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => {
|
||||
<Shield className="h-4 w-4" />
|
||||
Impersonate
|
||||
</Button>
|
||||
<DeleteAccountByEmail />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||
|
||||
@@ -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 (
|
||||
<Card className="bg-transparent border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
Delete account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Permanently remove your account and all the data associated with it.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
|
||||
{isCloud && (
|
||||
<li>
|
||||
Active subscriptions are cancelled immediately. Invoices already
|
||||
issued are kept for accounting.
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Every organization you own is deleted, including its projects,
|
||||
services, environments, domains and server connections.
|
||||
</li>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
<li>Containers already running on your servers are not touched.</li>
|
||||
<li>
|
||||
We will email a confirmation code to{" "}
|
||||
<span className="font-medium text-foreground">{email}</span> before
|
||||
anything is deleted.
|
||||
</li>
|
||||
<li>This action cannot be undone.</li>
|
||||
</ul>
|
||||
<div>
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) reset();
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Delete account</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{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.`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
{step === "request" && hasPassword && (
|
||||
<>
|
||||
<Label htmlFor="delete-account-password">Password</Label>
|
||||
<Input
|
||||
id="delete-account-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<>
|
||||
<Label htmlFor="delete-account-code">
|
||||
Confirmation code
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-account-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) =>
|
||||
setCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="self-start px-0"
|
||||
disabled={isBusy}
|
||||
onClick={handleRequestCode}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
|
||||
{step === "request" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isBusy || (hasPassword && password.length === 0)}
|
||||
onClick={handleRequestCode}
|
||||
>
|
||||
{requestCode.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
Send confirmation code
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isBusy || code.length !== 6}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{deleteAccount.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
Delete account
|
||||
</Button>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
<ProfileForm />
|
||||
{isCloud && <LinkingAccount />}
|
||||
{permissions?.api.read && <ShowApiKeys />}
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
|
||||
<Container className="my-[40px] mx-auto max-w-[520px]">
|
||||
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
|
||||
<Img
|
||||
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
|
||||
width="190"
|
||||
height="120"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-white px-[40px] py-[32px]">
|
||||
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
|
||||
Confirm your account deletion
|
||||
</Heading>
|
||||
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
|
||||
Hello {userName}, we received a request to permanently delete
|
||||
your Dokploy account. Enter the code below to confirm it.
|
||||
</Text>
|
||||
|
||||
<Section className="text-center mb-[24px]">
|
||||
<Text className="inline-block bg-[#f4f4f5] rounded-lg text-[#09090b] text-[32px] font-semibold tracking-[8px] px-[24px] py-[16px] m-0">
|
||||
{code}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center mb-[16px]">
|
||||
This code expires in {expiresInMinutes} minutes. Once confirmed,
|
||||
your subscriptions are cancelled and every organization you own
|
||||
is deleted. This cannot be undone.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
|
||||
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
|
||||
If you did not request this, someone else may have access to
|
||||
your account. Change your password and contact{" "}
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="text-[#71717a] underline"
|
||||
>
|
||||
Dokploy support
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDeletionCodeTemplate;
|
||||
@@ -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";
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<HubSpotDeletionResult> {
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user