Feat: ability to delete the account and clean up all resources

This commit is contained in:
Harshith Mullapudi 2025-10-20 12:16:20 +05:30
parent bcae1bd4a1
commit 95636f96a8
7 changed files with 639 additions and 197 deletions

View File

@ -53,7 +53,7 @@ export function NavUser({ user }: { user: ExtendedUser }) {
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex gap-2"
onClick={() => navigate("/settings/api")}
onClick={() => navigate("/settings/account")}
>
<Settings size={16} />
Settings

View File

@ -238,3 +238,127 @@ export async function grantUserCloudAccess({
},
});
}
export async function deleteUser(id: User["id"]) {
// Get user's workspace
const user = await prisma.user.findUnique({
where: { id },
include: {
Workspace: true,
},
});
if (!user) {
throw new Error("User not found");
}
// If workspace exists, delete all workspace-related data
// Most models DON'T have onDelete: Cascade, so we must delete manually
if (user.Workspace) {
const workspaceId = user.Workspace.id;
// 1. Delete nested conversation data
await prisma.conversationExecutionStep.deleteMany({
where: {
conversationHistory: {
conversation: { workspaceId },
},
},
});
await prisma.conversationHistory.deleteMany({
where: {
conversation: { workspaceId },
},
});
await prisma.conversation.deleteMany({
where: { workspaceId },
});
// 2. Delete space patterns (nested under Space)
await prisma.spacePattern.deleteMany({
where: {
space: { workspaceId },
},
});
await prisma.space.deleteMany({
where: { workspaceId },
});
// 3. Delete webhook delivery logs (nested under WebhookConfiguration)
await prisma.webhookDeliveryLog.deleteMany({
where: {
webhookConfiguration: { workspaceId },
},
});
await prisma.webhookConfiguration.deleteMany({
where: { workspaceId },
});
// 4. Delete ingestion data
await prisma.ingestionQueue.deleteMany({
where: { workspaceId },
});
await prisma.ingestionRule.deleteMany({
where: { workspaceId },
});
// 5. Delete integration accounts
await prisma.integrationAccount.deleteMany({
where: { workspaceId },
});
await prisma.integrationDefinitionV2.deleteMany({
where: { workspaceId },
});
// 6. Delete recall logs
await prisma.recallLog.deleteMany({
where: { workspaceId },
});
// 7. Delete activities
await prisma.activity.deleteMany({
where: { workspaceId },
});
// 8. Delete MCP sessions
await prisma.mCPSession.deleteMany({
where: { workspaceId },
});
// 9. Delete billing history (nested under Subscription)
await prisma.billingHistory.deleteMany({
where: {
subscription: { workspaceId },
},
});
await prisma.subscription.deleteMany({
where: { workspaceId },
});
// 10. Delete the workspace (this will CASCADE delete OAuth models automatically)
await prisma.workspace.delete({
where: { id: workspaceId },
});
}
// Delete user-specific data
await prisma.personalAccessToken.deleteMany({
where: { userId: id },
});
await prisma.userUsage.deleteMany({
where: { userId: id },
});
// Finally, delete the user
return prisma.user.delete({
where: { id },
});
}

View File

@ -0,0 +1,71 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/services/session.server";
import { deleteUser, getUserById } from "~/models/user.server";
import { sessionStorage } from "~/services/sessionStorage.server";
import { cancelSubscriptionImmediately } from "~/services/stripe.server";
import { isBillingEnabled } from "~/config/billing.server";
import { prisma } from "~/db.server";
import { createHybridActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const { action, loader } = createHybridActionApiRoute(
{
corsStrategy: "all",
allowJWT: true,
method: "DELETE",
authorization: {
action: "delete",
},
},
async ({ authentication, request }) => {
try {
const user = await getUserById(authentication.userId);
if (!user || !user.Workspace) {
throw new Error("No user or workspace found");
}
// If billing is enabled, cancel any active subscriptions
if (isBillingEnabled()) {
const subscription = await prisma.subscription.findUnique({
where: { workspaceId: user?.Workspace?.id! },
});
if (subscription?.stripeSubscriptionId) {
try {
await cancelSubscriptionImmediately(
subscription.stripeSubscriptionId,
);
} catch (error) {
console.error("Failed to cancel Stripe subscription:", error);
// Continue with deletion even if Stripe cancellation fails
}
}
}
// Delete the user and all associated data
await deleteUser(user.id);
// Destroy the session
const session = await sessionStorage.getSession(
request.headers.get("Cookie"),
);
return json(
{ success: true },
{
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
},
);
} catch (error) {
console.error("Error deleting user:", error);
return json(
{ error: "Failed to delete account. Please try again." },
{ status: 500 },
);
}
},
);
export { action, loader };

View File

@ -0,0 +1,216 @@
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useFetcher, useNavigate } from "@remix-run/react";
import { requireUser } from "~/services/session.server";
import { Card } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { AlertTriangle } from "lucide-react";
import { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { SettingSection } from "~/components/setting-section";
interface SuccessDataResponse {
success: boolean;
}
interface ErrorDataResponse {
error: string;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
return json({
user,
});
};
export default function AccountSettings() {
const { user } = useLoaderData<typeof loader>();
const fetcher = useFetcher<SuccessDataResponse | ErrorDataResponse>();
const navigate = useNavigate();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [confirmText, setConfirmText] = useState("");
const isDeleting = fetcher.state === "submitting";
const handleDeleteAccount = () => {
fetcher.submit(
{},
{
method: "DELETE",
action: "/api/v1/user/delete",
},
);
};
// Redirect to login after successful deletion
if (fetcher.data && "success" in fetcher.data && fetcher.data.success) {
setTimeout(() => {
navigate("/login");
}, 1000);
}
const canDelete = confirmText === user.email;
return (
<div className="mx-auto flex w-3xl flex-col gap-4 px-4 py-6">
<SettingSection
title="Account Settings"
description="Manage your account information and preferences"
>
<>
{/* Account Information */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Account Information</h2>
<Card className="p-6">
<div className="space-y-4">
<div>
<Label className="text-muted-foreground text-sm">Email</Label>
<p className="text-base font-medium">{user.email}</p>
</div>
{user.name && (
<div>
<Label className="text-muted-foreground text-sm">
Name
</Label>
<p className="text-base font-medium">{user.name}</p>
</div>
)}
{user.displayName && (
<div>
<Label className="text-muted-foreground text-sm">
Display Name
</Label>
<p className="text-base font-medium">{user.displayName}</p>
</div>
)}
<div>
<Label className="text-muted-foreground text-sm">
Account Created
</Label>
<p className="text-base font-medium">
{new Date(user.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</Card>
</div>
{/* Danger Zone */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold text-red-600 dark:text-red-400">
Danger Zone
</h2>
<Card className="p-6">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-1 h-5 w-5 text-red-600 dark:text-red-400" />
<div className="flex-1">
<h3 className="font-semibold text-red-900 dark:text-red-100">
Delete Account
</h3>
<p className="mb-4 text-sm text-red-700 dark:text-red-300">
Permanently delete your account and all associated data.
This action cannot be undone.
</p>
<ul className="mb-4 list-inside list-disc space-y-1 text-sm">
<li>All your memories and conversations will be deleted</li>
<li>All integration connections will be removed</li>
<li>All API keys and webhooks will be revoked</li>
<li>
Your workspace and all its data will be permanently lost
</li>
<li>Active subscriptions will be cancelled immediately</li>
</ul>
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
Delete My Account
</Button>
</div>
</div>
</Card>
</div>
</>
</SettingSection>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p>
This action <strong>cannot be undone</strong>. This will
permanently delete your account and remove all your data from
our servers.
</p>
<div>
<Label
htmlFor="confirm-email"
className="text-sm font-medium"
>
To confirm, type your email address:{" "}
<span className="font-mono">{user.email}</span>
</Label>
<Input
id="confirm-email"
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="Enter your email"
className="mt-2"
autoComplete="off"
/>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setConfirmText("");
}}
disabled={isDeleting}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAccount}
disabled={!canDelete || isDeleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{isDeleting ? "Deleting..." : "Delete Account Permanently"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Success Message */}
{fetcher.data && "success" in fetcher.data && fetcher.data.success && (
<div className="fixed right-4 bottom-4 rounded-md bg-green-600 p-4 text-white shadow-lg">
Account deleted successfully. Redirecting...
</div>
)}
{/* Error Message */}
{fetcher.data && "error" in fetcher.data && (
<div className="fixed right-4 bottom-4 rounded-md bg-red-600 p-4 text-white shadow-lg">
{fetcher.data.error}
</div>
)}
</div>
);
}

View File

@ -36,6 +36,7 @@ import {
} from "~/components/ui/alert-dialog";
import { prisma } from "~/db.server";
import { isBillingEnabled } from "~/config/billing.server";
import { SettingSection } from "~/components/setting-section";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
@ -230,218 +231,233 @@ export default function BillingSettings() {
}
return (
<div className="p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-muted-foreground">
Manage your subscription, usage, and billing history
</p>
</div>
<div className="mx-auto flex w-3xl flex-col gap-4 px-4 py-6">
<SettingSection
title="Billing"
description=" Manage your subscription, usage, and billing history"
>
<>
{/* Usage Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Current Usage</h2>
{/* Usage Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Current Usage</h2>
<div className="grid gap-4 md:grid-cols-3">
{/* Credits Card */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">Credits</span>
<CreditCard className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.credits.available}
</span>
<span className="text-muted-foreground">
{" "}
/ {usageSummary.credits.monthly}
</span>
</div>
<Progress
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
className="mb-2"
color="#c15e50"
/>
<p className="text-muted-foreground text-xs">
{usageSummary.credits.percentageUsed}% used this period
</p>
</Card>
{/* Usage Breakdown */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Usage Breakdown
</span>
<TrendingUp className="text-muted-foreground h-4 w-4" />
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Facts</span>
<span className="font-medium">
{usageSummary.usage.episodes}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Searches</span>
<span className="font-medium">
{usageSummary.usage.searches}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Chat</span>
<span className="font-medium">{usageSummary.usage.chat}</span>
</div>
</div>
</Card>
{/* Billing Cycle */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Billing Cycle
</span>
<Calendar className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.billingCycle.daysRemaining}
</span>
<span className="text-muted-foreground"> days left</span>
</div>
<p className="text-muted-foreground text-xs">
Resets on{" "}
{new Date(usageSummary.billingCycle.end).toLocaleDateString()}
</p>
</Card>
</div>
{/* Overage Warning */}
{usageSummary.credits.overage > 0 && (
<Card className="mt-4 border-orange-500 bg-orange-50 p-4 dark:bg-orange-950">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<h3 className="font-semibold text-orange-900 dark:text-orange-100">
Overage Usage Detected
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
You've used {usageSummary.credits.overage} additional credits
beyond your monthly allocation.
{usageSummary.overage.enabled &&
usageSummary.overage.pricePerCredit && (
<>
{" "}
This will cost $
{(
usageSummary.credits.overage *
usageSummary.overage.pricePerCredit
).toFixed(2)}{" "}
extra this month.
</>
)}
<div className="grid gap-4 md:grid-cols-3">
{/* Credits Card */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">Credits</span>
<CreditCard className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.credits.available}
</span>
<span className="text-muted-foreground">
{" "}
/ {usageSummary.credits.monthly}
</span>
</div>
<Progress
segments={[
{ value: 100 - usageSummary.credits.percentageUsed },
]}
className="mb-2"
color="#c15e50"
/>
<p className="text-muted-foreground text-xs">
{usageSummary.credits.percentageUsed}% used this period
</p>
</div>
</div>
</Card>
)}
</div>
</Card>
{/* Plan Section */}
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Plan</h2>
<Button variant="secondary" onClick={() => setShowPlansModal(true)}>
View All Plans
</Button>
</div>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xl font-bold">{usageSummary.plan.name}</h3>
<Badge
variant={
usageSummary.plan.type === "FREE" ? "secondary" : "default"
}
className="rounded"
>
{usageSummary.plan.type}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{usageSummary.credits.monthly} credits/month
{usageSummary.overage.enabled && (
<> + ${usageSummary.overage.pricePerCredit}/credit overage</>
)}
</p>
{subscription?.status === "CANCELED" &&
subscription.planType !== "FREE" && (
<div className="mt-3 flex items-start gap-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<AlertCircle className="mt-0.5 h-4 w-4 text-orange-600 dark:text-orange-400" />
<p className="text-sm text-orange-700 dark:text-orange-300">
Downgrading to FREE plan on{" "}
<strong>
{new Date(
subscription.currentPeriodEnd,
).toLocaleDateString()}
</strong>
. Your current credits and plan will remain active until
then.
</p>
{/* Usage Breakdown */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Usage Breakdown
</span>
<TrendingUp className="text-muted-foreground h-4 w-4" />
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Facts</span>
<span className="font-medium">
{usageSummary.usage.episodes}
</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Searches</span>
<span className="font-medium">
{usageSummary.usage.searches}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Chat</span>
<span className="font-medium">
{usageSummary.usage.chat}
</span>
</div>
</div>
</Card>
{/* Billing Cycle */}
<Card className="p-6">
<div className="mb-2 flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Billing Cycle
</span>
<Calendar className="text-muted-foreground h-4 w-4" />
</div>
<div className="mb-2">
<span className="text-3xl font-bold">
{usageSummary.billingCycle.daysRemaining}
</span>
<span className="text-muted-foreground"> days left</span>
</div>
<p className="text-muted-foreground text-xs">
Resets on{" "}
{new Date(usageSummary.billingCycle.end).toLocaleDateString()}
</p>
</Card>
</div>
</div>
</Card>
</div>
{/* Invoices Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Invoices</h2>
{billingHistory.length === 0 ? (
<Card className="p-6">
<p className="text-muted-foreground text-center">No invoices yet</p>
</Card>
) : (
<Card>
<div className="divide-y">
{billingHistory.map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4"
>
{/* Overage Warning */}
{usageSummary.credits.overage > 0 && (
<Card className="mt-4 border-orange-500 bg-orange-50 p-4 dark:bg-orange-950">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<p className="font-medium">
{new Date(invoice.periodStart).toLocaleDateString()} -{" "}
{new Date(invoice.periodEnd).toLocaleDateString()}
<h3 className="font-semibold text-orange-900 dark:text-orange-100">
Overage Usage Detected
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300">
You've used {usageSummary.credits.overage} additional
credits beyond your monthly allocation.
{usageSummary.overage.enabled &&
usageSummary.overage.pricePerCredit && (
<>
{" "}
This will cost $
{(
usageSummary.credits.overage *
usageSummary.overage.pricePerCredit
).toFixed(2)}{" "}
extra this month.
</>
)}
</p>
</div>
<div className="text-right">
<p className="font-bold">
${invoice.totalAmount.toFixed(2)}
</p>
</div>
</Card>
)}
</div>
{/* Plan Section */}
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Plan</h2>
<Button
variant="secondary"
onClick={() => setShowPlansModal(true)}
>
View All Plans
</Button>
</div>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-xl font-bold">
{usageSummary.plan.name}
</h3>
<Badge
variant={
invoice.stripePaymentStatus === "paid"
? "default"
: "destructive"
usageSummary.plan.type === "FREE"
? "secondary"
: "default"
}
className="rounded"
>
{invoice.stripePaymentStatus || "pending"}
{usageSummary.plan.type}
</Badge>
</div>
<p className="text-muted-foreground text-sm">
{usageSummary.credits.monthly} credits/month
{usageSummary.overage.enabled && (
<>
{" "}
+ ${usageSummary.overage.pricePerCredit}/credit overage
</>
)}
</p>
{subscription?.status === "CANCELED" &&
subscription.planType !== "FREE" && (
<div className="mt-3 flex items-start gap-2 rounded-md bg-orange-50 p-3 dark:bg-orange-950">
<AlertCircle className="mt-0.5 h-4 w-4 text-orange-600 dark:text-orange-400" />
<p className="text-sm text-orange-700 dark:text-orange-300">
Downgrading to FREE plan on{" "}
<strong>
{new Date(
subscription.currentPeriodEnd,
).toLocaleDateString()}
</strong>
. Your current credits and plan will remain active
until then.
</p>
</div>
)}
</div>
))}
</div>
</Card>
)}
</div>
</div>
</Card>
</div>
{/* Invoices Section */}
<div className="mb-8">
<h2 className="mb-4 text-lg font-semibold">Invoices</h2>
{billingHistory.length === 0 ? (
<Card className="p-6">
<p className="text-muted-foreground text-center">
No invoices yet
</p>
</Card>
) : (
<Card>
<div className="divide-y">
{billingHistory.map((invoice) => (
<div
key={invoice.id}
className="flex items-center justify-between p-4"
>
<div>
<p className="font-medium">
{new Date(invoice.periodStart).toLocaleDateString()} -{" "}
{new Date(invoice.periodEnd).toLocaleDateString()}
</p>
</div>
<div className="text-right">
<p className="font-bold">
${invoice.totalAmount.toFixed(2)}
</p>
<Badge
variant={
invoice.stripePaymentStatus === "paid"
? "default"
: "destructive"
}
className="rounded"
>
{invoice.stripePaymentStatus || "pending"}
</Badge>
</div>
</div>
))}
</div>
</Card>
)}
</div>
</>
</SettingSection>
{/* Plans Modal */}
<Dialog open={showPlansModal} onOpenChange={setShowPlansModal}>

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Code, Webhook, Cable, CreditCard } from "lucide-react";
import { ArrowLeft, Code, Webhook, Cable, CreditCard, User } from "lucide-react";
import {
Sidebar,
@ -41,6 +41,7 @@ export default function Settings() {
const data = {
nav: [
// { name: "Workspace", icon: Building },
{ name: "Account", icon: User },
{ name: "Billing", icon: CreditCard },
{ name: "API", icon: Code },
{ name: "Webhooks", icon: Webhook },

View File

@ -170,6 +170,20 @@ export async function cancelSubscription(workspaceId: string): Promise<void> {
});
}
/**
* Cancel a subscription immediately (for account deletion)
*/
export async function cancelSubscriptionImmediately(
subscriptionId: string,
): Promise<void> {
if (!stripe || !isStripeConfigured()) {
throw new Error("Stripe is not configured");
}
// Cancel immediately
await stripe.subscriptions.cancel(subscriptionId);
}
/**
* Reactivate a canceled subscription
*/