diff --git a/actions/account.ts b/actions/account.ts new file mode 100644 index 00000000..2ddb3a13 --- /dev/null +++ b/actions/account.ts @@ -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> { + 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."); + } +} diff --git a/app/settings/account/components/delete-account-section.tsx b/app/settings/account/components/delete-account-section.tsx new file mode 100644 index 00000000..dafdbf8f --- /dev/null +++ b/app/settings/account/components/delete-account-section.tsx @@ -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 ( + <> +
+

Danger Zone

+

+ Permanently delete your account and all associated data. This action + cannot be undone. +

+ +
+ + { + if (!isDeleting) { + setOpen(v); + if (!v) setConfirmText(""); + } + }}> + + + + + Delete your account + + +
+

+ This will permanently delete your account and all associated + data, including: +

+
    +
  • All saved themes
  • +
  • Community published themes
  • +
  • AI usage history
  • +
  • Active subscription (if any)
  • +
+

This action cannot be undone.

+
+ + setConfirmText(e.target.value)} + placeholder={CONFIRMATION_TEXT} + disabled={isDeleting} + autoComplete="off" + /> +
+
+
+
+ + Cancel + + +
+
+ + ); +} diff --git a/app/settings/account/page.tsx b/app/settings/account/page.tsx new file mode 100644 index 00000000..77766cd8 --- /dev/null +++ b/app/settings/account/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/app/settings/components/settings-sidebar.tsx b/app/settings/components/settings-sidebar.tsx index ff808d75..d6fae02b 100644 --- a/app/settings/components/settings-sidebar.tsx +++ b/app/settings/components/settings-sidebar.tsx @@ -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[] => [