Reset and deletion of project
This commit is contained in:
@@ -1,20 +1,10 @@
|
||||
import {randomBytes} from 'node:crypto';
|
||||
|
||||
import {Controller, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
||||
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
SMTP_DOMAIN,
|
||||
SMTP_ENABLED,
|
||||
SMTP_PORT_SECURE,
|
||||
SMTP_PORT_SUBMISSION,
|
||||
STRIPE_ENABLED,
|
||||
STRIPE_PRICE_EMAIL_USAGE,
|
||||
STRIPE_PRICE_ONBOARDING,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
} from '../app/constants.js';
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
@@ -86,7 +76,7 @@ export class Users {
|
||||
members: {
|
||||
create: {
|
||||
userId: auth.userId,
|
||||
role: 'ADMIN',
|
||||
role: 'OWNER',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -614,4 +604,143 @@ export class Users {
|
||||
|
||||
return res.status(200).json(metrics);
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/reset')
|
||||
@Middleware([isAuthenticated])
|
||||
@CatchAsync
|
||||
public async resetProject(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
||||
}
|
||||
|
||||
// Delete all project data in a transaction
|
||||
await prisma.$transaction(async tx => {
|
||||
// Delete all emails
|
||||
await tx.email.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all events
|
||||
await tx.event.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all campaigns
|
||||
await tx.campaign.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all workflows
|
||||
await tx.workflow.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all segments
|
||||
await tx.segment.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all contacts
|
||||
await tx.contact.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all templates
|
||||
await tx.template.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all API requests
|
||||
await tx.apiRequest.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json({success: true, message: 'Project reset successfully'});
|
||||
}
|
||||
|
||||
@Delete('@me/projects/:id')
|
||||
@Middleware([isAuthenticated])
|
||||
@CatchAsync
|
||||
public async deleteProject(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {id} = req.params;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has owner or admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['OWNER', 'ADMIN'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound(
|
||||
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
|
||||
);
|
||||
}
|
||||
|
||||
// Get project to check for active subscription
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
subscription: true,
|
||||
customer: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// If project has an active subscription, cancel it first
|
||||
if (STRIPE_ENABLED && stripe && project.subscription) {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(project.subscription);
|
||||
} catch (error) {
|
||||
// Log but don't fail if subscription cancellation fails
|
||||
console.error('Failed to cancel subscription:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the project (cascading deletes will handle related data)
|
||||
await prisma.project.delete({
|
||||
where: {id},
|
||||
});
|
||||
|
||||
return res.status(200).json({success: true, message: 'Project deleted successfully'});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ export default function Settings() {
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [resetConfirmText, setResetConfirmText] = useState('');
|
||||
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
|
||||
|
||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||
@@ -264,6 +268,57 @@ export default function Settings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetProject = async () => {
|
||||
if (!activeProject || resetConfirmText !== 'RESET') return;
|
||||
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
await network.fetch('POST', `/users/@me/projects/${activeProject.id}/reset`);
|
||||
|
||||
setSuccessMessage('Project reset successfully. All data has been cleared.');
|
||||
setShowResetDialog(false);
|
||||
setResetConfirmText('');
|
||||
|
||||
// Refresh the page to reload data
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to reset project');
|
||||
setShowResetDialog(false);
|
||||
setResetConfirmText('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!activeProject || deleteConfirmText !== 'DELETE') return;
|
||||
|
||||
try {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
await network.fetch('DELETE', `/users/@me/projects/${activeProject.id}`);
|
||||
|
||||
setSuccessMessage('Project deleted successfully. Redirecting...');
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirmText('');
|
||||
|
||||
// Refresh projects list and redirect to dashboard
|
||||
await projectsMutate();
|
||||
|
||||
// Redirect to dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
setErrorMessage(error instanceof Error ? error.message : 'Failed to delete project');
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirmText('');
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeProject) {
|
||||
return (
|
||||
<>
|
||||
@@ -282,277 +337,463 @@ export default function Settings() {
|
||||
<NextSeo title="Settings" />
|
||||
<DashboardLayout>
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Settings</h1>
|
||||
<p className="text-neutral-500 mt-2">Manage your project settings and preferences</p>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Settings</h1>
|
||||
<p className="text-neutral-500 mt-2">Manage your project settings and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={currentTab} onValueChange={handleTabChange} className="max-w-4xl">
|
||||
<TabsList>
|
||||
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
{/* Tabs */}
|
||||
<Tabs value={currentTab} onValueChange={handleTabChange} className="max-w-4xl">
|
||||
<TabsList>
|
||||
{buildTabs({billingEnabled, smtpEnabled}).map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
{/* General Tab */}
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Settings</CardTitle>
|
||||
<CardDescription>Update your project name and basic information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Awesome Project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Email Tracking Toggle - only show if feature is available */}
|
||||
{trackingToggleEnabled && (
|
||||
{/* General Tab */}
|
||||
<TabsContent value="general">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Settings</CardTitle>
|
||||
<CardDescription>Update your project name and basic information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trackingEnabled"
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Email Tracking</FormLabel>
|
||||
<FormDescription>
|
||||
Enable open and click tracking for emails sent from this project. When disabled, emails
|
||||
will be sent without tracking pixels.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormItem>
|
||||
<FormLabel>Project Name</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<Input placeholder="My Awesome Project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Email Tracking Toggle - only show if feature is available */}
|
||||
{trackingToggleEnabled && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="trackingEnabled"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Email Tracking</FormLabel>
|
||||
<FormDescription>
|
||||
Enable open and click tracking for emails sent from this project. When disabled,
|
||||
emails will be sent without tracking pixels.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="space-y-4 pt-4 border-t border-neutral-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-neutral-900">API Keys</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={promptRegenerateKeys}
|
||||
className="text-xs"
|
||||
>
|
||||
Regenerate Keys
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
<ApiKeyDisplay
|
||||
label="Public API Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret API Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Billing Tab */}
|
||||
<TabsContent value="billing">
|
||||
<div className="space-y-6">
|
||||
{/* Unpaid Invoice Banner */}
|
||||
<UnpaidInvoiceBanner projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing & Subscription</CardTitle>
|
||||
<CardDescription>Manage your subscription and billing information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{activeProject.subscription ? (
|
||||
// Has subscription - show billing portal button
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-800 mb-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<span className="font-medium">Active Subscription</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Your subscription is active. Manage your billing details, update payment methods, or cancel
|
||||
your subscription through the billing portal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleManageBilling} disabled={isLoadingBilling}>
|
||||
{isLoadingBilling ? 'Loading...' : 'Manage Billing'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// No subscription - show start subscription button
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-neutral-50 border border-neutral-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-neutral-800 mb-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<span className="font-medium">No Active Subscription</span>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Start a subscription to unlock premium features and support the development of Plunk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
|
||||
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Billing Limits */}
|
||||
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
{/* API Keys - Separate Card */}
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>API Credentials</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with the Plunk API</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={promptRegenerateKeys}>
|
||||
Regenerate Keys
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ApiKeyDisplay
|
||||
label="Public API Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret API Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Current Month Consumption */}
|
||||
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
{/* Danger Zone - Separate Card */}
|
||||
<Card className="border-red-200 mt-6">
|
||||
<CardHeader className="border-b border-red-100 bg-gradient-to-r from-red-50 to-orange-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm border border-red-200">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg text-red-900">Danger Zone</CardTitle>
|
||||
<CardDescription className="text-red-700">
|
||||
Irreversible actions that affect your project data
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{/* Reset Project */}
|
||||
<div className="group">
|
||||
<div className="flex items-start justify-between gap-4 p-5 rounded-lg border border-gray-200 bg-white transition-all">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Database className="h-4 w-4 text-orange-600" />
|
||||
<h4 className="font-semibold text-gray-900">Reset Project Data</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Clear all campaigns, contacts, workflows, templates, and events. This gives you a blank
|
||||
project to start fresh.
|
||||
</p>
|
||||
<div className="flex items-start gap-2 text-xs text-gray-500">
|
||||
<span className="font-medium">Preserved:</span>
|
||||
<span>API keys, domains, billing information</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowResetDialog(true)}
|
||||
className="shrink-0 border-orange-300 text-orange-700 hover:bg-orange-50 hover:border-orange-400"
|
||||
>
|
||||
Reset Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Past Invoices */}
|
||||
<BillingInvoices
|
||||
projectId={activeProject.id}
|
||||
hasSubscription={!!activeProject.subscription}
|
||||
onManageBilling={handleManageBilling}
|
||||
{/* Delete Project */}
|
||||
<div className="group">
|
||||
<div className="flex items-start justify-between gap-4 p-5 rounded-lg border-2 border-red-200 bg-red-50/50 transition-all">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
<h4 className="font-semibold text-red-900">Delete Project Permanently</h4>
|
||||
</div>
|
||||
<p className="text-sm text-red-800 mb-3">
|
||||
Permanently delete this project and all associated data. This action{' '}
|
||||
<strong>cannot be undone</strong>.
|
||||
{activeProject?.subscription && ' Your subscription will be canceled.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded font-medium">
|
||||
Permanent Deletion
|
||||
</span>
|
||||
<span className="text-red-600">All data will be lost</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="shrink-0 bg-red-600 hover:bg-red-700 text-white shadow-sm hover:shadow-md transition-all"
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Billing Tab */}
|
||||
<TabsContent value="billing">
|
||||
<div className="space-y-6">
|
||||
{/* Unpaid Invoice Banner */}
|
||||
<UnpaidInvoiceBanner projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Billing & Subscription</CardTitle>
|
||||
<CardDescription>Manage your subscription and billing information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Success/Error Messages */}
|
||||
<AnimatePresence mode="wait">
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0}}
|
||||
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{activeProject.subscription ? (
|
||||
// Has subscription - show billing portal button
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-800 mb-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<span className="font-medium">Active Subscription</span>
|
||||
</div>
|
||||
<p className="text-sm text-green-700">
|
||||
Your subscription is active. Manage your billing details, update payment methods, or
|
||||
cancel your subscription through the billing portal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleManageBilling} disabled={isLoadingBilling}>
|
||||
{isLoadingBilling ? 'Loading...' : 'Manage Billing'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// No subscription - show start subscription button
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-neutral-50 border border-neutral-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-neutral-800 mb-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<span className="font-medium">No Active Subscription</span>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">
|
||||
Start a subscription to unlock premium features and support the development of Plunk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
|
||||
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Billing Limits */}
|
||||
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
{/* Current Month Consumption */}
|
||||
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
|
||||
|
||||
{/* Past Invoices */}
|
||||
<BillingInvoices
|
||||
projectId={activeProject.id}
|
||||
hasSubscription={!!activeProject.subscription}
|
||||
onManageBilling={handleManageBilling}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Domains Tab */}
|
||||
<TabsContent value="domains">
|
||||
<DomainsSettings projectId={activeProject.id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* SMTP Tab */}
|
||||
<TabsContent value="smtp">
|
||||
<SmtpSettings smtpConfig={smtpConfig} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Data Management Tab */}
|
||||
<TabsContent value="data">
|
||||
<DataManagementSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Keys Confirmation Dialog */}
|
||||
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
Regenerate API Keys
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>Are you sure you want to regenerate your API keys?</p>
|
||||
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
|
||||
applications using the old keys will stop working until you update them with the new keys.
|
||||
</div>
|
||||
</Alert>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
|
||||
Regenerate Keys
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reset Project Confirmation Dialog */}
|
||||
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="space-y-3">
|
||||
<div className="mx-auto w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Database className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-xl">Reset Project Data?</DialogTitle>
|
||||
<DialogDescription className="text-center text-base">
|
||||
All your campaigns, contacts, workflows, and templates will be permanently deleted. Your API keys and
|
||||
settings will remain intact.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2 text-center">
|
||||
Type{' '}
|
||||
<span className="font-mono font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">RESET</span>{' '}
|
||||
to confirm
|
||||
</label>
|
||||
<Input
|
||||
value={resetConfirmText}
|
||||
onChange={e => setResetConfirmText(e.target.value)}
|
||||
placeholder="Type RESET here"
|
||||
className="text-center"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Domains Tab */}
|
||||
<TabsContent value="domains">
|
||||
<DomainsSettings projectId={activeProject.id} />
|
||||
</TabsContent>
|
||||
<DialogFooter className="flex-col-reverse gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowResetDialog(false);
|
||||
setResetConfirmText('');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleResetProject}
|
||||
disabled={resetConfirmText !== 'RESET'}
|
||||
className="w-full bg-orange-600 hover:bg-orange-700 disabled:opacity-50"
|
||||
>
|
||||
Reset Data
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* SMTP Tab */}
|
||||
<TabsContent value="smtp">
|
||||
<SmtpSettings smtpConfig={smtpConfig} />
|
||||
</TabsContent>
|
||||
{/* Delete Project Confirmation Dialog */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader className="space-y-3">
|
||||
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-xl">Delete Project Permanently?</DialogTitle>
|
||||
<DialogDescription className="text-center text-base space-y-2">
|
||||
<p>
|
||||
This will <strong className="text-red-600">permanently delete</strong> your entire project and all
|
||||
data. This action cannot be undone.
|
||||
</p>
|
||||
{activeProject?.subscription && (
|
||||
<p className="text-sm text-red-700 bg-red-50 rounded p-2 border border-red-200">
|
||||
Your active subscription will be canceled
|
||||
</p>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Data Management Tab */}
|
||||
<TabsContent value="data">
|
||||
<DataManagementSettings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<label className="text-sm font-medium text-gray-700 block mb-2 text-center">
|
||||
Type <span className="font-mono font-semibold text-red-600 bg-red-50 px-2 py-0.5 rounded">DELETE</span>{' '}
|
||||
to confirm
|
||||
</label>
|
||||
<Input
|
||||
value={deleteConfirmText}
|
||||
onChange={e => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="Type DELETE here"
|
||||
className="text-center"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Regenerate Keys Confirmation Dialog */}
|
||||
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||
Regenerate API Keys
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-2">
|
||||
<p>Are you sure you want to regenerate your API keys?</p>
|
||||
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
|
||||
applications using the old keys will stop working until you update them with the new keys.
|
||||
</div>
|
||||
</Alert>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
|
||||
Regenerate Keys
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
<DialogFooter className="flex-col-reverse gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteProject}
|
||||
disabled={deleteConfirmText !== 'DELETE'}
|
||||
className="w-full bg-red-600 hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete Forever
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user