feat: Add email verification and password reset

This commit is contained in:
Dries Augustyns
2025-12-20 20:06:25 +01:00
parent 7e25c148cc
commit 1a5607f278
31 changed files with 1026 additions and 122 deletions
+6
View File
@@ -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 // Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
// Useful for self-hosters who want to manage project status manually // Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true'; 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
+6 -6
View File
@@ -2,7 +2,7 @@ import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js'; 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 {ActivityService, ActivityType} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -21,7 +21,7 @@ export class Activity {
* - endDate: ISO date string * - endDate: ISO date string
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) { public async getActivities(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -62,7 +62,7 @@ export class Activity {
* - endDate: ISO date string (defaults to now) * - endDate: ISO date string (defaults to now)
*/ */
@Get('stats') @Get('stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) { public async getStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -82,7 +82,7 @@ export class Activity {
* - minutes: number (default 5) * - minutes: number (default 5)
*/ */
@Get('recent-count') @Get('recent-count')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) { public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -98,7 +98,7 @@ export class Activity {
* Get available activity types (for UI filters) * Get available activity types (for UI filters)
*/ */
@Get('types') @Get('types')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTypes(_req: Request, res: Response, _next: NextFunction) { public async getTypes(_req: Request, res: Response, _next: NextFunction) {
const types = Object.values(ActivityType); const types = Object.values(ActivityType);
@@ -114,7 +114,7 @@ export class Activity {
* - daysAhead: number (default 30, max 90) * - daysAhead: number (default 30, max 90)
*/ */
@Get('upcoming') @Get('upcoming')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) { public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+5 -5
View File
@@ -2,7 +2,7 @@ import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js'; 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 {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -19,7 +19,7 @@ export class Analytics {
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered) * Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/ */
@Get('timeseries') @Get('timeseries')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) { public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -41,7 +41,7 @@ export class Analytics {
* - endDate: ISO date string (defaults to now) * - endDate: ISO date string (defaults to now)
*/ */
@Get('top-campaigns') @Get('top-campaigns')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) { public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -65,7 +65,7 @@ export class Analytics {
* Returns aggregate stats: total campaigns, active, completed, average rates * Returns aggregate stats: total campaigns, active, completed, average rates
*/ */
@Get('campaign-stats') @Get('campaign-stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) { public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -89,7 +89,7 @@ export class Analytics {
* Returns events sorted by frequency with trend data * Returns events sorted by frequency with trend data
*/ */
@Get('top-events') @Get('top-events')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) { public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+194 -2
View File
@@ -1,11 +1,25 @@
import {Controller, Get, Post} from '@overnightjs/core'; import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared'; 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 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 {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.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 {AuthService} from '../services/AuthService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js'; import {UserService} from '../services/UserService.js';
@@ -64,6 +78,8 @@ export class Auth {
email, email,
password: await AuthService.generateHash(password), password: await AuthService.generateHash(password),
type: '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 // Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id); 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 token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions(); 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'}});
}
} }
+11 -11
View File
@@ -5,7 +5,7 @@ import type {NextFunction, Request, Response} from 'express';
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, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -17,7 +17,7 @@ export class Campaigns {
* POST /campaigns * POST /campaigns
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) { private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -60,7 +60,7 @@ export class Campaigns {
* GET /campaigns * GET /campaigns
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) { private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -93,7 +93,7 @@ export class Campaigns {
* GET /campaigns/:id * GET /campaigns/:id
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -112,7 +112,7 @@ export class Campaigns {
* PUT /campaigns/:id * PUT /campaigns/:id
*/ */
@Put(':id') @Put(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -158,7 +158,7 @@ export class Campaigns {
* DELETE /campaigns/:id * DELETE /campaigns/:id
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -177,7 +177,7 @@ export class Campaigns {
* POST /campaigns/:id/duplicate * POST /campaigns/:id/duplicate
*/ */
@Post(':id/duplicate') @Post(':id/duplicate')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -197,7 +197,7 @@ export class Campaigns {
* POST /campaigns/:id/send * POST /campaigns/:id/send
*/ */
@Post(':id/send') @Post(':id/send')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -228,7 +228,7 @@ export class Campaigns {
* POST /campaigns/:id/cancel * POST /campaigns/:id/cancel
*/ */
@Post(':id/cancel') @Post(':id/cancel')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -248,7 +248,7 @@ export class Campaigns {
* GET /campaigns/:id/stats * GET /campaigns/:id/stats
*/ */
@Get(':id/stats') @Get(':id/stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -267,7 +267,7 @@ export class Campaigns {
* POST /campaigns/:id/test * POST /campaigns/:id/test
*/ */
@Post(':id/test') @Post(':id/test')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
+15 -15
View File
@@ -4,7 +4,7 @@ import multer from 'multer';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; 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 {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js'; import {QueueService} from '../services/QueueService.js';
import {CatchAsync} from '../utils/asyncHandler.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 * List all contacts for the authenticated project with cursor-based pagination
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -51,7 +51,7 @@ export class Contacts {
* Returns field names with inferred types (string, number, boolean, date) * Returns field names with inferred types (string, number, boolean, date)
*/ */
@Get('fields') @Get('fields')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; 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 * Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/ */
@Get('fields/:field/values') @Get('fields/:field/values')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) { public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -110,7 +110,7 @@ export class Contacts {
* Get a specific contact by ID * Get a specific contact by ID
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -130,7 +130,7 @@ export class Contacts {
* Create or update a contact (upsert) * Create or update a contact (upsert)
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -160,7 +160,7 @@ export class Contacts {
* Update a contact * Update a contact
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -181,7 +181,7 @@ export class Contacts {
* Delete a contact * Delete a contact
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -301,7 +301,7 @@ export class Contacts {
* Get import job status * Get import job status
*/ */
@Get('import/:jobId') @Get('import/:jobId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) { public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId; 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 * Returns information about where the field is used and whether it can be safely deleted
*/ */
@Get('fields/:field/usage') @Get('fields/:field/usage')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) { public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; 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 * Only works if the field is not used in any segments or campaigns
*/ */
@Delete('fields/:field') @Delete('fields/:field')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) { public async deleteField(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -385,7 +385,7 @@ export class Contacts {
* Queue bulk subscribe operation * Queue bulk subscribe operation
*/ */
@Post('bulk-subscribe') @Post('bulk-subscribe')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) { public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -420,7 +420,7 @@ export class Contacts {
* Queue bulk unsubscribe operation * Queue bulk unsubscribe operation
*/ */
@Post('bulk-unsubscribe') @Post('bulk-unsubscribe')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) { public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -454,7 +454,7 @@ export class Contacts {
* Queue bulk delete operation * Queue bulk delete operation
*/ */
@Post('bulk-delete') @Post('bulk-delete')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) { public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -488,7 +488,7 @@ export class Contacts {
* Get bulk action job status * Get bulk action job status
*/ */
@Get('bulk/:jobId') @Get('bulk/:jobId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) { public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId; const jobId = req.params.jobId;
+5 -5
View File
@@ -5,7 +5,7 @@ import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js'; import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.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 {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js'; import {Keys} from '../services/keys.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -17,7 +17,7 @@ export class Domains {
* Get all domains for a project * Get all domains for a project
*/ */
@Get('project/:projectId') @Get('project/:projectId')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -44,7 +44,7 @@ export class Domains {
* Add a new domain to a project * Add a new domain to a project
*/ */
@Post('') @Post('')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) { public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -104,7 +104,7 @@ export class Domains {
* Check verification status for a domain * Check verification status for a domain
*/ */
@Get(':id/verify') @Get(':id/verify')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) { public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -141,7 +141,7 @@ export class Domains {
* Remove a domain from a project * Remove a domain from a project
*/ */
@Delete(':id') @Delete(':id')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) { public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+8 -8
View File
@@ -3,7 +3,7 @@ import type {NextFunction, Request, Response} from 'express';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; 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 {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -14,7 +14,7 @@ export class Events {
* Track a custom event (can trigger workflows) * Track a custom event (can trigger workflows)
*/ */
@Post('track') @Post('track')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) { public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -34,7 +34,7 @@ export class Events {
* List events for the project * List events for the project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -51,7 +51,7 @@ export class Events {
* Get event statistics * Get event statistics
*/ */
@Get('stats') @Get('stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) { public async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -68,7 +68,7 @@ export class Events {
* Get events for a specific contact * Get events for a specific contact
*/ */
@Get('contact/:contactId') @Get('contact/:contactId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) { public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -89,7 +89,7 @@ export class Events {
* Get unique event names for the project * Get unique event names for the project
*/ */
@Get('names') @Get('names')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) { public async getEventNames(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; 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 * Returns information about where the event is used and whether it can be safely deleted
*/ */
@Get(':eventName/usage') @Get(':eventName/usage')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) { public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; 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 * Only works if the event is not used in any segments or workflows
*/ */
@Delete(':eventName') @Delete(':eventName')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) { public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+1
View File
@@ -85,6 +85,7 @@ export class Github {
data: { data: {
email, email,
type: 'GITHUB_OAUTH', type: 'GITHUB_OAUTH',
emailVerified: true,
}, },
}); });
isNewUser = true; isNewUser = true;
+1
View File
@@ -75,6 +75,7 @@ export class Google {
data: { data: {
email, email,
type: 'GOOGLE_OAUTH', type: 'GOOGLE_OAUTH',
emailVerified: true,
}, },
}); });
isNewUser = true; isNewUser = true;
+7 -7
View File
@@ -5,7 +5,7 @@ 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, requireEmailVerified} from '../middleware/auth.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -16,7 +16,7 @@ export class Projects {
* GET /projects/:id/setup-state * GET /projects/:id/setup-state
*/ */
@Get(':id/setup-state') @Get(':id/setup-state')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -89,7 +89,7 @@ export class Projects {
* GET /projects/:id/security * GET /projects/:id/security
*/ */
@Get(':id/security') @Get(':id/security')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) { private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -121,7 +121,7 @@ export class Projects {
* GET /projects/:id/members * GET /projects/:id/members
*/ */
@Get(':id/members') @Get(':id/members')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -170,7 +170,7 @@ export class Projects {
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' } * Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
*/ */
@Post(':id/members') @Post(':id/members')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@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;
@@ -253,7 +253,7 @@ export class Projects {
* Body: { role: 'ADMIN' | 'MEMBER' } * Body: { role: 'ADMIN' | 'MEMBER' }
*/ */
@Patch(':id/members/:userId') @Patch(':id/members/:userId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) { private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -345,7 +345,7 @@ export class Projects {
* DELETE /projects/:id/members/:userId * DELETE /projects/:id/members/:userId
*/ */
@Delete(':id/members/:userId') @Delete(':id/members/:userId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) { private async removeMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+9 -9
View File
@@ -2,7 +2,7 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js'; 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 {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -13,7 +13,7 @@ export class Segments {
* List all segments for the authenticated project * List all segments for the authenticated project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -28,7 +28,7 @@ export class Segments {
* Get a specific segment by ID with member count * Get a specific segment by ID with member count
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -48,7 +48,7 @@ export class Segments {
* Get contacts that match a segment's filters * Get contacts that match a segment's filters
*/ */
@Get(':id/contacts') @Get(':id/contacts')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) { public async getContacts(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -70,7 +70,7 @@ export class Segments {
* Create a new segment * Create a new segment
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -99,7 +99,7 @@ export class Segments {
* Update a segment * Update a segment
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -129,7 +129,7 @@ export class Segments {
* Delete a segment * Delete a segment
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -149,7 +149,7 @@ export class Segments {
* Recompute segment membership for all contacts * Recompute segment membership for all contacts
*/ */
@Post(':id/compute') @Post(':id/compute')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) { public async compute(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -169,7 +169,7 @@ export class Segments {
* Refresh segment member count * Refresh segment member count
*/ */
@Post(':id/refresh') @Post(':id/refresh')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) { public async refresh(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+8 -8
View File
@@ -3,7 +3,7 @@ import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js'; 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 {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js'; import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,7 +15,7 @@ export class Templates {
* List all templates for the authenticated project * List all templates for the authenticated project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -34,7 +34,7 @@ export class Templates {
* Get a specific template by ID * Get a specific template by ID
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -54,7 +54,7 @@ export class Templates {
* Create a new template * Create a new template
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -98,7 +98,7 @@ export class Templates {
* Update a template * Update a template
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -133,7 +133,7 @@ export class Templates {
* Delete a template * Delete a template
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -153,7 +153,7 @@ export class Templates {
* Duplicate a template * Duplicate a template
*/ */
@Post(':id/duplicate') @Post(':id/duplicate')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) { public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -173,7 +173,7 @@ export class Templates {
* Get template usage statistics * Get template usage statistics
*/ */
@Get(':id/usage') @Get(':id/usage')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) { public async getUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+2 -2
View File
@@ -4,7 +4,7 @@ import multer from 'multer';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; 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 * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -33,7 +33,7 @@ export class Uploads {
* Upload an image file to S3/Minio * Upload an image file to S3/Minio
*/ */
@Post('image') @Post('image')
@Middleware([requireAuth, upload.single('image')]) @Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync @CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) { public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+15 -15
View File
@@ -9,7 +9,7 @@ import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js'; import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.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 {BillingLimitService} from '../services/BillingLimitService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
@@ -20,7 +20,7 @@ import signale from 'signale';
@Controller('users') @Controller('users')
export class Users { export class Users {
@Get('@me') @Get('@me')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) { public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -39,7 +39,7 @@ export class Users {
} }
@Get('@me/projects') @Get('@me/projects')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) { public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -54,7 +54,7 @@ export class Users {
} }
@Post('@me/projects') @Post('@me/projects')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) { public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -101,7 +101,7 @@ export class Users {
} }
@Patch('@me/projects/:id') @Patch('@me/projects/:id')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -133,7 +133,7 @@ export class Users {
} }
@Post('@me/projects/:id/regenerate-keys') @Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -185,7 +185,7 @@ export class Users {
} }
@Post('@me/projects/:id/checkout') @Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -264,7 +264,7 @@ export class Users {
} }
@Post('@me/projects/:id/billing-portal') @Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -314,7 +314,7 @@ export class Users {
} }
@Get('@me/projects/:id/billing-limits') @Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -347,7 +347,7 @@ export class Users {
} }
@Put('@me/projects/:id/billing-limits') @Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -426,7 +426,7 @@ export class Users {
} }
@Get('@me/projects/:id/billing-consumption') @Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -552,7 +552,7 @@ export class Users {
} }
@Get('@me/projects/:id/billing-invoices') @Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -640,7 +640,7 @@ export class Users {
} }
@Get('@me/projects/:id/security') @Get('@me/projects/:id/security')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -673,7 +673,7 @@ export class Users {
} }
@Post('@me/projects/:id/reset') @Post('@me/projects/:id/reset')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
@@ -759,7 +759,7 @@ export class Users {
} }
@Delete('@me/projects/:id') @Delete('@me/projects/:id')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@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;
+17 -17
View File
@@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express';
import signale from 'signale'; import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; 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 {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,7 +15,7 @@ export class Workflows {
* List all workflows for the authenticated project * List all workflows for the authenticated project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; 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 * NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/ */
@Get('fields') @Get('fields')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -58,7 +58,7 @@ export class Workflows {
* Get a specific workflow with all steps and transitions * Get a specific workflow with all steps and transitions
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -78,7 +78,7 @@ export class Workflows {
* Create a new workflow * Create a new workflow
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -108,7 +108,7 @@ export class Workflows {
* Update a workflow * Update a workflow
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -136,7 +136,7 @@ export class Workflows {
* Delete a workflow * Delete a workflow
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -156,7 +156,7 @@ export class Workflows {
* Add a step to a workflow * Add a step to a workflow
*/ */
@Post(':id/steps') @Post(':id/steps')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) { public async addStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -188,7 +188,7 @@ export class Workflows {
* Update a workflow step * Update a workflow step
*/ */
@Patch(':id/steps/:stepId') @Patch(':id/steps/:stepId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) { public async updateStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -215,7 +215,7 @@ export class Workflows {
* Delete a workflow step * Delete a workflow step
*/ */
@Delete(':id/steps/:stepId') @Delete(':id/steps/:stepId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) { public async deleteStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -236,7 +236,7 @@ export class Workflows {
* Create a transition between steps * Create a transition between steps
*/ */
@Post(':id/transitions') @Post(':id/transitions')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) { public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -266,7 +266,7 @@ export class Workflows {
* Delete a transition * Delete a transition
*/ */
@Delete(':id/transitions/:transitionId') @Delete(':id/transitions/:transitionId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) { public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -287,7 +287,7 @@ export class Workflows {
* Start a workflow execution for a contact * Start a workflow execution for a contact
*/ */
@Post(':id/executions') @Post(':id/executions')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) { public async startExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -312,7 +312,7 @@ export class Workflows {
* List executions for a workflow * List executions for a workflow
*/ */
@Get(':id/executions') @Get(':id/executions')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) { public async listExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -335,7 +335,7 @@ export class Workflows {
* Get a specific execution with details * Get a specific execution with details
*/ */
@Get(':id/executions/:executionId') @Get(':id/executions/:executionId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) { public async getExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -356,7 +356,7 @@ export class Workflows {
* Cancel a workflow execution * Cancel a workflow execution
*/ */
@Delete(':id/executions/:executionId') @Delete(':id/executions/:executionId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) { public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
@@ -377,7 +377,7 @@ export class Workflows {
* Cancel all active executions for a workflow * Cancel all active executions for a workflow
*/ */
@Post(':id/executions/cancel-all') @Post(':id/executions/cancel-all')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) { public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth as AuthResponse;
+1
View File
@@ -11,6 +11,7 @@ export enum ErrorCode {
FORBIDDEN = 'FORBIDDEN', FORBIDDEN = 'FORBIDDEN',
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED', PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
PROJECT_DISABLED = 'PROJECT_DISABLED', PROJECT_DISABLED = 'PROJECT_DISABLED',
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
// Resource Errors (404-409) // Resource Errors (404-409)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
+44
View File
@@ -404,3 +404,47 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
next(error); 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);
}
};
+12
View File
@@ -6,6 +6,18 @@ export const Keys = {
email(email: string): string { email(email: string): string {
return `account:${email}`; 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: { Domain: {
id(id: string): string { id(id: string): string {
+18
View File
@@ -57,6 +57,15 @@ export class network {
const res = (await response.json()) as ApiResponse; const res = (await response.json()) as ApiResponse;
if (response.status >= 400) { 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 // Extract error message from standardized error response or fall back to direct message property
const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!'; const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!';
throw new Error(errorMessage); throw new Error(errorMessage);
@@ -91,6 +100,15 @@ export class network {
const res = (await response.json()) as ApiResponse; const res = (await response.json()) as ApiResponse;
if (response.status >= 400) { 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 // Extract error message from standardized error response or fall back to direct message property
const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!'; const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!';
throw new Error(errorMessage); throw new Error(errorMessage);
+5 -5
View File
@@ -19,7 +19,7 @@ dayjs.extend(relativeTime);
dayjs.extend(advancedFormat); dayjs.extend(advancedFormat);
// Routes that don't require authentication // 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 // Routes that don't require a project
const NO_PROJECT_ROUTES = ['/projects/create']; const NO_PROJECT_ROUTES = ['/projects/create'];
@@ -35,8 +35,8 @@ function App({Component, pageProps}: AppProps) {
function AuthGuard({children}: {children: React.ReactNode}) { function AuthGuard({children}: {children: React.ReactNode}) {
const {data: user, isLoading} = useUser(); const {data: user, isLoading} = useUser();
const router = useRouter(); const router = useRouter();
const isPublicRoute = PUBLIC_ROUTES.some(route => const isPublicRoute = PUBLIC_ROUTES.some(
router.pathname === route || router.pathname.startsWith(`${route}/`) route => router.pathname === route || router.pathname.startsWith(`${route}/`),
); );
useEffect(() => { useEffect(() => {
@@ -111,8 +111,8 @@ export default function WithProviders(props: AppProps) {
function Root(props: AppProps) { function Root(props: AppProps) {
const router = useRouter(); const router = useRouter();
const isPublicRoute = PUBLIC_ROUTES.some(route => const isPublicRoute = PUBLIC_ROUTES.some(
router.pathname === route || router.pathname.startsWith(`${route}/`) route => router.pathname === route || router.pathname.startsWith(`${route}/`),
); );
return ( return (
+2 -2
View File
@@ -86,9 +86,9 @@ export default function Login() {
setResetStatus('loading'); setResetStatus('loading');
setResetError(null); setResetError(null);
try { try {
const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.resetPassword>( const response = await network.fetch<{success: boolean}, typeof AuthenticationSchemas.requestPasswordReset>(
'POST', 'POST',
'/users/reset-password', '/auth/request-password-reset',
{ {
email: resetEmail, email: resetEmail,
}, },
+213
View File
@@ -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<string>('');
const form = useForm<z.infer<typeof AuthenticationSchemas.resetPassword>>({
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<typeof AuthenticationSchemas.resetPassword>) {
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 (
<>
<NextSeo title="Reset Password" />
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-red-600">Invalid reset link</h1>
<p className="text-neutral-600">
This password reset link is invalid. Please request a new one from the login page.
</p>
<Link href="/auth/login">
<Button className="w-full mt-4">Back to login</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</>
);
}
return (
<>
<NextSeo title="Reset Password" />
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<Card>
<CardContent className="p-0">
<AnimatePresence mode="wait">
{status === 'success' ? (
<motion.div
key="success"
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
className="p-8"
>
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-green-600">Password reset!</h1>
<p className="text-neutral-600">
Your password has been successfully reset. Redirecting to login...
</p>
</div>
</motion.div>
) : (
<motion.div
key="form"
initial={{opacity: 1}}
exit={{opacity: 0}}
className="p-8"
>
<Form {...form}>
<form
onSubmit={e => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Reset your password</h1>
<p className="text-neutral-600">Enter your new password below</p>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="newPassword"
render={({field}) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input placeholder="Enter new password" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{status === 'error' && (
<motion.p
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</AnimatePresence>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Reset password'
)}
</Button>
<div className="text-center text-sm text-neutral-500">
Remember your password?{' '}
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Back to login
</Link>
</div>
</div>
</form>
</Form>
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
</div>
</div>
</>
);
}
+236
View File
@@ -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<string>('');
const [isResending, setIsResending] = useState(false);
const [resendMessage, setResendMessage] = useState<string>('');
const processedToken = useRef<string | undefined>(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 (
<>
<NextSeo title="Verify Email" />
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<Card>
<CardContent className="p-8">
<div className="flex flex-col gap-6 text-center">
<AnimatePresence mode="wait">
{status === 'pending' && (
<motion.div
key="pending"
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
className="flex flex-col items-center gap-4"
>
<div className="h-16 w-16 rounded-full bg-blue-100 flex items-center justify-center">
<svg className="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
<p className="text-neutral-600">
Please check your inbox for a verification link. Click the link in the email to verify your
account.
</p>
<div className="flex flex-col gap-3 w-full mt-4">
<Button onClick={handleResend} disabled={isResending} className="w-full">
{isResending ? 'Sending...' : 'Resend verification email'}
</Button>
{resendMessage && (
<p
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
>
{resendMessage}
</p>
)}
<Link href="/auth/login">
<Button variant="outline" className="w-full">
Back to login
</Button>
</Link>
</div>
</motion.div>
)}
{status === 'verifying' && (
<motion.div
key="verifying"
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="flex flex-col items-center gap-4"
>
<div className="h-16 w-16 rounded-full bg-neutral-100 flex items-center justify-center">
<svg
className="h-8 w-8 animate-spin text-neutral-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight">Verifying your email...</h1>
<p className="text-neutral-600">Please wait while we verify your email address.</p>
</motion.div>
)}
{status === 'success' && (
<motion.div
key="success"
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
className="flex flex-col items-center gap-4"
>
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-green-600">Email verified!</h1>
<p className="text-neutral-600">
Your email has been successfully verified. Redirecting to dashboard...
</p>
</motion.div>
)}
{status === 'error' && (
<motion.div
key="error"
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
className="flex flex-col items-center gap-4"
>
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-red-600">Verification failed</h1>
<p className="text-neutral-600">{errorMessage}</p>
<div className="flex flex-col gap-3 w-full mt-4">
<Button onClick={handleResend} disabled={isResending} className="w-full">
{isResending ? 'Sending...' : 'Resend verification email'}
</Button>
{resendMessage && (
<p
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
>
{resendMessage}
</p>
)}
<Link href="/auth/login">
<Button variant="outline" className="w-full">
Back to login
</Button>
</Link>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</CardContent>
</Card>
</div>
</div>
</>
);
}
+53 -1
View File
@@ -7,11 +7,12 @@ import {
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle CardTitle,
} from '@plunk/ui'; } from '@plunk/ui';
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react'; import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import Link from 'next/link'; import Link from 'next/link';
import {useState} from 'react';
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';
@@ -21,6 +22,8 @@ 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 {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
import {useConfig} from '../lib/hooks/useConfig'; import {useConfig} from '../lib/hooks/useConfig';
import {useUser} from '../lib/hooks/useUser';
import {network} from '../lib/network';
export default function Index() { export default function Index() {
const {activeProject} = useActiveProject(); const {activeProject} = useActiveProject();
@@ -28,6 +31,9 @@ export default function Index() {
const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id); const {setupState, isLoading: isLoadingSetupState} = useProjectSetupState(activeProject?.id);
const {securityMetrics} = useProjectSecurity(activeProject?.id); const {securityMetrics} = useProjectSecurity(activeProject?.id);
const {data: config} = useConfig(); const {data: config} = useConfig();
const {data: user} = useUser();
const [isResending, setIsResending] = useState(false);
const [resendMessage, setResendMessage] = useState<string>('');
const stats = [ 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 ( return (
<> <>
<NextSeo title="Dashboard" /> <NextSeo title="Dashboard" />
@@ -71,6 +95,34 @@ export default function Index() {
</Alert> </Alert>
)} )}
{/* Email Verification Banner */}
{user && user.type === 'PASSWORD' && !user.emailVerified && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Verify your email address</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<span className="text-sm">
Please verify your email address to unlock all features. Check your inbox for the verification link.
</span>
<div className="flex flex-col gap-2">
<Button
size="sm"
className="w-full sm:w-auto"
onClick={handleResendVerification}
disabled={isResending}
>
{isResending ? 'Sending...' : 'Resend verification email'}
</Button>
{resendMessage && (
<p className={`text-xs ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}>
{resendMessage}
</p>
)}
</div>
</AlertDescription>
</Alert>
)}
{/* Security Warning Banner */} {/* Security Warning Banner */}
{activeProject && !activeProject.disabled && securityMetrics && ( {activeProject && !activeProject.disabled && securityMetrics && (
<SecurityWarningBanner status={securityMetrics.status} /> <SecurityWarningBanner status={securityMetrics.status} />
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "emailVerified" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -20,6 +20,7 @@ model User {
email String @unique email String @unique
password String? password String?
type AuthMethod type AuthMethod
emailVerified Boolean @default(false)
// Relations // Relations
memberships Membership[] memberships Membership[]
@@ -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 = '[email protected]',
verificationUrl = 'https://api.useplunk.com/auth/verify-email?token=abc123',
landingUrl = 'https://www.useplunk.com',
}: EmailVerificationEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
Verify your email address
</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Thanks for signing up! Please verify your email address to get started with Plunk.
</Text>
<Section className="mb-8">
<Link
href={verificationUrl}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Verify email address
</Link>
</Section>
<Section className="mb-8 rounded-lg bg-gray-50 px-6 py-4" style={{border: '1px solid #e5e7eb'}}>
<Text className="mb-2 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Or copy this link
</Text>
<Text className="mb-0 mt-0 break-all text-sm text-gray-600">{verificationUrl}</Text>
</Section>
<Text className="mb-0 mt-0 text-sm text-gray-500">
This link will expire in 1 hour. If you didn't sign up for Plunk, you can safely ignore this email.
</Text>
</Section>
<Footer landingUrl={landingUrl} />
</EmailLayout>
);
}
export default EmailVerificationEmail;
@@ -0,0 +1,59 @@
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 PasswordResetEmailProps {
email: string;
resetUrl: string;
landingUrl?: string;
}
export function PasswordResetEmail({
email = '[email protected]',
resetUrl = 'https://app.useplunk.com/auth/reset-password?token=abc123',
landingUrl = 'https://www.useplunk.com',
}: PasswordResetEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">
Reset your password
</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
We received a request to reset your password. Click the button below to create a new password.
</Text>
<Section className="mb-8">
<Link
href={resetUrl}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Reset password
</Link>
</Section>
<Section className="mb-8 rounded-lg bg-amber-50 px-6 py-4" style={{border: '1px solid #fbbf24'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-amber-900">
This link will expire in 1 hour. If you didn't request a password reset, you can safely ignore this email.
</Text>
</Section>
<Section className="mb-8 rounded-lg bg-gray-50 px-6 py-4" style={{border: '1px solid #e5e7eb'}}>
<Text className="mb-2 mt-0 text-xs font-medium uppercase tracking-wider text-gray-500">
Or copy this link
</Text>
<Text className="mb-0 mt-0 break-all text-sm text-gray-600">{resetUrl}</Text>
</Section>
</Section>
<Footer landingUrl={landingUrl} />
</EmailLayout>
);
}
export default PasswordResetEmail;
+2
View File
@@ -1,3 +1,5 @@
export {ProjectDisabledEmail} from './ProjectDisabled'; export {ProjectDisabledEmail} from './ProjectDisabled';
export {BillingLimitWarningEmail} from './BillingLimitWarning'; export {BillingLimitWarningEmail} from './BillingLimitWarning';
export {BillingLimitExceededEmail} from './BillingLimitExceeded'; export {BillingLimitExceededEmail} from './BillingLimitExceeded';
export {EmailVerificationEmail} from './EmailVerification';
export {PasswordResetEmail} from './PasswordReset';
+8 -1
View File
@@ -48,9 +48,16 @@ export const AuthenticationSchemas = {
email, email,
password: z.string().min(6), password: z.string().min(6),
}), }),
resetPassword: z.object({ verifyEmail: z.object({
token: z.string().length(64, 'Invalid verification token'),
}),
requestPasswordReset: z.object({
email, email,
}), }),
resetPassword: z.object({
token: z.string().length(64, 'Invalid reset token'),
newPassword: z.string().min(6, 'Password must be at least 6 characters'),
}),
} as const; } as const;
export const ProjectSchemas = { export const ProjectSchemas = {