diff --git a/apps/api/src/app/constants.ts b/apps/api/src/app/constants.ts index 73660e9..2d8c872 100644 --- a/apps/api/src/app/constants.ts +++ b/apps/api/src/app/constants.ts @@ -103,3 +103,9 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== ''; // Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded // Useful for self-hosters who want to manage project status manually export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true'; + +// Email Verification & Password Reset +export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour +export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour +export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour +export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds diff --git a/apps/api/src/controllers/Activity.ts b/apps/api/src/controllers/Activity.ts index 007607d..dc39639 100644 --- a/apps/api/src/controllers/Activity.ts +++ b/apps/api/src/controllers/Activity.ts @@ -2,7 +2,7 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {ActivityService, ActivityType} from '../services/ActivityService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -21,7 +21,7 @@ export class Activity { * - endDate: ISO date string */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getActivities(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -62,7 +62,7 @@ export class Activity { * - endDate: ISO date string (defaults to now) */ @Get('stats') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getStats(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -82,7 +82,7 @@ export class Activity { * - minutes: number (default 5) */ @Get('recent-count') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getRecentCount(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -98,7 +98,7 @@ export class Activity { * Get available activity types (for UI filters) */ @Get('types') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTypes(_req: Request, res: Response, _next: NextFunction) { const types = Object.values(ActivityType); @@ -114,7 +114,7 @@ export class Activity { * - daysAhead: number (default 30, max 90) */ @Get('upcoming') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getUpcoming(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Analytics.ts b/apps/api/src/controllers/Analytics.ts index a170417..1fac79d 100644 --- a/apps/api/src/controllers/Analytics.ts +++ b/apps/api/src/controllers/Analytics.ts @@ -2,7 +2,7 @@ import {Controller, Get, Middleware} from '@overnightjs/core'; import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {AnalyticsService} from '../services/AnalyticsService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -19,7 +19,7 @@ export class Analytics { * Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered) */ @Get('timeseries') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTimeSeries(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -41,7 +41,7 @@ export class Analytics { * - endDate: ISO date string (defaults to now) */ @Get('top-campaigns') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -65,7 +65,7 @@ export class Analytics { * Returns aggregate stats: total campaigns, active, completed, average rates */ @Get('campaign-stats') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getCampaignStats(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -89,7 +89,7 @@ export class Analytics { * Returns events sorted by frequency with trend data */ @Get('top-events') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getTopEvents(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Auth.ts b/apps/api/src/controllers/Auth.ts index 18fa0b2..a4f8ee0 100644 --- a/apps/api/src/controllers/Auth.ts +++ b/apps/api/src/controllers/Auth.ts @@ -1,11 +1,25 @@ import {Controller, Get, Post} from '@overnightjs/core'; import {AuthenticationSchemas} from '@plunk/shared'; +import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email'; +import {randomBytes} from 'node:crypto'; import type {NextFunction, Request, Response} from 'express'; +import * as React from 'react'; -import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js'; +import { + DASHBOARD_URI, + EMAIL_VERIFICATION_RATE_LIMIT, + EMAIL_VERIFICATION_RATE_WINDOW, + GITHUB_OAUTH_ENABLED, + GOOGLE_OAUTH_ENABLED, + LANDING_URI, + PASSWORD_RESET_RATE_LIMIT, + PLUNK_ENABLED, + TOKEN_EXPIRY_SECONDS, +} from '../app/constants.js'; import {prisma} from '../database/prisma.js'; import {redis, REDIS_ONE_MINUTE} from '../database/redis.js'; -import {jwt} from '../middleware/auth.js'; +import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js'; +import {jwt, parseJwt} from '../middleware/auth.js'; import {AuthService} from '../services/AuthService.js'; import {NtfyService} from '../services/NtfyService.js'; import {UserService} from '../services/UserService.js'; @@ -64,6 +78,8 @@ export class Auth { email, password: await AuthService.generateHash(password), type: 'PASSWORD', + // Auto-verify email if platform emails are disabled + emailVerified: !PLUNK_ENABLED, }, }); @@ -72,6 +88,27 @@ export class Auth { // Send notification about new user signup await NtfyService.notifyUserSignup(created_user.email, created_user.id); + // Send email verification if platform emails are enabled + if (PLUNK_ENABLED) { + const verificationToken = randomBytes(32).toString('hex'); + await redis.setex( + Keys.User.emailVerificationToken(verificationToken), + TOKEN_EXPIRY_SECONDS, + JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}), + ); + + const verificationUrl = `${LANDING_URI}/auth/verify-email?token=${verificationToken}`; + await sendPlatformEmail( + created_user.email, + 'Verify your email address', + React.createElement(EmailVerificationEmail, { + email: created_user.email, + verificationUrl, + landingUrl: LANDING_URI, + }), + ); + } + const token = jwt.sign(created_user.id); const cookie = UserService.cookieOptions(); @@ -97,4 +134,159 @@ export class Auth { }, }); } + + @Post('verify-email') + @CatchAsync + public async verifyEmail(req: Request, res: Response, _next: NextFunction) { + const {token} = AuthenticationSchemas.verifyEmail.parse(req.body); + + // Look up token in Redis + const data = await redis.get(Keys.User.emailVerificationToken(token)); + + if (!data) { + throw new BadRequest('Invalid or expired verification token'); + } + + const {userId} = JSON.parse(data); + + // Update user + await prisma.user.update({ + where: {id: userId}, + data: {emailVerified: true}, + }); + + // Delete token (single use) and invalidate cache + await redis.del(Keys.User.emailVerificationToken(token)); + await redis.del(Keys.User.id(userId)); + + return res.json({success: true, data: {message: 'Email verified successfully'}}); + } + + @Post('request-verification') + @CatchAsync + public async requestVerification(req: Request, res: Response, _next: NextFunction) { + const userId = parseJwt(req); + const user = await UserService.id(userId); + + if (!user) { + throw new NotAuthenticated(); + } + + if (user.emailVerified) { + return res.json({success: true, data: {message: 'Email already verified'}}); + } + + // Check rate limit + const rateLimitKey = Keys.User.emailVerificationRateLimit(userId); + const count = await redis.get(rateLimitKey); + + if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) { + throw new RateLimitError('Too many verification emails sent. Please try again later.'); + } + + // Generate token + const token = randomBytes(32).toString('hex'); + await redis.setex( + Keys.User.emailVerificationToken(token), + TOKEN_EXPIRY_SECONDS, + JSON.stringify({userId, email: user.email, createdAt: Date.now()}), + ); + + // Send email + const verificationUrl = `${LANDING_URI}/auth/verify-email?token=${token}`; + await sendPlatformEmail( + user.email, + 'Verify your email address', + React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}), + ); + + // Increment rate limit + if (count) { + await redis.incr(rateLimitKey); + } else { + await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1'); + } + + return res.json({success: true, data: {message: 'Verification email sent'}}); + } + + @Post('request-password-reset') + @CatchAsync + public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) { + const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body); + + // Check rate limit + const rateLimitKey = Keys.User.passwordResetRateLimit(email); + const count = await redis.get(rateLimitKey); + + if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) { + // Still return success to prevent enumeration + return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}}); + } + + // Look up user + const user = await UserService.email(email); + + // Only send email if user exists and is PASSWORD type + if (user && user.type === 'PASSWORD') { + const token = randomBytes(32).toString('hex'); + await redis.setex( + Keys.User.passwordResetToken(token), + TOKEN_EXPIRY_SECONDS, + JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}), + ); + + const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`; + await sendPlatformEmail( + user.email, + 'Reset your password', + React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}), + ); + + // Increment rate limit + if (count) { + await redis.incr(rateLimitKey); + } else { + await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1'); + } + } + + // Always return success (prevent enumeration) + return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}}); + } + + @Post('reset-password') + @CatchAsync + public async resetPassword(req: Request, res: Response, _next: NextFunction) { + const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body); + + // Look up token + const data = await redis.get(Keys.User.passwordResetToken(token)); + + if (!data) { + throw new BadRequest('Invalid or expired reset token'); + } + + const {userId} = JSON.parse(data); + + // Get user and verify type + const user = await prisma.user.findUnique({where: {id: userId}}); + + if (!user || user.type !== 'PASSWORD') { + throw new BadRequest('Invalid reset token'); + } + + // Hash new password and update + const hashedPassword = await AuthService.generateHash(newPassword); + await prisma.user.update({ + where: {id: userId}, + data: {password: hashedPassword}, + }); + + // Delete token and invalidate cache + await redis.del(Keys.User.passwordResetToken(token)); + await redis.del(Keys.User.id(userId)); + + return res.json({success: true, data: {message: 'Password reset successfully'}}); + } } diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index 5ec2503..fa04626 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -5,7 +5,7 @@ import type {NextFunction, Request, Response} from 'express'; import {HttpException} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {CampaignService} from '../services/CampaignService.js'; import {DomainService} from '../services/DomainService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -17,7 +17,7 @@ export class Campaigns { * POST /campaigns */ @Post('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async create(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -60,7 +60,7 @@ export class Campaigns { * GET /campaigns */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -93,7 +93,7 @@ export class Campaigns { * GET /campaigns/:id */ @Get(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async get(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -112,7 +112,7 @@ export class Campaigns { * PUT /campaigns/:id */ @Put(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async update(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -158,7 +158,7 @@ export class Campaigns { * DELETE /campaigns/:id */ @Delete(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async delete(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -177,7 +177,7 @@ export class Campaigns { * POST /campaigns/:id/duplicate */ @Post(':id/duplicate') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async duplicate(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -197,7 +197,7 @@ export class Campaigns { * POST /campaigns/:id/send */ @Post(':id/send') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async send(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -228,7 +228,7 @@ export class Campaigns { * POST /campaigns/:id/cancel */ @Post(':id/cancel') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async cancel(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -248,7 +248,7 @@ export class Campaigns { * GET /campaigns/:id/stats */ @Get(':id/stats') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async stats(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -267,7 +267,7 @@ export class Campaigns { * POST /campaigns/:id/test */ @Post(':id/test') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async sendTest(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index 7e78121..305385f 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -4,7 +4,7 @@ import multer from 'multer'; import signale from 'signale'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {ContactService} from '../services/ContactService.js'; import {QueueService} from '../services/QueueService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -32,7 +32,7 @@ export class Contacts { * List all contacts for the authenticated project with cursor-based pagination */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -51,7 +51,7 @@ export class Contacts { * Returns field names with inferred types (string, number, boolean, date) */ @Get('fields') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -77,7 +77,7 @@ export class Contacts { * Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values */ @Get('fields/:field/values') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getFieldValues(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -110,7 +110,7 @@ export class Contacts { * Get a specific contact by ID */ @Get(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -130,7 +130,7 @@ export class Contacts { * Create or update a contact (upsert) */ @Post('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -160,7 +160,7 @@ export class Contacts { * Update a contact */ @Patch(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -181,7 +181,7 @@ export class Contacts { * Delete a contact */ @Delete(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -301,7 +301,7 @@ export class Contacts { * Get import job status */ @Get('import/:jobId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getImportStatus(req: Request, res: Response, _next: NextFunction) { const jobId = req.params.jobId; @@ -332,7 +332,7 @@ export class Contacts { * Returns information about where the field is used and whether it can be safely deleted */ @Get('fields/:field/usage') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getFieldUsage(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -359,7 +359,7 @@ export class Contacts { * Only works if the field is not used in any segments or campaigns */ @Delete('fields/:field') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteField(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -385,7 +385,7 @@ export class Contacts { * Queue bulk subscribe operation */ @Post('bulk-subscribe') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -420,7 +420,7 @@ export class Contacts { * Queue bulk unsubscribe operation */ @Post('bulk-unsubscribe') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -454,7 +454,7 @@ export class Contacts { * Queue bulk delete operation */ @Post('bulk-delete') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async bulkDelete(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -488,7 +488,7 @@ export class Contacts { * Get bulk action job status */ @Get('bulk/:jobId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) { const jobId = req.params.jobId; diff --git a/apps/api/src/controllers/Domains.ts b/apps/api/src/controllers/Domains.ts index 130ab48..629d34e 100644 --- a/apps/api/src/controllers/Domains.ts +++ b/apps/api/src/controllers/Domains.ts @@ -5,7 +5,7 @@ import type {NextFunction, Request, Response} from 'express'; import {redis} from '../database/redis.js'; import {NotFound} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; -import {isAuthenticated} from '../middleware/auth.js'; +import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {Keys} from '../services/keys.js'; import {prisma} from '../database/prisma.js'; @@ -17,7 +17,7 @@ export class Domains { * Get all domains for a project */ @Get('project/:projectId') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -44,7 +44,7 @@ export class Domains { * Add a new domain to a project */ @Post('') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async addDomain(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -104,7 +104,7 @@ export class Domains { * Check verification status for a domain */ @Get(':id/verify') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async checkVerification(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -141,7 +141,7 @@ export class Domains { * Remove a domain from a project */ @Delete(':id') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async removeDomain(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Events.ts b/apps/api/src/controllers/Events.ts index 12760fa..d365269 100644 --- a/apps/api/src/controllers/Events.ts +++ b/apps/api/src/controllers/Events.ts @@ -3,7 +3,7 @@ import type {NextFunction, Request, Response} from 'express'; import signale from 'signale'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {EventService} from '../services/EventService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -14,7 +14,7 @@ export class Events { * Track a custom event (can trigger workflows) */ @Post('track') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async track(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -34,7 +34,7 @@ export class Events { * List events for the project */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -51,7 +51,7 @@ export class Events { * Get event statistics */ @Get('stats') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async stats(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -68,7 +68,7 @@ export class Events { * Get events for a specific contact */ @Get('contact/:contactId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getContactEvents(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -89,7 +89,7 @@ export class Events { * Get unique event names for the project */ @Get('names') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getEventNames(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -105,7 +105,7 @@ export class Events { * Returns information about where the event is used and whether it can be safely deleted */ @Get(':eventName/usage') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getEventUsage(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -132,7 +132,7 @@ export class Events { * Only works if the event is not used in any segments or workflows */ @Delete(':eventName') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteEvent(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Oauth/Github.ts b/apps/api/src/controllers/Oauth/Github.ts index 6e013f8..a25bbb0 100644 --- a/apps/api/src/controllers/Oauth/Github.ts +++ b/apps/api/src/controllers/Oauth/Github.ts @@ -85,6 +85,7 @@ export class Github { data: { email, type: 'GITHUB_OAUTH', + emailVerified: true, }, }); isNewUser = true; diff --git a/apps/api/src/controllers/Oauth/Google.ts b/apps/api/src/controllers/Oauth/Google.ts index 593612a..5c2a3ce 100644 --- a/apps/api/src/controllers/Oauth/Google.ts +++ b/apps/api/src/controllers/Oauth/Google.ts @@ -75,6 +75,7 @@ export class Google { data: { email, type: 'GOOGLE_OAUTH', + emailVerified: true, }, }); isNewUser = true; diff --git a/apps/api/src/controllers/Projects.ts b/apps/api/src/controllers/Projects.ts index b65c5cd..06a8d6a 100644 --- a/apps/api/src/controllers/Projects.ts +++ b/apps/api/src/controllers/Projects.ts @@ -5,7 +5,7 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {SecurityService} from '../services/SecurityService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -16,7 +16,7 @@ export class Projects { * GET /projects/:id/setup-state */ @Get(':id/setup-state') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async getSetupState(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -89,7 +89,7 @@ export class Projects { * GET /projects/:id/security */ @Get(':id/security') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -121,7 +121,7 @@ export class Projects { * GET /projects/:id/members */ @Get(':id/members') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async getMembers(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -170,7 +170,7 @@ export class Projects { * Body: { email: string, role?: 'ADMIN' | 'MEMBER' } */ @Post(':id/members') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async addMember(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -253,7 +253,7 @@ export class Projects { * Body: { role: 'ADMIN' | 'MEMBER' } */ @Patch(':id/members/:userId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async updateMemberRole(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -345,7 +345,7 @@ export class Projects { * DELETE /projects/:id/members/:userId */ @Delete(':id/members/:userId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync private async removeMember(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Segments.ts b/apps/api/src/controllers/Segments.ts index cf78dc3..86ad72e 100644 --- a/apps/api/src/controllers/Segments.ts +++ b/apps/api/src/controllers/Segments.ts @@ -2,7 +2,7 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {SegmentService} from '../services/SegmentService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -13,7 +13,7 @@ export class Segments { * List all segments for the authenticated project */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -28,7 +28,7 @@ export class Segments { * Get a specific segment by ID with member count */ @Get(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -48,7 +48,7 @@ export class Segments { * Get contacts that match a segment's filters */ @Get(':id/contacts') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getContacts(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -70,7 +70,7 @@ export class Segments { * Create a new segment */ @Post('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -99,7 +99,7 @@ export class Segments { * Update a segment */ @Patch(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -129,7 +129,7 @@ export class Segments { * Delete a segment */ @Delete(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -149,7 +149,7 @@ export class Segments { * Recompute segment membership for all contacts */ @Post(':id/compute') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async compute(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -169,7 +169,7 @@ export class Segments { * Refresh segment member count */ @Post(':id/refresh') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async refresh(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 902d96e..c29aea1 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -3,7 +3,7 @@ import {TemplateType} from '@plunk/db'; import type {NextFunction, Request, Response} from 'express'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {DomainService} from '../services/DomainService.js'; import {TemplateService} from '../services/TemplateService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -15,7 +15,7 @@ export class Templates { * List all templates for the authenticated project */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -34,7 +34,7 @@ export class Templates { * Get a specific template by ID */ @Get(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -54,7 +54,7 @@ export class Templates { * Create a new template */ @Post('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -98,7 +98,7 @@ export class Templates { * Update a template */ @Patch(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -133,7 +133,7 @@ export class Templates { * Delete a template */ @Delete(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -153,7 +153,7 @@ export class Templates { * Duplicate a template */ @Post(':id/duplicate') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async duplicate(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -173,7 +173,7 @@ export class Templates { * Get template usage statistics */ @Get(':id/usage') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getUsage(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Uploads.ts b/apps/api/src/controllers/Uploads.ts index 1085502..f675315 100644 --- a/apps/api/src/controllers/Uploads.ts +++ b/apps/api/src/controllers/Uploads.ts @@ -4,7 +4,7 @@ import multer from 'multer'; import signale from 'signale'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import * as S3Service from '../services/S3Service.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -33,7 +33,7 @@ export class Uploads { * Upload an image file to S3/Minio */ @Post('image') - @Middleware([requireAuth, upload.single('image')]) + @Middleware([requireAuth, requireEmailVerified, upload.single('image')]) @CatchAsync public async uploadImage(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 819c1ad..99ca5f5 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -9,7 +9,7 @@ import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js'; import type {AuthResponse} from '../middleware/auth.js'; -import {isAuthenticated} from '../middleware/auth.js'; +import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js'; import {BillingLimitService} from '../services/BillingLimitService.js'; import {NtfyService} from '../services/NtfyService.js'; import {SecurityService} from '../services/SecurityService.js'; @@ -20,7 +20,7 @@ import signale from 'signale'; @Controller('users') export class Users { @Get('@me') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async me(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -39,7 +39,7 @@ export class Users { } @Get('@me/projects') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async meProjects(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -54,7 +54,7 @@ export class Users { } @Post('@me/projects') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async createProject(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -101,7 +101,7 @@ export class Users { } @Patch('@me/projects/:id') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async updateProject(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -133,7 +133,7 @@ export class Users { } @Post('@me/projects/:id/regenerate-keys') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -185,7 +185,7 @@ export class Users { } @Post('@me/projects/:id/checkout') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -264,7 +264,7 @@ export class Users { } @Post('@me/projects/:id/billing-portal') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -314,7 +314,7 @@ export class Users { } @Get('@me/projects/:id/billing-limits') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getBillingLimits(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -347,7 +347,7 @@ export class Users { } @Put('@me/projects/:id/billing-limits') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -426,7 +426,7 @@ export class Users { } @Get('@me/projects/:id/billing-consumption') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -552,7 +552,7 @@ export class Users { } @Get('@me/projects/:id/billing-invoices') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -640,7 +640,7 @@ export class Users { } @Get('@me/projects/:id/security') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -673,7 +673,7 @@ export class Users { } @Post('@me/projects/:id/reset') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async resetProject(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -759,7 +759,7 @@ export class Users { } @Delete('@me/projects/:id') - @Middleware([isAuthenticated]) + @Middleware([isAuthenticated, requireEmailVerified]) @CatchAsync public async deleteProject(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index cf60021..eda9fa7 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express'; import signale from 'signale'; import type {AuthResponse} from '../middleware/auth.js'; -import {requireAuth} from '../middleware/auth.js'; +import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {WorkflowService} from '../services/WorkflowService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; @@ -15,7 +15,7 @@ export class Workflows { * List all workflows for the authenticated project */ @Get('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async list(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -35,7 +35,7 @@ export class Workflows { * NOTE: This must be defined BEFORE the :id route to avoid conflicts */ @Get('fields') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -58,7 +58,7 @@ export class Workflows { * Get a specific workflow with all steps and transitions */ @Get(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async get(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -78,7 +78,7 @@ export class Workflows { * Create a new workflow */ @Post('') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async create(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -108,7 +108,7 @@ export class Workflows { * Update a workflow */ @Patch(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async update(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -136,7 +136,7 @@ export class Workflows { * Delete a workflow */ @Delete(':id') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async delete(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -156,7 +156,7 @@ export class Workflows { * Add a step to a workflow */ @Post(':id/steps') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async addStep(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -188,7 +188,7 @@ export class Workflows { * Update a workflow step */ @Patch(':id/steps/:stepId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async updateStep(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -215,7 +215,7 @@ export class Workflows { * Delete a workflow step */ @Delete(':id/steps/:stepId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteStep(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -236,7 +236,7 @@ export class Workflows { * Create a transition between steps */ @Post(':id/transitions') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async createTransition(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -266,7 +266,7 @@ export class Workflows { * Delete a transition */ @Delete(':id/transitions/:transitionId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async deleteTransition(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -287,7 +287,7 @@ export class Workflows { * Start a workflow execution for a contact */ @Post(':id/executions') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async startExecution(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -312,7 +312,7 @@ export class Workflows { * List executions for a workflow */ @Get(':id/executions') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async listExecutions(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -335,7 +335,7 @@ export class Workflows { * Get a specific execution with details */ @Get(':id/executions/:executionId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async getExecution(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -356,7 +356,7 @@ export class Workflows { * Cancel a workflow execution */ @Delete(':id/executions/:executionId') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async cancelExecution(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; @@ -377,7 +377,7 @@ export class Workflows { * Cancel all active executions for a workflow */ @Post(':id/executions/cancel-all') - @Middleware([requireAuth]) + @Middleware([requireAuth, requireEmailVerified]) @CatchAsync public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) { const auth = res.locals.auth as AuthResponse; diff --git a/apps/api/src/exceptions/index.ts b/apps/api/src/exceptions/index.ts index 7e0f147..eca6288 100644 --- a/apps/api/src/exceptions/index.ts +++ b/apps/api/src/exceptions/index.ts @@ -11,6 +11,7 @@ export enum ErrorCode { FORBIDDEN = 'FORBIDDEN', PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED', PROJECT_DISABLED = 'PROJECT_DISABLED', + EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED', // Resource Errors (404-409) RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index fb3a891..4ca606c 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -404,3 +404,47 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio next(error); } }; + +/** + * Middleware to require email verification + * Must be used AFTER isAuthenticated or requireProjectAccess + * @param req + * @param res + * @param next + */ +export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => { + try { + const auth = res.locals.auth as AuthResponse; + + if (!auth.userId) { + throw new NotAuthenticated(); + } + + const user = await prisma.user.findUnique({ + where: {id: auth.userId}, + select: {emailVerified: true, type: true}, + }); + + if (!user) { + throw new NotAuthenticated(); + } + + // OAuth users are always considered verified + if (user.type !== 'PASSWORD') { + return next(); + } + + // PASSWORD users must verify email + if (!user.emailVerified) { + throw new HttpException( + 403, + 'Please verify your email address to access this resource', + ErrorCode.EMAIL_VERIFICATION_REQUIRED, + ); + } + + next(); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/services/keys.ts b/apps/api/src/services/keys.ts index 8479af8..6f6a98f 100644 --- a/apps/api/src/services/keys.ts +++ b/apps/api/src/services/keys.ts @@ -6,6 +6,18 @@ export const Keys = { email(email: string): string { return `account:${email}`; }, + emailVerificationToken(token: string): string { + return `auth:email_verification:${token}`; + }, + passwordResetToken(token: string): string { + return `auth:password_reset:${token}`; + }, + emailVerificationRateLimit(userId: string): string { + return `auth:email_verification_rate:${userId}`; + }, + passwordResetRateLimit(email: string): string { + return `auth:password_reset_rate:${email}`; + }, }, Domain: { id(id: string): string { diff --git a/apps/web/src/lib/network.ts b/apps/web/src/lib/network.ts index e1d1f89..3ef94f0 100644 --- a/apps/web/src/lib/network.ts +++ b/apps/web/src/lib/network.ts @@ -57,6 +57,15 @@ export class network { const res = (await response.json()) as ApiResponse; if (response.status >= 400) { + // Check if this is an email verification required error + if (res.error?.code === 'EMAIL_VERIFICATION_REQUIRED') { + // Redirect to verification page + if (typeof window !== 'undefined' && !window.location.href.includes('/auth/verify-email')) { + window.location.href = '/auth/verify-email'; + } + throw new Error(res.error.message ?? 'Please verify your email address to continue'); + } + // Extract error message from standardized error response or fall back to direct message property const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!'; throw new Error(errorMessage); @@ -91,6 +100,15 @@ export class network { const res = (await response.json()) as ApiResponse; if (response.status >= 400) { + // Check if this is an email verification required error + if (res.error?.code === 'EMAIL_VERIFICATION_REQUIRED') { + // Redirect to verification page + if (typeof window !== 'undefined') { + window.location.href = '/auth/verify-email'; + } + throw new Error(res.error.message ?? 'Please verify your email address to continue'); + } + // Extract error message from standardized error response or fall back to direct message property const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!'; throw new Error(errorMessage); diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 556073c..4351d63 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -19,7 +19,7 @@ dayjs.extend(relativeTime); dayjs.extend(advancedFormat); // Routes that don't require authentication -const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset', '/unsubscribe', '/subscribe', '/manage']; +const PUBLIC_ROUTES = ['/auth/login', '/auth/signup', '/auth/reset-password', '/auth/verify-email', '/unsubscribe', '/subscribe', '/manage']; // Routes that don't require a project const NO_PROJECT_ROUTES = ['/projects/create']; @@ -35,8 +35,8 @@ function App({Component, pageProps}: AppProps) { function AuthGuard({children}: {children: React.ReactNode}) { const {data: user, isLoading} = useUser(); const router = useRouter(); - const isPublicRoute = PUBLIC_ROUTES.some(route => - router.pathname === route || router.pathname.startsWith(`${route}/`) + const isPublicRoute = PUBLIC_ROUTES.some( + route => router.pathname === route || router.pathname.startsWith(`${route}/`), ); useEffect(() => { @@ -111,8 +111,8 @@ export default function WithProviders(props: AppProps) { function Root(props: AppProps) { const router = useRouter(); - const isPublicRoute = PUBLIC_ROUTES.some(route => - router.pathname === route || router.pathname.startsWith(`${route}/`) + const isPublicRoute = PUBLIC_ROUTES.some( + route => router.pathname === route || router.pathname.startsWith(`${route}/`), ); return ( diff --git a/apps/web/src/pages/auth/login.tsx b/apps/web/src/pages/auth/login.tsx index 4d5c9c8..27f444c 100644 --- a/apps/web/src/pages/auth/login.tsx +++ b/apps/web/src/pages/auth/login.tsx @@ -86,9 +86,9 @@ export default function Login() { setResetStatus('loading'); setResetError(null); try { - const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.resetPassword>( + const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.requestPasswordReset>( 'POST', - '/users/reset-password', + '/auth/request-password-reset', { email: resetEmail, }, diff --git a/apps/web/src/pages/auth/reset-password.tsx b/apps/web/src/pages/auth/reset-password.tsx new file mode 100644 index 0000000..c09cb05 --- /dev/null +++ b/apps/web/src/pages/auth/reset-password.tsx @@ -0,0 +1,213 @@ +import {zodResolver} from '@hookform/resolvers/zod'; +import {AuthenticationSchemas} from '@plunk/shared'; +import {Button, Card, CardContent, Form, FormControl, FormField, FormItem, FormLabel, FormMessage, Input} from '@plunk/ui'; +import {AnimatePresence, motion} from 'framer-motion'; +import {NextSeo} from 'next-seo'; +import Link from 'next/link'; +import {useRouter} from 'next/router'; +import React, {useEffect, useState} from 'react'; +import {useForm} from 'react-hook-form'; +import type {z} from 'zod'; + +import {network} from '../../lib/network'; + +export default function ResetPassword() { + const router = useRouter(); + const {token} = router.query; + + const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(''); + + const form = useForm>({ + resolver: zodResolver(AuthenticationSchemas.resetPassword), + defaultValues: { + token: '', + newPassword: '', + }, + }); + + // Update form token when router is ready + useEffect(() => { + if (token && typeof token === 'string') { + form.setValue('token', token); + } + }, [token, form]); + + async function onSubmit(values: z.infer) { + try { + const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.resetPassword>( + 'POST', + '/auth/reset-password', + values, + ); + + if (response.success) { + setStatus('success'); + setTimeout(() => { + void router.push('/auth/login'); + }, 2000); + } else { + setStatus('error'); + setErrorMessage('Failed to reset password. The link may be invalid or expired.'); + } + } catch (error) { + setStatus('error'); + setErrorMessage(error instanceof Error ? error.message : 'Something went wrong'); + } + } + + if (!token) { + return ( + <> + +
+
+ + +
+
+ + + +
+

Invalid reset link

+

+ This password reset link is invalid. Please request a new one from the login page. +

+ + + +
+
+
+
+
+ + ); + } + + return ( + <> + +
+
+ + + + {status === 'success' ? ( + +
+
+ + + +
+

Password reset!

+

+ Your password has been successfully reset. Redirecting to login... +

+
+
+ ) : ( + +
+ { + e.preventDefault(); + void form.handleSubmit(onSubmit)(e); + }} + > +
+
+

Reset your password

+

Enter your new password below

+
+ +
+ ( + + New Password + + + + + + )} + /> +
+ + + {status === 'error' && ( + + {errorMessage} + + )} + + + + +
+ Remember your password?{' '} + + Back to login + +
+
+
+ +
+ )} +
+
+
+
+
+ + ); +} diff --git a/apps/web/src/pages/auth/verify-email.tsx b/apps/web/src/pages/auth/verify-email.tsx new file mode 100644 index 0000000..a6bb54f --- /dev/null +++ b/apps/web/src/pages/auth/verify-email.tsx @@ -0,0 +1,236 @@ +import {AuthenticationSchemas} from '@plunk/shared'; +import {Button, Card, CardContent} from '@plunk/ui'; +import {AnimatePresence, motion} from 'framer-motion'; +import {NextSeo} from 'next-seo'; +import Link from 'next/link'; +import {useRouter} from 'next/router'; +import React, {useEffect, useRef, useState} from 'react'; + +import {network} from '../../lib/network'; + +export default function VerifyEmail() { + const router = useRouter(); + const {token} = router.query; + + const [status, setStatus] = useState<'verifying' | 'success' | 'error' | 'pending'>('pending'); + const [errorMessage, setErrorMessage] = useState(''); + const [isResending, setIsResending] = useState(false); + const [resendMessage, setResendMessage] = useState(''); + const processedToken = useRef(undefined); + + useEffect(() => { + // Wait for router to be ready before processing + if (!router.isReady) { + return; + } + + const normalizedToken = typeof token === 'string' ? token : undefined; + + if (processedToken.current === normalizedToken) { + return; + } + + processedToken.current = normalizedToken; + + // If no token, show the pending verification state + if (!token || typeof token !== 'string') { + setStatus('pending'); + return; + } + + setStatus('verifying'); + + async function verifyEmail() { + try { + const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.verifyEmail>( + 'POST', + '/auth/verify-email', + {token: token as string}, + ); + + if (response.success) { + setStatus('success'); + setTimeout(() => { + void router.push('/'); + }, 2000); + } else { + setStatus('error'); + setErrorMessage('Invalid or expired verification link'); + } + } catch (error) { + setStatus('error'); + setErrorMessage(error instanceof Error ? error.message : 'Something went wrong'); + } + } + + void verifyEmail(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, token]); + + async function handleResend() { + setIsResending(true); + setResendMessage(''); + try { + const response = await network.fetch<{success: boolean}>('POST', '/auth/request-verification'); + + if (response.success) { + setResendMessage('Verification email sent! Please check your inbox.'); + } else { + setResendMessage('Failed to send verification email. Please try again.'); + } + } catch { + setResendMessage('Failed to send verification email. Please try again.'); + } finally { + setIsResending(false); + } + } + + return ( + <> + +
+
+ + +
+ + {status === 'pending' && ( + +
+ + + +
+

Verify your email

+

+ Please check your inbox for a verification link. Click the link in the email to verify your + account. +

+ +
+ + + {resendMessage && ( +

+ {resendMessage} +

+ )} + + + + +
+
+ )} + + {status === 'verifying' && ( + +
+ + + + +
+

Verifying your email...

+

Please wait while we verify your email address.

+
+ )} + + {status === 'success' && ( + +
+ + + +
+

Email verified!

+

+ Your email has been successfully verified. Redirecting to dashboard... +

+
+ )} + + {status === 'error' && ( + +
+ + + +
+

Verification failed

+

{errorMessage}

+ +
+ + + {resendMessage && ( +

+ {resendMessage} +

+ )} + + + + +
+
+ )} +
+
+
+
+
+
+ + ); +} diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 5c94de3..0d6aec9 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -7,11 +7,12 @@ import { CardContent, CardDescription, CardHeader, - CardTitle + CardTitle, } from '@plunk/ui'; import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; +import {useState} from 'react'; import {ApiKeyDisplay} from '../components/ApiKeyDisplay'; import {DashboardLayout} from '../components/DashboardLayout'; import {QuickStart} from '../components/QuickStart'; @@ -21,6 +22,8 @@ import {useDashboardStats} from '../lib/hooks/useDashboardStats'; import {useProjectSetupState} from '../lib/hooks/useProjectSetupState'; import {useProjectSecurity} from '../lib/hooks/useProjectSecurity'; import {useConfig} from '../lib/hooks/useConfig'; +import {useUser} from '../lib/hooks/useUser'; +import {network} from '../lib/network'; export default function Index() { const {activeProject} = useActiveProject(); @@ -28,6 +31,9 @@ export default function Index() { const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id); const {securityMetrics} = useProjectSecurity(activeProject?.id); const {data: config} = useConfig(); + const {data: user} = useUser(); + const [isResending, setIsResending] = useState(false); + const [resendMessage, setResendMessage] = useState(''); const stats = [ { @@ -52,6 +58,24 @@ export default function Index() { }, ]; + async function handleResendVerification() { + setIsResending(true); + setResendMessage(''); + try { + const response = await network.fetch<{success: boolean}>('POST', '/auth/request-verification'); + + if (response.success) { + setResendMessage('Verification email sent! Please check your inbox.'); + } else { + setResendMessage('Failed to send verification email. Please try again.'); + } + } catch { + setResendMessage('Failed to send verification email. Please try again.'); + } finally { + setIsResending(false); + } + } + return ( <> @@ -71,6 +95,34 @@ export default function Index() { )} + {/* Email Verification Banner */} + {user && user.type === 'PASSWORD' && !user.emailVerified && ( + + + Verify your email address + + + Please verify your email address to unlock all features. Check your inbox for the verification link. + +
+ + {resendMessage && ( +

+ {resendMessage} +

+ )} +
+
+
+ )} + {/* Security Warning Banner */} {activeProject && !activeProject.disabled && securityMetrics && ( diff --git a/packages/db/prisma/migrations/20251220175216_add_email_verified/migration.sql b/packages/db/prisma/migrations/20251220175216_add_email_verified/migration.sql new file mode 100644 index 0000000..4cad82c --- /dev/null +++ b/packages/db/prisma/migrations/20251220175216_add_email_verified/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index fbc66eb..913ac33 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -17,9 +17,10 @@ model User { id String @id @default(uuid()) // Credentials - email String @unique - password String? - type AuthMethod + email String @unique + password String? + type AuthMethod + emailVerified Boolean @default(false) // Relations memberships Membership[] diff --git a/packages/email/src/emails/EmailVerification.tsx b/packages/email/src/emails/EmailVerification.tsx new file mode 100644 index 0000000..f135130 --- /dev/null +++ b/packages/email/src/emails/EmailVerification.tsx @@ -0,0 +1,57 @@ +import {Heading, Link, Section, Text} from '@react-email/components'; +import * as React from 'react'; +import {EmailLayout} from '../common/EmailLayout'; +import {Footer} from '../common/Footer'; +import {Header} from '../common/Header'; + +interface EmailVerificationEmailProps { + email: string; + verificationUrl: string; + landingUrl?: string; +} + +export function EmailVerificationEmail({ + email = 'user@example.com', + verificationUrl = 'https://api.useplunk.com/auth/verify-email?token=abc123', + landingUrl = 'https://www.useplunk.com', +}: EmailVerificationEmailProps) { + return ( + +
+ +
+ + Verify your email address + + + + Thanks for signing up! Please verify your email address to get started with Plunk. + + +
+ + Verify email address + +
+ +
+ + Or copy this link + + {verificationUrl} +
+ + + This link will expire in 1 hour. If you didn't sign up for Plunk, you can safely ignore this email. + +
+ +