diff --git a/maternal-app/maternal-app-backend/package-lock.json b/maternal-app/maternal-app-backend/package-lock.json index c352fdb..5c3f350 100644 --- a/maternal-app/maternal-app-backend/package-lock.json +++ b/maternal-app/maternal-app-backend/package-lock.json @@ -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", diff --git a/maternal-app/maternal-app-backend/package.json b/maternal-app/maternal-app-backend/package.json index 73d9383..b1deb02 100644 --- a/maternal-app/maternal-app-backend/package.json +++ b/maternal-app/maternal-app-backend/package.json @@ -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", diff --git a/maternal-app/maternal-app-backend/src/common/common.module.ts b/maternal-app/maternal-app-backend/src/common/common.module.ts index 207ef7a..18629ac 100644 --- a/maternal-app/maternal-app-backend/src/common/common.module.ts +++ b/maternal-app/maternal-app-backend/src/common/common.module.ts @@ -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 {} diff --git a/maternal-app/maternal-app-backend/src/common/services/email.service.ts b/maternal-app/maternal-app-backend/src/common/services/email.service.ts new file mode 100644 index 0000000..05ffe0f --- /dev/null +++ b/maternal-app/maternal-app-backend/src/common/services/email.service.ts @@ -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('MAILGUN_API_KEY'); + const mailgunRegion = this.configService.get('MAILGUN_REGION', 'us'); // 'us' or 'eu' + this.mailgunDomain = this.configService.get('MAILGUN_DOMAIN', ''); + this.fromEmail = this.configService.get('EMAIL_FROM', 'noreply@maternal-app.com'); + this.fromName = this.configService.get('EMAIL_FROM_NAME', 'Maternal App'); + this.appUrl = this.configService.get('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 { + 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 { + 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 { + 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 { + 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 ` + + + + + + Reset Your Password + + + +
+
+

Reset Your Password

+
+
+

Hi ${data.userName},

+

We received a request to reset your password for your Maternal App account. Click the button below to create a new password:

+ +
+ ⏱️ This link expires in ${data.expiresIn}
+ If you didn't request a password reset, please ignore this email. Your password will remain unchanged. +
+

If the button doesn't work, copy and paste this link into your browser:

+

${data.resetLink}

+

Best regards,
The Maternal App Team

+
+ +
+ + + `.trim(); + } + + /** + * Email verification template + */ + private getEmailVerificationTemplate(data: EmailVerificationData): string { + return ` + + + + + + Verify Your Email + + + +
+
+

Verify Your Email 📧

+
+
+

Hi ${data.userName},

+

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:

+ +
+ ✅ Why verify?
+ Email verification helps us ensure your account security and enables us to send you important updates about your child's activities and milestones. +
+

If the button doesn't work, copy and paste this link into your browser:

+

${data.verificationLink}

+

Welcome to the Maternal App family! 🎉
The Maternal App Team

+
+ +
+ + + `.trim(); + } + + /** + * Welcome email template + */ + private getWelcomeEmailTemplate(userName: string): string { + return ` + + + + + + Welcome to Maternal App + + + +
+
+

Welcome to Maternal App! 🎉

+
+
+

Hi ${userName},

+

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.

+ +
+

📊 Track Activities

+

Easily log feeding, sleep, diaper changes, and more with our intuitive interface.

+
+ +
+

🤖 AI Parenting Assistant

+

Get personalized insights and answers to your parenting questions 24/7.

+
+ +
+

👨‍👩‍👧 Family Sync

+

Keep everyone in the loop with real-time activity updates across all devices.

+
+ + + +

If you have any questions or need help getting started, our support team is here for you!

+

Happy parenting! 💕
The Maternal App Team

+
+ +
+ + + `.trim(); + } + + /** + * Strip HTML tags for plain text version + */ + private stripHtml(html: string): string { + return html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); + } +} diff --git a/maternal-app/maternal-app-backend/src/database/entities/index.ts b/maternal-app/maternal-app-backend/src/database/entities/index.ts index 4cc6999..b5f9e03 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/index.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/index.ts @@ -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'; diff --git a/maternal-app/maternal-app-backend/src/database/entities/password-reset-token.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/password-reset-token.entity.ts new file mode 100644 index 0000000..7cb40c2 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/entities/password-reset-token.entity.ts @@ -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; + } +} diff --git a/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts b/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts index 3b3344d..d4b6f77 100644 --- a/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts +++ b/maternal-app/maternal-app-backend/src/database/entities/user.entity.ts @@ -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; diff --git a/maternal-app/maternal-app-backend/src/database/migrations/V011_create_password_reset_tokens.sql b/maternal-app/maternal-app-backend/src/database/migrations/V011_create_password_reset_tokens.sql new file mode 100644 index 0000000..47fbf93 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/database/migrations/V011_create_password_reset_tokens.sql @@ -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); diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts index dd38920..d70b142 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.controller.ts @@ -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); + } } \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts index a32da10..6ed1428 100644 --- a/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts +++ b/maternal-app/maternal-app-backend/src/modules/auth/auth.module.ts @@ -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 {} \ No newline at end of file diff --git a/maternal-app/maternal-app-backend/src/modules/auth/dto/password-reset.dto.ts b/maternal-app/maternal-app-backend/src/modules/auth/dto/password-reset.dto.ts new file mode 100644 index 0000000..614ab59 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/dto/password-reset.dto.ts @@ -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; +} diff --git a/maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.ts b/maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.ts new file mode 100644 index 0000000..b07a845 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/auth/password-reset.service.ts @@ -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, + @InjectRepository(PasswordResetToken) + private passwordResetTokenRepository: Repository, + 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('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('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); + } +} diff --git a/maternal-web/app/(auth)/forgot-password/page.tsx b/maternal-web/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..d6587eb --- /dev/null +++ b/maternal-web/app/(auth)/forgot-password/page.tsx @@ -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 ( + + + + {!success ? ( + <> + + + + + + Forgot Password? + + + No worries! Enter your email address and we'll send you a link to reset your password. + + + + {error && ( + + {error} + + )} + +
+ setEmail(e.target.value)} + disabled={loading} + required + autoFocus + sx={{ + mb: 3, + '& .MuiOutlinedInput-root': { + borderRadius: 3, + }, + }} + /> + + + + + + + + Back to Login + + + + ) : ( + + + + + + + Check Your Email 📧 + + + + If an account with that email exists, we've sent you a password reset link. Please check your inbox and follow the instructions. + + + + + Didn't receive the email? + + + • Check your spam or junk folder +
+ • Make sure you entered the correct email +
+ • The link expires in 1 hour +
+
+ + + + + + Back to Login + +
+ )} +
+
+
+ ); +} diff --git a/maternal-web/app/(auth)/reset-password/page.tsx b/maternal-web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..146c880 --- /dev/null +++ b/maternal-web/app/(auth)/reset-password/page.tsx @@ -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 ( + + + + ); + } + + return ( + + + + {!success ? ( + <> + + + + + + Reset Password + + + Enter your new password below + + + + {error && ( + + {error} + + )} + +
+ setNewPassword(e.target.value)} + disabled={loading} + required + autoFocus + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ), + }} + sx={{ + mb: 2, + '& .MuiOutlinedInput-root': { + borderRadius: 3, + }, + }} + /> + + setConfirmPassword(e.target.value)} + disabled={loading} + required + InputProps={{ + endAdornment: ( + + setShowConfirmPassword(!showConfirmPassword)} + edge="end" + > + {showConfirmPassword ? : } + + + ), + }} + sx={{ + mb: 2, + '& .MuiOutlinedInput-root': { + borderRadius: 3, + }, + }} + /> + + + + Password Requirements: + + + • At least 8 characters long +
+ • One uppercase letter (A-Z) +
+ • One lowercase letter (a-z) +
• One number (0-9) +
+
+ + + + + + + Back to Login + + + + ) : ( + + + + + + + Password Reset Successful! 🎉 + + + + Your password has been reset successfully. You can now log in with your new password. + + + + Redirecting to login page... + + + + + )} +
+
+
+ ); +} diff --git a/maternal-web/app/page.tsx b/maternal-web/app/page.tsx index dcae208..76ca99c 100644 --- a/maternal-web/app/page.tsx +++ b/maternal-web/app/page.tsx @@ -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() { + + { + 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 ( + <> + } + onClose={handleDismiss} + sx={{ + borderRadius: 2, + mb: 2, + '& .MuiAlert-message': { + width: '100%', + }, + }} + > + + + Verify your email address +
+ Please check your inbox and click the verification link to access all features. +
+ +
+
+ + setSnackbar({ ...snackbar, open: false })} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar({ ...snackbar, open: false })} + sx={{ borderRadius: 2 }} + > + {snackbar.message} + + + + ); +};