feat: Add security center and warning for exceeding bounce/complaint rates
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Progress,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
|
||||
import type {ProjectSecurityMetrics} from '@plunk/types';
|
||||
|
||||
interface SecuritySettingsProps {
|
||||
metrics: ProjectSecurityMetrics;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Overview</CardTitle>
|
||||
<CardDescription>Monitor your project's email health and reputation</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading security metrics...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const {status, thresholds, isDisabled} = metrics;
|
||||
|
||||
// Helper to get status color and icon
|
||||
const getStatusIndicator = (rate: number, warningThreshold: number, criticalThreshold: number) => {
|
||||
if (rate >= criticalThreshold) {
|
||||
return {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-600', label: 'Critical'};
|
||||
}
|
||||
if (rate >= warningThreshold) {
|
||||
return {color: 'text-orange-600', icon: AlertTriangle, bg: 'bg-orange-500', label: 'Warning'};
|
||||
}
|
||||
return {color: 'text-green-600', icon: CheckCircle, bg: 'bg-green-600', label: 'Healthy'};
|
||||
};
|
||||
|
||||
const sevenDayBounceStatus = getStatusIndicator(
|
||||
status.sevenDay.bounceRate,
|
||||
thresholds.BOUNCE_7DAY_WARNING,
|
||||
thresholds.BOUNCE_7DAY_CRITICAL,
|
||||
);
|
||||
|
||||
const allTimeBounceStatus = getStatusIndicator(
|
||||
status.allTime.bounceRate,
|
||||
thresholds.BOUNCE_ALLTIME_WARNING,
|
||||
thresholds.BOUNCE_ALLTIME_CRITICAL,
|
||||
);
|
||||
|
||||
const sevenDayComplaintStatus = getStatusIndicator(
|
||||
status.sevenDay.complaintRate,
|
||||
thresholds.COMPLAINT_7DAY_WARNING,
|
||||
thresholds.COMPLAINT_7DAY_CRITICAL,
|
||||
);
|
||||
|
||||
const allTimeComplaintStatus = getStatusIndicator(
|
||||
status.allTime.complaintRate,
|
||||
thresholds.COMPLAINT_ALLTIME_WARNING,
|
||||
thresholds.COMPLAINT_ALLTIME_CRITICAL,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${status.isHealthy ? 'bg-green-100' : 'bg-red-100'}`}>
|
||||
<Shield className={`h-5 w-5 ${status.isHealthy ? 'text-green-600' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>Security Overview</CardTitle>
|
||||
<CardDescription>
|
||||
{status.isHealthy ? 'Your project is in good standing' : 'Action required to maintain project health'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Project Disabled Alert */}
|
||||
{isDisabled && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Project Disabled</AlertTitle>
|
||||
<AlertDescription>
|
||||
This project has been disabled due to critical security violations. Contact support to resolve.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Violations */}
|
||||
{status.violations.length > 0 && !isDisabled && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Critical Violations ({status.violations.length})</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
||||
{status.violations.map((violation, idx) => (
|
||||
<li key={idx}>{violation}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{status.warnings.length > 0 && (
|
||||
<Alert variant="warning" className="mb-4">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Security Warnings ({status.warnings.length})</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
||||
{status.warnings.map((warning, idx) => (
|
||||
<li key={idx}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Healthy Status */}
|
||||
{status.isHealthy && !isDisabled && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription>
|
||||
All security metrics are within acceptable thresholds. Keep up the good work!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bounce Rate Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bounce Rate Metrics</CardTitle>
|
||||
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 7-Day Bounce Rate */}
|
||||
<MetricDisplay
|
||||
label="7-Day Bounce Rate"
|
||||
rate={status.sevenDay.bounceRate}
|
||||
count={status.sevenDay.bounces}
|
||||
total={status.sevenDay.total}
|
||||
warningThreshold={thresholds.BOUNCE_7DAY_WARNING}
|
||||
criticalThreshold={thresholds.BOUNCE_7DAY_CRITICAL}
|
||||
status={sevenDayBounceStatus}
|
||||
/>
|
||||
|
||||
{/* All-Time Bounce Rate */}
|
||||
<MetricDisplay
|
||||
label="All-Time Bounce Rate"
|
||||
rate={status.allTime.bounceRate}
|
||||
count={status.allTime.bounces}
|
||||
total={status.allTime.total}
|
||||
warningThreshold={thresholds.BOUNCE_ALLTIME_WARNING}
|
||||
criticalThreshold={thresholds.BOUNCE_ALLTIME_CRITICAL}
|
||||
status={allTimeBounceStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Complaint Rate Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Complaint Rate Metrics</CardTitle>
|
||||
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 7-Day Complaint Rate */}
|
||||
<MetricDisplay
|
||||
label="7-Day Complaint Rate"
|
||||
rate={status.sevenDay.complaintRate}
|
||||
count={status.sevenDay.complaints}
|
||||
total={status.sevenDay.total}
|
||||
warningThreshold={thresholds.COMPLAINT_7DAY_WARNING}
|
||||
criticalThreshold={thresholds.COMPLAINT_7DAY_CRITICAL}
|
||||
status={sevenDayComplaintStatus}
|
||||
isComplaintRate
|
||||
/>
|
||||
|
||||
{/* All-Time Complaint Rate */}
|
||||
<MetricDisplay
|
||||
label="All-Time Complaint Rate"
|
||||
rate={status.allTime.complaintRate}
|
||||
count={status.allTime.complaints}
|
||||
total={status.allTime.total}
|
||||
warningThreshold={thresholds.COMPLAINT_ALLTIME_WARNING}
|
||||
criticalThreshold={thresholds.COMPLAINT_ALLTIME_CRITICAL}
|
||||
status={allTimeComplaintStatus}
|
||||
isComplaintRate
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricDisplayProps {
|
||||
label: string;
|
||||
rate: number;
|
||||
count: number;
|
||||
total: number;
|
||||
warningThreshold: number;
|
||||
criticalThreshold: number;
|
||||
status: {
|
||||
color: string;
|
||||
icon: React.ComponentType<{className?: string}>;
|
||||
bg: string;
|
||||
label: string;
|
||||
};
|
||||
isComplaintRate?: boolean;
|
||||
}
|
||||
|
||||
function MetricDisplay({
|
||||
label,
|
||||
rate,
|
||||
count,
|
||||
total,
|
||||
warningThreshold,
|
||||
criticalThreshold,
|
||||
status,
|
||||
isComplaintRate = false,
|
||||
}: MetricDisplayProps) {
|
||||
const Icon = status.icon;
|
||||
const progressValue = Math.min((rate / criticalThreshold) * 100, 100);
|
||||
const decimals = isComplaintRate ? 3 : 2;
|
||||
|
||||
return (
|
||||
<div className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900">{label}</h3>
|
||||
<p className="text-sm text-neutral-600 mt-1">
|
||||
{count.toLocaleString()} / {total.toLocaleString()} emails
|
||||
<span className="text-neutral-400 mx-2">•</span>
|
||||
<strong>{rate.toFixed(decimals)}%</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${status.color}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Progress value={progressValue} className="h-2" indicatorClassName={status.bg} />
|
||||
<div className="flex justify-between text-xs text-neutral-500">
|
||||
<span>0%</span>
|
||||
<span>Warning: {warningThreshold}%</span>
|
||||
<span>Critical: {criticalThreshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {Alert, AlertDescription, AlertTitle, Button} from '@plunk/ui';
|
||||
import {AlertTriangle} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type {SecurityStatus} from '@plunk/types';
|
||||
|
||||
interface SecurityWarningBannerProps {
|
||||
status: SecurityStatus;
|
||||
}
|
||||
|
||||
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
||||
// Don't show if no warnings or already disabled
|
||||
if (status.warnings.length === 0 || status.shouldDisable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sevenDayBounceRate = status.sevenDay.bounceRate.toFixed(2);
|
||||
const sevenDayComplaintRate = status.sevenDay.complaintRate.toFixed(3);
|
||||
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Security Warning - Action Required</AlertTitle>
|
||||
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">
|
||||
Your project has exceeded security thresholds. Current rates: 7-day bounce rate{' '}
|
||||
<strong>{sevenDayBounceRate}%</strong>, 7-day complaint rate{' '}
|
||||
<strong>{sevenDayComplaintRate}%</strong>.
|
||||
</p>
|
||||
<p className="text-xs text-amber-800">
|
||||
High bounce or complaint rates can lead to project suspension. Review the detailed metrics
|
||||
and take action to improve your email quality.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/settings?tab=security">
|
||||
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
||||
View Details
|
||||
</Button>
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import useSWR from 'swr';
|
||||
import type {ProjectSecurityMetrics} from '@plunk/types';
|
||||
|
||||
export interface ProjectSecurityResponse {
|
||||
success: boolean;
|
||||
data: ProjectSecurityMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch project security metrics
|
||||
* Auto-refreshes every 2 minutes to keep data current
|
||||
*/
|
||||
export function useProjectSecurity(projectId: string | undefined) {
|
||||
const {data, error, isLoading, mutate} = useSWR<ProjectSecurityResponse>(
|
||||
projectId ? `/projects/${projectId}/security` : null,
|
||||
{
|
||||
refreshInterval: 120000, // 2 minutes
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
securityMetrics: data?.data,
|
||||
isLoading,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -15,15 +15,18 @@ import Link from 'next/link';
|
||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||
import {DashboardLayout} from '../components/DashboardLayout';
|
||||
import {QuickStart} from '../components/QuickStart';
|
||||
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
|
||||
export default function Index() {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
||||
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||
const {data: config} = useConfig();
|
||||
|
||||
const stats = [
|
||||
@@ -68,6 +71,11 @@ export default function Index() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Security Warning Banner */}
|
||||
{activeProject && !activeProject.disabled && securityMetrics && (
|
||||
<SecurityWarningBanner status={securityMetrics.status} />
|
||||
)}
|
||||
|
||||
{/* Subscription Warning Banner */}
|
||||
{activeProject &&
|
||||
!activeProject.disabled &&
|
||||
|
||||
@@ -37,7 +37,16 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon, Users} from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
Database,
|
||||
Globe,
|
||||
Mail,
|
||||
Settings as SettingsIcon,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type {z} from 'zod';
|
||||
import {useRouter} from 'next/router';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
@@ -50,14 +59,16 @@ import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
||||
import {SmtpSettings} from '../../components/SmtpSettings';
|
||||
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
||||
import {TeamSettings} from '../../components/TeamSettings';
|
||||
import {SecuritySettings} from '../../components/SecuritySettings';
|
||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||
import {network} from '../../lib/network';
|
||||
import {useProjects} from '../../lib/hooks/useProject';
|
||||
import {useConfig} from '../../lib/hooks/useConfig';
|
||||
import {useUser} from '../../lib/hooks/useUser';
|
||||
import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity';
|
||||
import useSWR from 'swr';
|
||||
|
||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team';
|
||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
@@ -71,6 +82,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
|
||||
const allTabs: Tab[] = [
|
||||
{id: 'general', label: 'General', icon: SettingsIcon},
|
||||
{id: 'team', label: 'Team', icon: Users},
|
||||
{id: 'security', label: 'Security', icon: Shield},
|
||||
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
||||
{id: 'domains', label: 'Domains', icon: Globe},
|
||||
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
||||
@@ -103,6 +115,8 @@ export default function Settings() {
|
||||
const currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
|
||||
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
||||
|
||||
const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id);
|
||||
|
||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
||||
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
||||
@@ -691,6 +705,24 @@ export default function Settings() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Security Tab */}
|
||||
<TabsContent value="security">
|
||||
{securityMetrics ? (
|
||||
<SecuritySettings metrics={securityMetrics} isLoading={isLoadingSecurityMetrics} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{isLoadingSecurityMetrics ? 'Loading...' : 'Unable to load security metrics'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Domains Tab */}
|
||||
<TabsContent value="domains">
|
||||
<DomainsSettings projectId={activeProject.id} />
|
||||
|
||||
Reference in New Issue
Block a user