mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-10 23:48:26 +00:00
Feat: ability to delete the account and clean up all resources
This commit is contained in:
parent
bcae1bd4a1
commit
0fabeeecd1
@ -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
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
71
apps/webapp/app/routes/api.v1.user.delete.tsx
Normal file
71
apps/webapp/app/routes/api.v1.user.delete.tsx
Normal 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 };
|
||||
216
apps/webapp/app/routes/settings.account.tsx
Normal file
216
apps/webapp/app/routes/settings.account.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user