diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 37cc1bd..5879637 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -28,6 +28,7 @@ import { RefreshTokenDto, VerifyEmailDto, ChangePasswordDto, + ChangeEmailDto, } from './dto'; import { VerifyTwoFactorDto, @@ -232,6 +233,28 @@ export class AuthController { return this.authService.changePassword(userId, dto); } + // ========================================== + // REQUEST EMAIL CHANGE + // ========================================== + @Post('change-email') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: + 'Request an email change — sends verification link to the new email. Email updates only after link is clicked.', + }) + @ApiBody({ type: ChangeEmailDto }) + @ApiResponse({ status: 200, description: 'Verification email sent' }) + @ApiResponse({ status: 400, description: 'Incorrect password or invalid email' }) + @ApiResponse({ status: 409, description: 'Email already in use' }) + async requestEmailChange( + @CurrentUser('id') userId: string, + @Body() dto: ChangeEmailDto, + ) { + return this.authService.requestEmailChange(userId, dto); + } + // ========================================== // VERIFY EMAIL // ========================================== diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d0384b4..55cff62 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -23,6 +23,7 @@ import { RefreshTokenDto, VerifyEmailDto, ChangePasswordDto, + ChangeEmailDto, } from './dto'; import { UserRole, AuthProvider, UserStatus } from '@prisma/client'; import { TwoFactorService } from './two-factor/two-factor.service'; @@ -539,6 +540,44 @@ export class AuthService { secret: this.configService.get('jwt.secret'), }); + // Email-change token — swap user's email to the pending one + if (payload.type === 'email-change') { + const user = await this.prisma.user.findUnique({ + where: { id: payload.sub }, + }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const newEmail = (payload.newEmail as string)?.toLowerCase(); + if (!newEmail) { + throw new BadRequestException('Invalid verification token'); + } + + // Ensure the email hasn't been claimed by another account in the meantime + const existing = await this.prisma.user.findUnique({ + where: { email: newEmail }, + }); + if (existing && existing.id !== user.id) { + throw new ConflictException('Email is already in use'); + } + + if (user.email === newEmail && user.emailVerified) { + return { message: 'Email is already verified' }; + } + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + email: newEmail, + emailVerified: true, + emailVerifiedAt: new Date(), + }, + }); + + return { message: 'Email changed and verified successfully' }; + } + if (payload.type !== 'email-verification') { throw new BadRequestException('Invalid verification token'); } @@ -594,6 +633,72 @@ export class AuthService { } } + // ========================================== + // REQUEST EMAIL CHANGE + // ========================================== + async requestEmailChange(userId: string, dto: ChangeEmailDto) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + include: { userProfile: true, agentProfile: true }, + }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + if (user.authProvider !== AuthProvider.LOCAL) { + throw new BadRequestException( + 'Email cannot be changed for social login accounts', + ); + } + + if (!user.password) { + throw new BadRequestException('Password is not set on this account'); + } + + const passwordOk = await argon2.verify(user.password, dto.password); + if (!passwordOk) { + throw new BadRequestException('Incorrect password'); + } + + const newEmail = dto.newEmail.toLowerCase().trim(); + if (newEmail === user.email) { + throw new BadRequestException( + 'New email must be different from current email', + ); + } + + const existing = await this.prisma.user.findUnique({ + where: { email: newEmail }, + }); + if (existing) { + throw new ConflictException('Email is already in use'); + } + + const token = this.jwtService.sign( + { sub: user.id, newEmail, type: 'email-change' }, + { + secret: this.configService.get('jwt.secret'), + expiresIn: '24h', + }, + ); + + const profile = + user.role === UserRole.AGENT ? user.agentProfile : user.userProfile; + + // Emit event to send the verification email to the NEW address + this.eventEmitter.emit('user.email-change-requested', { + newEmail, + token, + name: profile?.firstName, + }); + + return { + message: + 'A verification link has been sent to your new email. Please click the link to confirm the change.', + }; + } + // ========================================== // RESEND VERIFICATION EMAIL // ========================================== diff --git a/src/auth/dto/change-email.dto.ts b/src/auth/dto/change-email.dto.ts new file mode 100644 index 0000000..7e6fad4 --- /dev/null +++ b/src/auth/dto/change-email.dto.ts @@ -0,0 +1,20 @@ +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ChangeEmailDto { + @ApiProperty({ + description: 'New email address', + example: 'new-email@example.com', + }) + @IsNotEmpty({ message: 'New email is required' }) + @IsEmail({}, { message: 'Invalid email format' }) + newEmail: string; + + @ApiProperty({ + description: 'Current password (for verification)', + example: 'CurrentPass123!', + }) + @IsNotEmpty({ message: 'Password is required' }) + @IsString() + password: string; +} diff --git a/src/auth/dto/index.ts b/src/auth/dto/index.ts index c0682b2..c46b0e1 100644 --- a/src/auth/dto/index.ts +++ b/src/auth/dto/index.ts @@ -6,3 +6,4 @@ export * from './reset-password.dto'; export * from './refresh-token.dto'; export * from './verify-email.dto'; export * from './change-password.dto'; +export * from './change-email.dto'; diff --git a/src/email/email.listener.ts b/src/email/email.listener.ts index 6b87b9f..2eb9545 100644 --- a/src/email/email.listener.ts +++ b/src/email/email.listener.ts @@ -99,4 +99,26 @@ export class EmailListener { this.logger.error(`Failed to send welcome email to ${payload.email}:`, error); } } + + @OnEvent('user.email-change-requested') + async handleEmailChangeRequested(payload: { + newEmail: string; + token: string; + name?: string; + }) { + this.logger.log(`Email change requested for: ${payload.newEmail}`); + try { + await this.emailService.sendVerificationEmail( + payload.newEmail, + payload.token, + payload.name, + ); + this.logger.log(`Email-change verification sent to: ${payload.newEmail}`); + } catch (error) { + this.logger.error( + `Failed to send email-change verification to ${payload.newEmail}:`, + error, + ); + } + } }