feat: Implement password reset and email verification with Mailgun
Some checks failed
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled

Backend changes:
- Add password reset token database migration (V011)
- Create email service with Mailgun integration (EU/US regions)
- Implement password reset flow with secure token generation
- Add email verification endpoints and logic
- Create beautiful HTML email templates for reset and verification
- Add password reset DTOs with validation
- Update User entity with email verification fields

Frontend changes:
- Create forgot password page with email submission
- Create reset password page with token validation
- Add email verification banner component
- Integrate verification banner into main dashboard
- Add password requirements and validation UI

Features:
- Mailgun API ready for EU and US regions
- Secure token expiration (1h for reset, 24h for verification)
- Rate limiting on resend (2min interval)
- Protection against email enumeration
- IP address and user agent tracking
- Token reuse prevention

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-01 19:17:48 +00:00
parent 7ee79adcea
commit aaa239121e
16 changed files with 1487 additions and 6 deletions

View File

@@ -35,9 +35,11 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
"form-data": "^4.0.4",
"graphql": "^16.11.0",
"ioredis": "^5.8.0",
"langchain": "^0.3.35",
"mailgun.js": "^12.1.0",
"multer": "^2.0.2",
"openai": "^5.23.2",
"passport": "^0.7.0",
@@ -6978,6 +6980,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base-64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -11471,6 +11479,20 @@
"node": ">=12"
}
},
"node_modules/mailgun.js": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/mailgun.js/-/mailgun.js-12.1.0.tgz",
"integrity": "sha512-Cd6rD/aYosAuKcHI9iHdgF6n65nNB63ClQrpsvNVxDeks49SMOuh1euFXV/JUNB27n4jMZhprXSKnftcV2Bm9Q==",
"license": "MIT",
"dependencies": {
"axios": "^1.12.1",
"base-64": "^1.0.0",
"url-join": "^4.0.1"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -14840,6 +14862,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-join": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -47,9 +47,11 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
"form-data": "^4.0.4",
"graphql": "^16.11.0",
"ioredis": "^5.8.0",
"langchain": "^0.3.35",
"mailgun.js": "^12.1.0",
"multer": "^2.0.2",
"openai": "^5.23.2",
"passport": "^0.7.0",

View File

@@ -4,11 +4,12 @@ import { AuditLog } from '../database/entities';
import { AuditService } from './services/audit.service';
import { ErrorResponseService } from './services/error-response.service';
import { CacheService } from './services/cache.service';
import { EmailService } from './services/email.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [AuditService, ErrorResponseService, CacheService],
exports: [AuditService, ErrorResponseService, CacheService],
providers: [AuditService, ErrorResponseService, CacheService, EmailService],
exports: [AuditService, ErrorResponseService, CacheService, EmailService],
})
export class CommonModule {}

View File

@@ -0,0 +1,299 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Mailgun from 'mailgun.js';
import formData from 'form-data';
export interface EmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
export interface PasswordResetEmailData {
userName: string;
resetLink: string;
expiresIn: string;
}
export interface EmailVerificationData {
userName: string;
verificationLink: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private mailgunClient: any;
private readonly fromEmail: string;
private readonly fromName: string;
private readonly appUrl: string;
private readonly mailgunDomain: string;
constructor(private configService: ConfigService) {
const mailgunApiKey = this.configService.get<string>('MAILGUN_API_KEY');
const mailgunRegion = this.configService.get<string>('MAILGUN_REGION', 'us'); // 'us' or 'eu'
this.mailgunDomain = this.configService.get<string>('MAILGUN_DOMAIN', '');
this.fromEmail = this.configService.get<string>('EMAIL_FROM', 'noreply@maternal-app.com');
this.fromName = this.configService.get<string>('EMAIL_FROM_NAME', 'Maternal App');
this.appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
// Initialize Mailgun client
if (mailgunApiKey && this.mailgunDomain) {
const mailgun = new Mailgun(formData);
this.mailgunClient = mailgun.client({
username: 'api',
key: mailgunApiKey,
url: mailgunRegion === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net',
});
this.logger.log(`Mailgun initialized for ${mailgunRegion.toUpperCase()} region`);
} else {
this.logger.warn('Mailgun not configured - emails will be logged but not sent');
}
}
/**
* Send a generic email
*/
async sendEmail(options: EmailOptions): Promise<void> {
const { to, subject, html, text } = options;
if (!this.mailgunClient) {
this.logger.warn(`[EMAIL NOT SENT - No Mailgun Config] To: ${to}, Subject: ${subject}`);
this.logger.debug(`Email content: ${text || html.substring(0, 200)}...`);
return;
}
try {
const messageData = {
from: `${this.fromName} <${this.fromEmail}>`,
to,
subject,
html,
text: text || this.stripHtml(html),
};
await this.mailgunClient.messages.create(this.mailgunDomain, messageData);
this.logger.log(`Email sent successfully to ${to}: ${subject}`);
} catch (error) {
this.logger.error(`Failed to send email to ${to}: ${error.message}`, error.stack);
throw error;
}
}
/**
* Send password reset email
*/
async sendPasswordResetEmail(to: string, data: PasswordResetEmailData): Promise<void> {
const subject = 'Reset Your Maternal App Password';
const html = this.getPasswordResetEmailTemplate(data);
await this.sendEmail({ to, subject, html });
}
/**
* Send email verification email
*/
async sendEmailVerificationEmail(to: string, data: EmailVerificationData): Promise<void> {
const subject = 'Verify Your Maternal App Email';
const html = this.getEmailVerificationTemplate(data);
await this.sendEmail({ to, subject, html });
}
/**
* Send welcome email after successful registration
*/
async sendWelcomeEmail(to: string, userName: string): Promise<void> {
const subject = 'Welcome to Maternal App! 🎉';
const html = this.getWelcomeEmailTemplate(userName);
await this.sendEmail({ to, subject, html });
}
/**
* Password reset email template
*/
private getPasswordResetEmailTemplate(data: PasswordResetEmailData): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Your Password</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
.content { padding: 40px 30px; }
.content p { margin: 0 0 20px 0; font-size: 16px; }
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
.button:hover { background: #FF7B6D; }
.info-box { background: #FFF5F5; border-left: 4px solid #FFB5A0; padding: 15px; margin: 20px 0; border-radius: 4px; }
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
.footer a { color: #FF8B7D; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Reset Your Password</h1>
</div>
<div class="content">
<p>Hi ${data.userName},</p>
<p>We received a request to reset your password for your Maternal App account. Click the button below to create a new password:</p>
<div style="text-align: center;">
<a href="${data.resetLink}" class="button">Reset Password</a>
</div>
<div class="info-box">
<strong>⏱️ This link expires in ${data.expiresIn}</strong><br>
If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
</div>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666; font-size: 14px;">${data.resetLink}</p>
<p style="margin-top: 30px;">Best regards,<br>The Maternal App Team</p>
</div>
<div class="footer">
<p>If you have any questions, contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Email verification template
*/
private getEmailVerificationTemplate(data: EmailVerificationData): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Your Email</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
.content { padding: 40px 30px; }
.content p { margin: 0 0 20px 0; font-size: 16px; }
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
.button:hover { background: #FF7B6D; }
.info-box { background: #E8F5E9; border-left: 4px solid #4CAF50; padding: 15px; margin: 20px 0; border-radius: 4px; }
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
.footer a { color: #FF8B7D; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Verify Your Email 📧</h1>
</div>
<div class="content">
<p>Hi ${data.userName},</p>
<p>Thank you for signing up for Maternal App! To complete your registration and access all features, please verify your email address by clicking the button below:</p>
<div style="text-align: center;">
<a href="${data.verificationLink}" class="button">Verify Email</a>
</div>
<div class="info-box">
<strong>✅ Why verify?</strong><br>
Email verification helps us ensure your account security and enables us to send you important updates about your child's activities and milestones.
</div>
<p>If the button doesn't work, copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #666; font-size: 14px;">${data.verificationLink}</p>
<p style="margin-top: 30px;">Welcome to the Maternal App family! 🎉<br>The Maternal App Team</p>
</div>
<div class="footer">
<p>If you didn't create an account, you can safely ignore this email.</p>
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Welcome email template
*/
private getWelcomeEmailTemplate(userName: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Welcome to Maternal App</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 40px auto; background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.header { background: linear-gradient(135deg, #FFB5A0 0%, #FF8B7D 100%); padding: 40px 20px; text-align: center; color: white; }
.header h1 { margin: 0; font-size: 28px; font-weight: 600; }
.content { padding: 40px 30px; }
.content p { margin: 0 0 20px 0; font-size: 16px; }
.feature-box { background: #FFF5F5; padding: 20px; margin: 15px 0; border-radius: 6px; border-left: 4px solid #FFB5A0; }
.feature-box h3 { margin: 0 0 10px 0; color: #FF8B7D; font-size: 18px; }
.button { display: inline-block; padding: 14px 32px; background: #FF8B7D; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px; margin: 20px 0; }
.footer { background: #f9f9f9; padding: 20px 30px; text-align: center; font-size: 14px; color: #666; }
.footer a { color: #FF8B7D; text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to Maternal App! 🎉</h1>
</div>
<div class="content">
<p>Hi ${userName},</p>
<p>We're thrilled to have you join the Maternal App family! Our mission is to help you track and understand your child's development, reduce your mental load, and provide AI-powered parenting support whenever you need it.</p>
<div class="feature-box">
<h3>📊 Track Activities</h3>
<p>Easily log feeding, sleep, diaper changes, and more with our intuitive interface.</p>
</div>
<div class="feature-box">
<h3>🤖 AI Parenting Assistant</h3>
<p>Get personalized insights and answers to your parenting questions 24/7.</p>
</div>
<div class="feature-box">
<h3>👨‍👩‍👧 Family Sync</h3>
<p>Keep everyone in the loop with real-time activity updates across all devices.</p>
</div>
<div style="text-align: center;">
<a href="${this.appUrl}" class="button">Get Started</a>
</div>
<p style="margin-top: 30px;">If you have any questions or need help getting started, our support team is here for you!</p>
<p>Happy parenting! 💕<br>The Maternal App Team</p>
</div>
<div class="footer">
<p>Questions? Contact us at <a href="mailto:support@maternal-app.com">support@maternal-app.com</a></p>
<p>© ${new Date().getFullYear()} Maternal App. All rights reserved.</p>
</div>
</div>
</body>
</html>
`.trim();
}
/**
* Strip HTML tags for plain text version
*/
private stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
}

View File

@@ -4,6 +4,7 @@ export { Family } from './family.entity';
export { FamilyMember, FamilyRole, FamilyPermissions } from './family-member.entity';
export { Child } from './child.entity';
export { RefreshToken } from './refresh-token.entity';
export { PasswordResetToken } from './password-reset-token.entity';
export { AIConversation, MessageRole, ConversationMessage } from './ai-conversation.entity';
export { Activity, ActivityType } from './activity.entity';
export { AuditLog, AuditAction, EntityType } from './audit-log.entity';

View File

@@ -0,0 +1,57 @@
import {
Entity,
Column,
PrimaryColumn,
ManyToOne,
JoinColumn,
CreateDateColumn,
BeforeInsert,
} from 'typeorm';
import { nanoid } from 'nanoid';
import { User } from './user.entity';
@Entity('password_reset_tokens')
export class PasswordResetToken {
@PrimaryColumn({ type: 'varchar', length: 30 })
id: string;
@Column({ type: 'varchar', length: 20, name: 'user_id' })
userId: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ type: 'varchar', length: 64, unique: true })
token: string;
@Column({ type: 'timestamp without time zone', name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'timestamp without time zone', name: 'used_at', nullable: true })
usedAt: Date | null;
@Column({ type: 'varchar', length: 45, name: 'ip_address', nullable: true })
ipAddress: string | null;
@Column({ type: 'text', name: 'user_agent', nullable: true })
userAgent: string | null;
@CreateDateColumn({ type: 'timestamp without time zone', name: 'created_at' })
createdAt: Date;
@BeforeInsert()
generateId() {
if (!this.id) {
this.id = `reset_${nanoid(12)}`;
}
}
isExpired(): boolean {
return new Date() > this.expiresAt;
}
isUsed(): boolean {
return this.usedAt !== null;
}
}

View File

@@ -36,6 +36,12 @@ export class User {
@Column({ name: 'email_verified', default: false })
emailVerified: boolean;
@Column({ name: 'email_verification_token', length: 64, nullable: true })
emailVerificationToken?: string | null;
@Column({ name: 'email_verification_sent_at', type: 'timestamp without time zone', nullable: true })
emailVerificationSentAt?: Date | null;
@Column({ type: 'jsonb', nullable: true })
preferences?: {
notifications?: boolean;

View File

@@ -0,0 +1,43 @@
-- Migration: V011_create_password_reset_tokens
-- Description: Create password reset tokens and email verification tracking
-- Password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id VARCHAR(30) PRIMARY KEY,
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
used_at TIMESTAMP WITHOUT TIME ZONE,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX idx_password_reset_tokens_token ON password_reset_tokens(token);
CREATE INDEX idx_password_reset_tokens_expires ON password_reset_tokens(expires_at);
-- Add email verification fields to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(64),
ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP WITHOUT TIME ZONE;
CREATE INDEX idx_users_email_verification_token ON users(email_verification_token);
-- Email verification history (for auditing)
CREATE TABLE IF NOT EXISTS email_verification_logs (
id VARCHAR(30) PRIMARY KEY,
user_id VARCHAR(20) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
verification_type VARCHAR(50) NOT NULL, -- 'signup', 'password_reset', 'email_change'
token_sent_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
verified_at TIMESTAMP WITHOUT TIME ZONE,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_email_verification_logs_user ON email_verification_logs(user_id);
CREATE INDEX idx_email_verification_logs_email ON email_verification_logs(email);
CREATE INDEX idx_email_verification_logs_created ON email_verification_logs(created_at DESC);

View File

@@ -8,19 +8,26 @@ import {
HttpStatus,
UseGuards,
ValidationPipe,
Ip,
Headers,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { PasswordResetService } from './password-reset.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { LogoutDto } from './dto/logout.dto';
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
import { Public } from './decorators/public.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@Controller('api/v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly passwordResetService: PasswordResetService,
) {}
@Public()
@Post('register')
@@ -69,4 +76,48 @@ export class AuthController {
) {
return await this.authService.updateProfile(user.userId, updateData);
}
// Password Reset Endpoints
@Public()
@Post('password/forgot')
@HttpCode(HttpStatus.OK)
async forgotPassword(
@Body(ValidationPipe) dto: RequestPasswordResetDto,
@Ip() ip: string,
@Headers('user-agent') userAgent: string,
) {
return await this.passwordResetService.requestPasswordReset(dto, ip, userAgent);
}
@Public()
@Post('password/reset')
@HttpCode(HttpStatus.OK)
async resetPassword(
@Body(ValidationPipe) dto: ResetPasswordDto,
@Ip() ip: string,
) {
return await this.passwordResetService.resetPassword(dto, ip);
}
// Email Verification Endpoints
@UseGuards(JwtAuthGuard)
@Post('email/send-verification')
@HttpCode(HttpStatus.OK)
async sendEmailVerification(@CurrentUser() user: any) {
return await this.passwordResetService.sendEmailVerification(user.userId);
}
@Public()
@Post('email/verify')
@HttpCode(HttpStatus.OK)
async verifyEmail(@Body(ValidationPipe) dto: VerifyEmailDto) {
return await this.passwordResetService.verifyEmail(dto);
}
@UseGuards(JwtAuthGuard)
@Post('email/resend-verification')
@HttpCode(HttpStatus.OK)
async resendEmailVerification(@CurrentUser() user: any) {
return await this.passwordResetService.resendEmailVerification(user.userId);
}
}

View File

@@ -5,19 +5,21 @@ import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { PasswordResetService } from './password-reset.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import {
User,
DeviceRegistry,
RefreshToken,
PasswordResetToken,
Family,
FamilyMember,
} from '../../database/entities';
@Module({
imports: [
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, Family, FamilyMember]),
TypeOrmModule.forFeature([User, DeviceRegistry, RefreshToken, PasswordResetToken, Family, FamilyMember]),
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
@@ -31,7 +33,7 @@ import {
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, LocalStrategy],
exports: [AuthService],
providers: [AuthService, PasswordResetService, JwtStrategy, LocalStrategy],
exports: [AuthService, PasswordResetService],
})
export class AuthModule {}

View File

@@ -0,0 +1,26 @@
import { IsEmail, IsNotEmpty, IsString, MinLength, Matches } from 'class-validator';
export class RequestPasswordResetDto {
@IsEmail({}, { message: 'Please provide a valid email address' })
@IsNotEmpty({ message: 'Email is required' })
email: string;
}
export class ResetPasswordDto {
@IsString({ message: 'Token is required' })
@IsNotEmpty({ message: 'Token is required' })
token: string;
@IsString({ message: 'Password must be a string' })
@MinLength(8, { message: 'Password must be at least 8 characters long' })
@Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/, {
message: 'Password must contain at least one uppercase letter, one lowercase letter, and one number',
})
newPassword: string;
}
export class VerifyEmailDto {
@IsString({ message: 'Token is required' })
@IsNotEmpty({ message: 'Token is required' })
token: string;
}

View File

@@ -0,0 +1,265 @@
import {
Injectable,
BadRequestException,
NotFoundException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { User, PasswordResetToken } from '../../database/entities';
import { EmailService } from '../../common/services/email.service';
import { ConfigService } from '@nestjs/config';
import { RequestPasswordResetDto, ResetPasswordDto, VerifyEmailDto } from './dto/password-reset.dto';
@Injectable()
export class PasswordResetService {
private readonly logger = new Logger(PasswordResetService.name);
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(PasswordResetToken)
private passwordResetTokenRepository: Repository<PasswordResetToken>,
private emailService: EmailService,
private configService: ConfigService,
) {}
/**
* Request a password reset - sends email with reset link
*/
async requestPasswordReset(
dto: RequestPasswordResetDto,
ipAddress?: string,
userAgent?: string,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { email: dto.email },
});
// Always return success to prevent email enumeration attacks
if (!user) {
this.logger.warn(`Password reset requested for non-existent email: ${dto.email}`);
return {
success: true,
message: 'If an account with that email exists, a password reset link has been sent.',
};
}
// Generate secure random token
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1); // Token expires in 1 hour
// Create password reset token
const resetToken = this.passwordResetTokenRepository.create({
userId: user.id,
token,
expiresAt,
ipAddress,
userAgent,
});
await this.passwordResetTokenRepository.save(resetToken);
// Generate reset link
const appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
const resetLink = `${appUrl}/reset-password?token=${token}`;
// Send password reset email
try {
await this.emailService.sendPasswordResetEmail(user.email, {
userName: user.name,
resetLink,
expiresIn: '1 hour',
});
this.logger.log(`Password reset email sent to ${user.email}`);
} catch (error) {
this.logger.error(`Failed to send password reset email to ${user.email}:`, error);
// Don't throw error to user - they'll get generic success message
}
return {
success: true,
message: 'If an account with that email exists, a password reset link has been sent.',
};
}
/**
* Reset password using token
*/
async resetPassword(
dto: ResetPasswordDto,
ipAddress?: string,
): Promise<{ success: boolean; message: string }> {
// Find the reset token
const resetToken = await this.passwordResetTokenRepository.findOne({
where: { token: dto.token },
relations: ['user'],
});
if (!resetToken) {
throw new BadRequestException('Invalid or expired password reset token');
}
// Check if token is expired
if (resetToken.isExpired()) {
throw new BadRequestException('Password reset token has expired. Please request a new one.');
}
// Check if token was already used
if (resetToken.isUsed()) {
throw new BadRequestException('This password reset token has already been used');
}
// Hash new password
const saltRounds = 10;
const passwordHash = await bcrypt.hash(dto.newPassword, saltRounds);
// Update user's password
resetToken.user.passwordHash = passwordHash;
await this.userRepository.save(resetToken.user);
// Mark token as used
resetToken.usedAt = new Date();
await this.passwordResetTokenRepository.save(resetToken);
this.logger.log(`Password reset successful for user ${resetToken.user.id}`);
return {
success: true,
message: 'Your password has been reset successfully. You can now log in with your new password.',
};
}
/**
* Send email verification link
*/
async sendEmailVerification(
userId: string,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerified) {
return {
success: true,
message: 'Your email is already verified',
};
}
// Generate verification token
const token = crypto.randomBytes(32).toString('hex');
// Store token in user record
user.emailVerificationToken = token;
user.emailVerificationSentAt = new Date();
await this.userRepository.save(user);
// Generate verification link
const appUrl = this.configService.get<string>('APP_URL', 'http://localhost:3030');
const verificationLink = `${appUrl}/verify-email?token=${token}`;
// Send verification email
try {
await this.emailService.sendEmailVerificationEmail(user.email, {
userName: user.name,
verificationLink,
});
this.logger.log(`Email verification sent to ${user.email}`);
} catch (error) {
this.logger.error(`Failed to send verification email to ${user.email}:`, error);
throw new BadRequestException('Failed to send verification email. Please try again later.');
}
return {
success: true,
message: 'Verification email sent. Please check your inbox.',
};
}
/**
* Verify email using token
*/
async verifyEmail(
dto: VerifyEmailDto,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { emailVerificationToken: dto.token },
});
if (!user) {
throw new BadRequestException('Invalid or expired verification token');
}
if (user.emailVerified) {
return {
success: true,
message: 'Your email is already verified',
};
}
// Check if token is too old (expires after 24 hours)
const tokenAge = new Date().getTime() - user.emailVerificationSentAt.getTime();
const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
if (tokenAge > maxAge) {
throw new BadRequestException('Verification token has expired. Please request a new one.');
}
// Mark email as verified
user.emailVerified = true;
user.emailVerificationToken = null;
user.emailVerificationSentAt = null;
await this.userRepository.save(user);
this.logger.log(`Email verified for user ${user.id}`);
return {
success: true,
message: 'Your email has been verified successfully!',
};
}
/**
* Resend email verification
*/
async resendEmailVerification(
userId: string,
): Promise<{ success: boolean; message: string }> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerified) {
return {
success: true,
message: 'Your email is already verified',
};
}
// Check if we recently sent a verification email (rate limiting)
if (user.emailVerificationSentAt) {
const timeSinceLastSent = new Date().getTime() - user.emailVerificationSentAt.getTime();
const minInterval = 2 * 60 * 1000; // 2 minutes in milliseconds
if (timeSinceLastSent < minInterval) {
throw new BadRequestException('Please wait at least 2 minutes before requesting another verification email');
}
}
return await this.sendEmailVerification(userId);
}
}

View File

@@ -0,0 +1,245 @@
'use client';
import { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
Paper,
Alert,
CircularProgress,
Link as MuiLink,
} from '@mui/material';
import { Email, ArrowBack } from '@mui/icons-material';
import { motion } from 'framer-motion';
import Link from 'next/link';
import apiClient from '@/lib/api/client';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) {
setError('Please enter your email address');
return;
}
setLoading(true);
setError('');
try {
await apiClient.post('/api/v1/auth/password/forgot', { email });
setSuccess(true);
} catch (err: any) {
console.error('Forgot password error:', err);
setError(err.response?.data?.message || 'Failed to send reset email. Please try again.');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
px: 2,
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Paper
elevation={0}
sx={{
maxWidth: 480,
width: '100%',
p: 4,
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
{!success ? (
<>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'primary.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 2,
}}
>
<Email sx={{ fontSize: 32, color: 'primary.main' }} />
</Box>
<Typography variant="h4" fontWeight="600" gutterBottom>
Forgot Password?
</Typography>
<Typography variant="body2" color="text.secondary">
No worries! Enter your email address and we'll send you a link to reset your password.
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
required
autoFocus
sx={{
mb: 3,
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading}
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Send Reset Link'}
</Button>
</form>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<MuiLink
component={Link}
href="/login"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'primary.main',
textDecoration: 'none',
fontWeight: 500,
'&:hover': {
textDecoration: 'underline',
},
}}
>
<ArrowBack sx={{ fontSize: 18 }} />
Back to Login
</MuiLink>
</Box>
</>
) : (
<Box sx={{ textAlign: 'center' }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'success.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3,
}}
>
<Email sx={{ fontSize: 32, color: 'success.main' }} />
</Box>
<Typography variant="h5" fontWeight="600" gutterBottom>
Check Your Email 📧
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
If an account with that email exists, we've sent you a password reset link. Please check your inbox and follow the instructions.
</Typography>
<Alert severity="info" sx={{ mb: 3, borderRadius: 2, textAlign: 'left' }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
Didn't receive the email?
</Typography>
<Typography variant="body2">
• Check your spam or junk folder
<br />
• Make sure you entered the correct email
<br />
• The link expires in 1 hour
</Typography>
</Alert>
<Button
fullWidth
variant="outlined"
onClick={() => {
setSuccess(false);
setEmail('');
}}
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
mb: 2,
}}
>
Try Another Email
</Button>
<MuiLink
component={Link}
href="/login"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
color: 'primary.main',
textDecoration: 'none',
fontWeight: 500,
'&:hover': {
textDecoration: 'underline',
},
}}
>
<ArrowBack sx={{ fontSize: 18 }} />
Back to Login
</MuiLink>
</Box>
)}
</Paper>
</motion.div>
</Box>
);
}

View File

@@ -0,0 +1,335 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import {
Box,
TextField,
Button,
Typography,
Paper,
Alert,
CircularProgress,
InputAdornment,
IconButton,
Link as MuiLink,
} from '@mui/material';
import { LockReset, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material';
import { motion } from 'framer-motion';
import Link from 'next/link';
import apiClient from '@/lib/api/client';
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [token, setToken] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
const tokenParam = searchParams.get('token');
if (tokenParam) {
setToken(tokenParam);
} else {
setError('Invalid or missing reset token');
}
}, [searchParams]);
const validatePassword = (password: string): string | null => {
if (password.length < 8) {
return 'Password must be at least 8 characters long';
}
if (!/[a-z]/.test(password)) {
return 'Password must contain at least one lowercase letter';
}
if (!/[A-Z]/.test(password)) {
return 'Password must contain at least one uppercase letter';
}
if (!/\d/.test(password)) {
return 'Password must contain at least one number';
}
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!newPassword.trim()) {
setError('Please enter a new password');
return;
}
const passwordError = validatePassword(newPassword);
if (passwordError) {
setError(passwordError);
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
setError('');
try {
await apiClient.post('/api/v1/auth/password/reset', {
token,
newPassword,
});
setSuccess(true);
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login');
}, 3000);
} catch (err: any) {
console.error('Reset password error:', err);
setError(
err.response?.data?.message || 'Failed to reset password. The link may have expired.'
);
} finally {
setLoading(false);
}
};
if (!token && !error) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress />
</Box>
);
}
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #FFE4E1 0%, #FFDAB9 100%)',
px: 2,
}}
>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Paper
elevation={0}
sx={{
maxWidth: 480,
width: '100%',
p: 4,
borderRadius: 4,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
}}
>
{!success ? (
<>
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'primary.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 2,
}}
>
<LockReset sx={{ fontSize: 32, color: 'primary.main' }} />
</Box>
<Typography variant="h4" fontWeight="600" gutterBottom>
Reset Password
</Typography>
<Typography variant="body2" color="text.secondary">
Enter your new password below
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="New Password"
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
required
autoFocus
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<TextField
fullWidth
label="Confirm New Password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 3,
},
}}
/>
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
<Typography variant="body2" fontWeight="600" gutterBottom>
Password Requirements:
</Typography>
<Typography variant="body2" component="div">
At least 8 characters long
<br />
One uppercase letter (A-Z)
<br />
One lowercase letter (a-z)
<br /> One number (0-9)
</Typography>
</Alert>
<Button
fullWidth
type="submit"
variant="contained"
size="large"
disabled={loading || !token}
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
}}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Reset Password'}
</Button>
</form>
<Box sx={{ mt: 3, textAlign: 'center' }}>
<MuiLink
component={Link}
href="/login"
sx={{
color: 'primary.main',
textDecoration: 'none',
fontWeight: 500,
'&:hover': {
textDecoration: 'underline',
},
}}
>
Back to Login
</MuiLink>
</Box>
</>
) : (
<Box sx={{ textAlign: 'center' }}>
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
bgcolor: 'success.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mx: 'auto',
mb: 3,
}}
>
<CheckCircle sx={{ fontSize: 48, color: 'success.main' }} />
</Box>
<Typography variant="h5" fontWeight="600" gutterBottom>
Password Reset Successful! 🎉
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Your password has been reset successfully. You can now log in with your new password.
</Typography>
<Alert severity="success" sx={{ mb: 3, borderRadius: 2 }}>
Redirecting to login page...
</Alert>
<Button
fullWidth
variant="contained"
component={Link}
href="/login"
sx={{
borderRadius: 3,
py: 1.5,
textTransform: 'none',
fontSize: 16,
fontWeight: 600,
}}
>
Go to Login
</Button>
</Box>
)}
</Paper>
</motion.div>
</Box>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
import { Box, Typography, Button, Paper, Grid, CircularProgress } from '@mui/material';
import { AppShell } from '@/components/layouts/AppShell/AppShell';
import { ProtectedRoute } from '@/components/common/ProtectedRoute';
import { EmailVerificationBanner } from '@/components/common/EmailVerificationBanner';
import {
Restaurant,
Hotel,
@@ -84,6 +85,8 @@ export default function HomePage() {
<ProtectedRoute>
<AppShell>
<Box>
<EmailVerificationBanner />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import { Alert, Button, Snackbar, Box } from '@mui/material';
import { Email } from '@mui/icons-material';
import { useAuth } from '@/lib/auth/AuthContext';
import apiClient from '@/lib/api/client';
export const EmailVerificationBanner: React.FC = () => {
const { user } = useAuth();
const [dismissed, setDismissed] = useState(false);
const [loading, setLoading] = useState(false);
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success',
});
// Don't show if user is not logged in, email is verified, or banner was dismissed
if (!user || user.emailVerified || dismissed) {
return null;
}
const handleResendVerification = async () => {
setLoading(true);
try {
await apiClient.post('/api/v1/auth/email/send-verification');
setSnackbar({
open: true,
message: 'Verification email sent! Please check your inbox.',
severity: 'success',
});
} catch (error: any) {
console.error('Failed to resend verification email:', error);
setSnackbar({
open: true,
message: error.response?.data?.message || 'Failed to send verification email. Please try again.',
severity: 'error',
});
} finally {
setLoading(false);
}
};
const handleDismiss = () => {
setDismissed(true);
// Store dismissal in localStorage to persist across sessions
localStorage.setItem('emailVerificationBannerDismissed', 'true');
};
return (
<>
<Alert
severity="warning"
icon={<Email />}
onClose={handleDismiss}
sx={{
borderRadius: 2,
mb: 2,
'& .MuiAlert-message': {
width: '100%',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
flexWrap: 'wrap',
}}
>
<Box>
<strong>Verify your email address</strong>
<br />
Please check your inbox and click the verification link to access all features.
</Box>
<Button
variant="outlined"
size="small"
onClick={handleResendVerification}
disabled={loading}
sx={{
borderRadius: 2,
textTransform: 'none',
fontWeight: 600,
borderColor: 'warning.main',
color: 'warning.dark',
'&:hover': {
borderColor: 'warning.dark',
bgcolor: 'warning.light',
},
}}
>
{loading ? 'Sending...' : 'Resend Email'}
</Button>
</Box>
</Alert>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={snackbar.severity}
onClose={() => setSnackbar({ ...snackbar, open: false })}
sx={{ borderRadius: 2 }}
>
{snackbar.message}
</Alert>
</Snackbar>
</>
);
};