mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 05:11:26 +00:00
Merge pull request #5463 from Dokploy/feat/self-delete-account
feat: account deletion for Dokploy Cloud
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,214 @@
|
||||
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 (!isCloud || !data || data.role !== "owner" || hasPassword === undefined) {
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -158,11 +158,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;
|
||||
}
|
||||
|
||||
@@ -314,11 +312,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,47 @@ 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 (!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",
|
||||
message: "Accounts cannot be deleted while impersonating",
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
all: withPermission("member", "read").query(async ({ ctx }) => {
|
||||
return await db.query.member.findMany({
|
||||
@@ -178,16 +226,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 +497,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;
|
||||
};
|
||||
@@ -173,3 +173,36 @@ export const getBillingStatus = async (
|
||||
isAnnual,
|
||||
};
|
||||
};
|
||||
|
||||
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