diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index b05309812..8e33eba7e 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -57,6 +57,14 @@ export class AuthController { return this.service.confirmEmail(confirmEmailDto.hash); } + @Post('email/confirm/new') + @HttpCode(HttpStatus.NO_CONTENT) + async confirmNewEmail( + @Body() confirmEmailDto: AuthConfirmEmailDto, + ): Promise { + return this.service.confirmNewEmail(confirmEmailDto.hash); + } + @Post('forgot/password') @HttpCode(HttpStatus.NO_CONTENT) async forgotPassword( diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 8f9fd4e28..3ec787177 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -276,6 +276,50 @@ export class AuthService { await this.usersService.update(user.id, user); } + async confirmNewEmail(hash: string): Promise { + let userId: User['id']; + let newEmail: User['email']; + + try { + const jwtData = await this.jwtService.verifyAsync<{ + confirmEmailUserId: User['id']; + newEmail: User['email']; + }>(hash, { + secret: this.configService.getOrThrow('auth.confirmEmailSecret', { + infer: true, + }), + }); + + userId = jwtData.confirmEmailUserId; + newEmail = jwtData.newEmail; + } catch { + throw new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + hash: `invalidHash`, + }, + }); + } + + const user = await this.usersService.findOne({ + id: userId, + }); + + if (!user) { + throw new NotFoundException({ + status: HttpStatus.NOT_FOUND, + error: `notFound`, + }); + } + + user.email = newEmail; + user.status = { + id: StatusEnum.active, + }; + + await this.usersService.update(user.id, user); + } + async forgotPassword(email: string): Promise { const user = await this.usersService.findOne({ email, @@ -373,25 +417,25 @@ export class AuthService { userJwtPayload: JwtPayloadType, userDto: AuthUpdateDto, ): Promise> { - if (userDto.password) { - if (!userDto.oldPassword) { - throw new UnprocessableEntityException({ - status: HttpStatus.UNPROCESSABLE_ENTITY, - errors: { - oldPassword: 'missingOldPassword', - }, - }); - } + const currentUser = await this.usersService.findOne({ + id: userJwtPayload.id, + }); - const currentUser = await this.usersService.findOne({ - id: userJwtPayload.id, + if (!currentUser) { + throw new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + user: 'userNotFound', + }, }); + } - if (!currentUser) { + if (userDto.password) { + if (!userDto.oldPassword) { throw new UnprocessableEntityException({ status: HttpStatus.UNPROCESSABLE_ENTITY, errors: { - user: 'userNotFound', + oldPassword: 'missingOldPassword', }, }); } @@ -427,6 +471,46 @@ export class AuthService { } } + if (userDto.email && userDto.email !== currentUser.email) { + const userByEmail = await this.usersService.findOne({ + email: userDto.email, + }); + + if (userByEmail && userByEmail.id !== currentUser.id) { + throw new UnprocessableEntityException({ + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailExists', + }, + }); + } + + const hash = await this.jwtService.signAsync( + { + confirmEmailUserId: currentUser.id, + newEmail: userDto.email, + }, + { + secret: this.configService.getOrThrow('auth.confirmEmailSecret', { + infer: true, + }), + expiresIn: this.configService.getOrThrow('auth.confirmEmailExpires', { + infer: true, + }), + }, + ); + + await this.mailService.confirmNewEmail({ + to: userDto.email, + data: { + hash, + }, + }); + } + + delete userDto.email; + delete userDto.oldPassword; + await this.usersService.update(userJwtPayload.id, userDto); return this.usersService.findOne({ diff --git a/src/auth/dto/auth-update.dto.ts b/src/auth/dto/auth-update.dto.ts index 315addb1f..08a1c2a28 100644 --- a/src/auth/dto/auth-update.dto.ts +++ b/src/auth/dto/auth-update.dto.ts @@ -1,6 +1,8 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, MinLength } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator'; import { FileDto } from '../../files/dto/file.dto'; +import { Transform } from 'class-transformer'; +import { lowerCaseTransformer } from '../../utils/transformers/lower-case.transformer'; export class AuthUpdateDto { @ApiPropertyOptional({ type: () => FileDto }) @@ -17,6 +19,13 @@ export class AuthUpdateDto { @IsNotEmpty({ message: 'mustBeNotEmpty' }) lastName?: string; + @ApiPropertyOptional({ example: 'new.email@example.com' }) + @IsOptional() + @IsNotEmpty() + @IsEmail() + @Transform(lowerCaseTransformer) + email?: string; + @ApiPropertyOptional() @IsOptional() @IsNotEmpty() diff --git a/src/i18n/en/confirm-new-email.json b/src/i18n/en/confirm-new-email.json new file mode 100644 index 000000000..155b40978 --- /dev/null +++ b/src/i18n/en/confirm-new-email.json @@ -0,0 +1,5 @@ +{ + "text1": "Hey!", + "text2": "Confirm your new email address.", + "text3": "Simply click the big green button below to verify your email address." +} diff --git a/src/mail/mail-templates/confirm-new-email.hbs b/src/mail/mail-templates/confirm-new-email.hbs new file mode 100644 index 000000000..b0c26dbed --- /dev/null +++ b/src/mail/mail-templates/confirm-new-email.hbs @@ -0,0 +1,33 @@ + + + + + + + {{title}} + + + + + + + + + + + + + +
+ {{app_name}} +
+ {{text1}}
+ {{text2}}
+ {{text3}} +
+ {{actionTitle}} +
+ + + \ No newline at end of file diff --git a/src/mail/mail.service.ts b/src/mail/mail.service.ts index a204fec3c..3bdfd44a2 100644 --- a/src/mail/mail.service.ts +++ b/src/mail/mail.service.ts @@ -118,4 +118,52 @@ export class MailService { }, }); } + + async confirmNewEmail(mailData: MailData<{ hash: string }>): Promise { + const i18n = I18nContext.current(); + let emailConfirmTitle: MaybeType; + let text1: MaybeType; + let text2: MaybeType; + let text3: MaybeType; + + if (i18n) { + [emailConfirmTitle, text1, text2, text3] = await Promise.all([ + i18n.t('common.confirmEmail'), + i18n.t('confirm-new-email.text1'), + i18n.t('confirm-new-email.text2'), + i18n.t('confirm-new-email.text3'), + ]); + } + + const url = new URL( + this.configService.getOrThrow('app.frontendDomain', { + infer: true, + }) + '/confirm-new-email', + ); + url.searchParams.set('hash', mailData.data.hash); + + await this.mailerService.sendMail({ + to: mailData.to, + subject: emailConfirmTitle, + text: `${url.toString()} ${emailConfirmTitle}`, + templatePath: path.join( + this.configService.getOrThrow('app.workingDirectory', { + infer: true, + }), + 'src', + 'mail', + 'mail-templates', + 'confirm-new-email.hbs', + ), + context: { + title: emailConfirmTitle, + url: url.toString(), + actionTitle: emailConfirmTitle, + app_name: this.configService.get('app.name', { infer: true }), + text1, + text2, + text3, + }, + }); + } } diff --git a/test/user/auth.e2e-spec.ts b/test/user/auth.e2e-spec.ts index bc548eefe..1fdace50d 100644 --- a/test/user/auth.e2e-spec.ts +++ b/test/user/auth.e2e-spec.ts @@ -245,6 +245,90 @@ describe('Auth Module', () => { .expect(200); }); + it('should update profile email successfully: /api/v1/auth/me (PATCH)', async () => { + const newUserFirstName = `Tester${Date.now()}`; + const newUserLastName = `E2E`; + const newUserEmail = `user.${Date.now()}@example.com`; + const newUserPassword = `secret`; + const newUserNewEmail = `new.${newUserEmail}`; + + await request(app) + .post('/api/v1/auth/email/register') + .send({ + email: newUserEmail, + password: newUserPassword, + firstName: newUserFirstName, + lastName: newUserLastName, + }) + .expect(204); + + const newUserApiToken = await request(app) + .post('/api/v1/auth/email/login') + .send({ email: newUserEmail, password: newUserPassword }) + .then(({ body }) => body.token); + + await request(app) + .patch('/api/v1/auth/me') + .auth(newUserApiToken, { + type: 'bearer', + }) + .send({ + email: newUserNewEmail, + }) + .expect(200); + + const hash = await request(mail) + .get('/email') + .then(({ body }) => + body + .find((letter) => { + return ( + letter.to[0].address.toLowerCase() === + newUserNewEmail.toLowerCase() && + /.*confirm\-new\-email\?hash\=(\S+).*/g.test(letter.text) + ); + }) + ?.text.replace(/.*confirm\-new\-email\?hash\=(\S+).*/g, '$1'), + ); + + await request(app) + .get('/api/v1/auth/me') + .auth(newUserApiToken, { + type: 'bearer', + }) + .expect(200) + .expect(({ body }) => { + expect(body.email).not.toBe(newUserNewEmail); + }); + + await request(app) + .post('/api/v1/auth/email/login') + .send({ email: newUserNewEmail, password: newUserPassword }) + .expect(422); + + await request(app) + .post('/api/v1/auth/email/confirm/new') + .send({ + hash, + }) + .expect(204); + + await request(app) + .get('/api/v1/auth/me') + .auth(newUserApiToken, { + type: 'bearer', + }) + .expect(200) + .expect(({ body }) => { + expect(body.email).toBe(newUserNewEmail); + }); + + await request(app) + .post('/api/v1/auth/email/login') + .send({ email: newUserNewEmail, password: newUserPassword }) + .expect(200); + }); + it('should delete profile successfully: /api/v1/auth/me (DELETE)', async () => { const newUserApiToken = await request(app) .post('/api/v1/auth/email/login')