mirror of
https://github.com/eliasstepanik/core.git
synced 2026-01-22 21:58:32 +00:00
Feat: ability to delete the account and clean up all resources
This commit is contained in:
parent
bcae1bd4a1
commit
95636f96a8
@ -53,7 +53,7 @@ export function NavUser({ user }: { user: ExtendedUser }) {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onClick={() => navigate("/settings/api")}
|
onClick={() => navigate("/settings/account")}
|
||||||
>
|
>
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
Settings
|
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";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { prisma } from "~/db.server";
|
import { prisma } from "~/db.server";
|
||||||
import { isBillingEnabled } from "~/config/billing.server";
|
import { isBillingEnabled } from "~/config/billing.server";
|
||||||
|
import { SettingSection } from "~/components/setting-section";
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const user = await requireUser(request);
|
const user = await requireUser(request);
|
||||||
@ -230,218 +231,233 @@ export default function BillingSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="mx-auto flex w-3xl flex-col gap-4 px-4 py-6">
|
||||||
{/* Header */}
|
<SettingSection
|
||||||
<div className="mb-8">
|
title="Billing"
|
||||||
<h1 className="text-2xl font-bold">Billing</h1>
|
description=" Manage your subscription, usage, and billing history"
|
||||||
<p className="text-muted-foreground">
|
>
|
||||||
Manage your subscription, usage, and billing history
|
<>
|
||||||
</p>
|
{/* Usage Section */}
|
||||||
</div>
|
<div className="mb-8">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold">Current Usage</h2>
|
||||||
|
|
||||||
{/* Usage Section */}
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="mb-8">
|
{/* Credits Card */}
|
||||||
<h2 className="mb-4 text-lg font-semibold">Current Usage</h2>
|
<Card className="p-6">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<span className="text-muted-foreground text-sm">Credits</span>
|
||||||
{/* Credits Card */}
|
<CreditCard className="text-muted-foreground h-4 w-4" />
|
||||||
<Card className="p-6">
|
</div>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2">
|
||||||
<span className="text-muted-foreground text-sm">Credits</span>
|
<span className="text-3xl font-bold">
|
||||||
<CreditCard className="text-muted-foreground h-4 w-4" />
|
{usageSummary.credits.available}
|
||||||
</div>
|
</span>
|
||||||
<div className="mb-2">
|
<span className="text-muted-foreground">
|
||||||
<span className="text-3xl font-bold">
|
{" "}
|
||||||
{usageSummary.credits.available}
|
/ {usageSummary.credits.monthly}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">
|
</div>
|
||||||
{" "}
|
<Progress
|
||||||
/ {usageSummary.credits.monthly}
|
segments={[
|
||||||
</span>
|
{ value: 100 - usageSummary.credits.percentageUsed },
|
||||||
</div>
|
]}
|
||||||
<Progress
|
className="mb-2"
|
||||||
segments={[{ value: 100 - usageSummary.credits.percentageUsed }]}
|
color="#c15e50"
|
||||||
className="mb-2"
|
/>
|
||||||
color="#c15e50"
|
<p className="text-muted-foreground text-xs">
|
||||||
/>
|
{usageSummary.credits.percentageUsed}% used this period
|
||||||
<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.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Plan Section */}
|
{/* Usage Breakdown */}
|
||||||
<div className="mb-8">
|
<Card className="p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">Plan</h2>
|
<span className="text-muted-foreground text-sm">
|
||||||
<Button variant="secondary" onClick={() => setShowPlansModal(true)}>
|
Usage Breakdown
|
||||||
View All Plans
|
</span>
|
||||||
</Button>
|
<TrendingUp className="text-muted-foreground h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
<Card className="p-6">
|
<div className="flex justify-between text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-muted-foreground">Facts</span>
|
||||||
<div>
|
<span className="font-medium">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
{usageSummary.usage.episodes}
|
||||||
<h3 className="text-xl font-bold">{usageSummary.plan.name}</h3>
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invoices Section */}
|
{/* Overage Warning */}
|
||||||
<div className="mb-8">
|
{usageSummary.credits.overage > 0 && (
|
||||||
<h2 className="mb-4 text-lg font-semibold">Invoices</h2>
|
<Card className="mt-4 border-orange-500 bg-orange-50 p-4 dark:bg-orange-950">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
{billingHistory.length === 0 ? (
|
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||||
<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>
|
<div>
|
||||||
<p className="font-medium">
|
<h3 className="font-semibold text-orange-900 dark:text-orange-100">
|
||||||
{new Date(invoice.periodStart).toLocaleDateString()} -{" "}
|
Overage Usage Detected
|
||||||
{new Date(invoice.periodEnd).toLocaleDateString()}
|
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
</div>
|
||||||
<p className="font-bold">
|
</Card>
|
||||||
${invoice.totalAmount.toFixed(2)}
|
)}
|
||||||
</p>
|
</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
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
invoice.stripePaymentStatus === "paid"
|
usageSummary.plan.type === "FREE"
|
||||||
? "default"
|
? "secondary"
|
||||||
: "destructive"
|
: "default"
|
||||||
}
|
}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
>
|
>
|
||||||
{invoice.stripePaymentStatus || "pending"}
|
{usageSummary.plan.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
|
||||||
</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 */}
|
{/* Plans Modal */}
|
||||||
<Dialog open={showPlansModal} onOpenChange={setShowPlansModal}>
|
<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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@ -41,6 +41,7 @@ export default function Settings() {
|
|||||||
const data = {
|
const data = {
|
||||||
nav: [
|
nav: [
|
||||||
// { name: "Workspace", icon: Building },
|
// { name: "Workspace", icon: Building },
|
||||||
|
{ name: "Account", icon: User },
|
||||||
{ name: "Billing", icon: CreditCard },
|
{ name: "Billing", icon: CreditCard },
|
||||||
{ name: "API", icon: Code },
|
{ name: "API", icon: Code },
|
||||||
{ name: "Webhooks", icon: Webhook },
|
{ 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
|
* Reactivate a canceled subscription
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user