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