feat: Add security center and warning for exceeding bounce/complaint rates
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||||
import {CampaignSchemas} from '@plunk/shared';
|
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
@@ -97,7 +97,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||||
req.body;
|
req.body;
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
await CampaignService.delete(auth.projectId, id!);
|
await CampaignService.delete(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const scheduledFor = req.body?.scheduledFor;
|
const scheduledFor = req.body?.scheduledFor;
|
||||||
|
|
||||||
// Parse scheduledFor if provided
|
// Parse scheduledFor if provided
|
||||||
@@ -232,7 +232,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||||
|
|
||||||
@@ -271,7 +271,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||||
|
|
||||||
await CampaignService.sendTest(auth.projectId, id!, email);
|
await CampaignService.sendTest(auth.projectId, id!, email);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import {MembershipSchemas} from '@plunk/shared';
|
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
import type {AuthResponse} from '../middleware/auth.js';
|
||||||
import {requireAuth} from '../middleware/auth.js';
|
import {requireAuth} from '../middleware/auth.js';
|
||||||
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@Controller('projects')
|
@Controller('projects')
|
||||||
@@ -19,7 +20,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -83,6 +84,38 @@ export class Projects {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project security metrics
|
||||||
|
* GET /projects/:id/security
|
||||||
|
*/
|
||||||
|
@Get(':id/security')
|
||||||
|
@Middleware([requireAuth])
|
||||||
|
@CatchAsync
|
||||||
|
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth as AuthResponse;
|
||||||
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
|
// Verify user has access to this project
|
||||||
|
const membership = await prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: auth.userId,
|
||||||
|
projectId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new HttpException(404, 'Project not found or you do not have access');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing SecurityService
|
||||||
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: metrics,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all members of a project
|
* Get all members of a project
|
||||||
* GET /projects/:id/members
|
* GET /projects/:id/members
|
||||||
@@ -92,7 +125,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -141,7 +174,7 @@ export class Projects {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {randomBytes} from 'node:crypto';
|
import {randomBytes} from 'node:crypto';
|
||||||
|
|
||||||
import {Controller, Delete, 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 {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||||
@@ -95,7 +95,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const data = ProjectSchemas.update.parse(req.body);
|
const data = ProjectSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
@@ -127,7 +127,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
const membership = await prisma.membership.findFirst({
|
||||||
@@ -179,7 +179,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -258,7 +258,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -308,7 +308,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -341,7 +341,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -408,7 +408,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -534,7 +534,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
if (!STRIPE_ENABLED || !stripe) {
|
if (!STRIPE_ENABLED || !stripe) {
|
||||||
@@ -622,7 +622,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -655,7 +655,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -731,7 +731,7 @@ export class Users {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth as AuthResponse;
|
||||||
const {id} = req.params;
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
|
|||||||
@@ -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 {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||||
import {DashboardLayout} from '../components/DashboardLayout';
|
import {DashboardLayout} from '../components/DashboardLayout';
|
||||||
import {QuickStart} from '../components/QuickStart';
|
import {QuickStart} from '../components/QuickStart';
|
||||||
|
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
|
||||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
|
||||||
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||||
|
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
|
||||||
import {useConfig} from '../lib/hooks/useConfig';
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const {activeProject} = useActiveProject();
|
const {activeProject} = useActiveProject();
|
||||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||||
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
|
||||||
|
const {securityMetrics} = useProjectSecurity(activeProject?.id);
|
||||||
const {data: config} = useConfig();
|
const {data: config} = useConfig();
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
@@ -68,6 +71,11 @@ export default function Index() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Security Warning Banner */}
|
||||||
|
{activeProject && !activeProject.disabled && securityMetrics && (
|
||||||
|
<SecurityWarningBanner status={securityMetrics.status} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Subscription Warning Banner */}
|
{/* Subscription Warning Banner */}
|
||||||
{activeProject &&
|
{activeProject &&
|
||||||
!activeProject.disabled &&
|
!activeProject.disabled &&
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ import {
|
|||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {NextSeo} from 'next-seo';
|
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 type {z} from 'zod';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
@@ -50,14 +59,16 @@ import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
|
|||||||
import {SmtpSettings} from '../../components/SmtpSettings';
|
import {SmtpSettings} from '../../components/SmtpSettings';
|
||||||
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
import {DataManagementSettings} from '../../components/DataManagementSettings';
|
||||||
import {TeamSettings} from '../../components/TeamSettings';
|
import {TeamSettings} from '../../components/TeamSettings';
|
||||||
|
import {SecuritySettings} from '../../components/SecuritySettings';
|
||||||
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {useProjects} from '../../lib/hooks/useProject';
|
import {useProjects} from '../../lib/hooks/useProject';
|
||||||
import {useConfig} from '../../lib/hooks/useConfig';
|
import {useConfig} from '../../lib/hooks/useConfig';
|
||||||
import {useUser} from '../../lib/hooks/useUser';
|
import {useUser} from '../../lib/hooks/useUser';
|
||||||
|
import {useProjectSecurity} from '../../lib/hooks/useProjectSecurity';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team';
|
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data' | 'team' | 'security';
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: TabId;
|
id: TabId;
|
||||||
@@ -71,6 +82,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
|
|||||||
const allTabs: Tab[] = [
|
const allTabs: Tab[] = [
|
||||||
{id: 'general', label: 'General', icon: SettingsIcon},
|
{id: 'general', label: 'General', icon: SettingsIcon},
|
||||||
{id: 'team', label: 'Team', icon: Users},
|
{id: 'team', label: 'Team', icon: Users},
|
||||||
|
{id: 'security', label: 'Security', icon: Shield},
|
||||||
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
|
||||||
{id: 'domains', label: 'Domains', icon: Globe},
|
{id: 'domains', label: 'Domains', icon: Globe},
|
||||||
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
|
{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 currentUserMembership = membershipData?.data.find(m => m.userId === user?.id);
|
||||||
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
const currentUserRole = currentUserMembership?.role || 'MEMBER';
|
||||||
|
|
||||||
|
const {securityMetrics, isLoading: isLoadingSecurityMetrics} = useProjectSecurity(activeProject?.id);
|
||||||
|
|
||||||
const billingEnabled = config?.features.billing.enabled ?? false;
|
const billingEnabled = config?.features.billing.enabled ?? false;
|
||||||
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
const smtpEnabled = config?.features.smtp.enabled ?? false;
|
||||||
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
const trackingToggleEnabled = config?.features.email.trackingToggleEnabled ?? false;
|
||||||
@@ -691,6 +705,24 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</TabsContent>
|
</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 */}
|
{/* Domains Tab */}
|
||||||
<TabsContent value="domains">
|
<TabsContent value="domains">
|
||||||
<DomainsSettings projectId={activeProject.id} />
|
<DomainsSettings projectId={activeProject.id} />
|
||||||
|
|||||||
@@ -55,3 +55,40 @@ export interface SegmentMembershipComputeResult {
|
|||||||
removed: number;
|
removed: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security status types
|
||||||
|
export interface SecurityRateData {
|
||||||
|
total: number;
|
||||||
|
bounces: number;
|
||||||
|
complaints: number;
|
||||||
|
bounceRate: number;
|
||||||
|
complaintRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityStatus {
|
||||||
|
projectId: string;
|
||||||
|
isHealthy: boolean;
|
||||||
|
shouldDisable: boolean;
|
||||||
|
sevenDay: SecurityRateData;
|
||||||
|
allTime: SecurityRateData;
|
||||||
|
violations: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityThresholds {
|
||||||
|
MIN_EMAILS_FOR_ENFORCEMENT: number;
|
||||||
|
BOUNCE_7DAY_WARNING: number;
|
||||||
|
BOUNCE_7DAY_CRITICAL: number;
|
||||||
|
BOUNCE_ALLTIME_WARNING: number;
|
||||||
|
BOUNCE_ALLTIME_CRITICAL: number;
|
||||||
|
COMPLAINT_7DAY_WARNING: number;
|
||||||
|
COMPLAINT_7DAY_CRITICAL: number;
|
||||||
|
COMPLAINT_ALLTIME_WARNING: number;
|
||||||
|
COMPLAINT_ALLTIME_CRITICAL: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSecurityMetrics {
|
||||||
|
status: SecurityStatus;
|
||||||
|
thresholds: SecurityThresholds;
|
||||||
|
isDisabled: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user