Reset and deletion of project

This commit is contained in:
Dries Augustyns
2025-12-03 21:30:54 +01:00
parent 382e97e5b2
commit a28be9adc9
4 changed files with 655 additions and 267 deletions
+486 -245
View File
@@ -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>
</>
);
}