mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-30 18:10:28 +08:00
feat: add delete account functionality in settings
Adds Account settings page with a danger zone section for permanent account deletion. Handles Polar customer cleanup, subscription records, and cascading user data deletion with confirmation dialog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { user as userTable, subscription } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getCurrentUserId } from "@/lib/shared";
|
||||
import { logError } from "@/lib/shared";
|
||||
import { actionError, actionSuccess, ErrorCode, type ActionResult } from "@/types/errors";
|
||||
import { polar } from "@/lib/polar";
|
||||
|
||||
export async function deleteAccount(): Promise<ActionResult<boolean>> {
|
||||
try {
|
||||
const userId = await getCurrentUserId();
|
||||
|
||||
// Try to delete Polar customer (cancels subscriptions + revokes benefits)
|
||||
// Free users won't have a Polar customer, so we catch and ignore errors
|
||||
try {
|
||||
await polar.customers.deleteExternal({ externalId: userId });
|
||||
} catch (_e) {
|
||||
// Expected for free users — no Polar customer exists
|
||||
}
|
||||
|
||||
// Delete subscription records (no CASCADE on this table)
|
||||
await db.delete(subscription).where(eq(subscription.userId, userId));
|
||||
|
||||
// Delete user — CASCADE handles: sessions, accounts, themes,
|
||||
// communityThemes, communityThemeTags, themeLikes, aiUsage,
|
||||
// oauthAuthorizationCode, oauthToken
|
||||
await db.delete(userTable).where(eq(userTable.id, userId));
|
||||
|
||||
return actionSuccess(true);
|
||||
} catch (error) {
|
||||
logError(error as Error, { action: "deleteAccount" });
|
||||
return actionError(ErrorCode.UNKNOWN_ERROR, "Failed to delete account. Please try again.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { deleteAccount } from "@/actions/account";
|
||||
|
||||
const CONFIRMATION_TEXT = "DELETE";
|
||||
|
||||
export function DeleteAccountSection() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
const posthog = usePostHog();
|
||||
|
||||
const isConfirmed = confirmText === CONFIRMATION_TEXT;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isConfirmed) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
const result = await deleteAccount();
|
||||
|
||||
if (result.success) {
|
||||
posthog.reset();
|
||||
await authClient.signOut();
|
||||
router.push("/");
|
||||
} else {
|
||||
toast({
|
||||
title: "Failed to delete account",
|
||||
description: result.error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-destructive/50 rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold text-destructive">Danger Zone</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
Permanently delete your account and all associated data. This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="mt-4"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={open} onOpenChange={(v) => {
|
||||
if (!isDeleting) {
|
||||
setOpen(v);
|
||||
if (!v) setConfirmText("");
|
||||
}
|
||||
}}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-5 text-destructive" />
|
||||
Delete your account
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
This will permanently delete your account and all associated
|
||||
data, including:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>All saved themes</li>
|
||||
<li>Community published themes</li>
|
||||
<li>AI usage history</li>
|
||||
<li>Active subscription (if any)</li>
|
||||
</ul>
|
||||
<p className="font-medium">This action cannot be undone.</p>
|
||||
<div className="pt-1">
|
||||
<label
|
||||
htmlFor="confirm-delete"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Type <span className="font-mono font-bold">{CONFIRMATION_TEXT}</span> to
|
||||
confirm
|
||||
</label>
|
||||
<Input
|
||||
id="confirm-delete"
|
||||
className="mt-1.5"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={CONFIRMATION_TEXT}
|
||||
disabled={isDeleting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={!isConfirmed || isDeleting}
|
||||
>
|
||||
{isDeleting && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
Delete Account
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SettingsHeader } from "../components/settings-header";
|
||||
import { DeleteAccountSection } from "./components/delete-account-section";
|
||||
|
||||
export default async function AccountPage() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
if (!session) redirect("/editor/theme");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Account"
|
||||
description="Manage your account settings"
|
||||
/>
|
||||
<DeleteAccountSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useSubscription } from "@/hooks/use-subscription";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChartNoAxesCombined, CreditCard, ExternalLink, LucideIcon, Palette } from "lucide-react";
|
||||
import { ChartNoAxesCombined, CreditCard, ExternalLink, LucideIcon, Palette, UserCog } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
@@ -24,6 +24,8 @@ type NavItem =
|
||||
const BASE_NAV_ITEMS: NavItem[] = [
|
||||
{ type: "link", href: "/settings/themes", label: "Themes", icon: Palette },
|
||||
{ type: "link", href: "/settings/usage", label: "AI Usage", icon: ChartNoAxesCombined },
|
||||
{ type: "separator", id: "account-separator" },
|
||||
{ type: "link", href: "/settings/account", label: "Account", icon: UserCog },
|
||||
];
|
||||
|
||||
const getSubscriptionNavItems = (): NavItem[] => [
|
||||
|
||||
Reference in New Issue
Block a user