Skip to content

Commit

Permalink
feat(auth): update email
Browse files Browse the repository at this point in the history
  • Loading branch information
Shchepotin committed Apr 14, 2024
1 parent 48e484a commit 9e180c9
Show file tree
Hide file tree
Showing 7 changed files with 285 additions and 14 deletions.
8 changes: 8 additions & 0 deletions src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return this.service.confirmNewEmail(confirmEmailDto.hash);
}

@Post('forgot/password')
@HttpCode(HttpStatus.NO_CONTENT)
async forgotPassword(
Expand Down
110 changes: 97 additions & 13 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,50 @@ export class AuthService {
await this.usersService.update(user.id, user);
}

async confirmNewEmail(hash: string): Promise<void> {
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<void> {
const user = await this.usersService.findOne({
email,
Expand Down Expand Up @@ -373,25 +417,25 @@ export class AuthService {
userJwtPayload: JwtPayloadType,
userDto: AuthUpdateDto,
): Promise<NullableType<User>> {
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',
},
});
}
Expand Down Expand Up @@ -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({
Expand Down
11 changes: 10 additions & 1 deletion src/auth/dto/auth-update.dto.ts
Original file line number Diff line number Diff line change
@@ -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 })
Expand All @@ -17,6 +19,13 @@ export class AuthUpdateDto {
@IsNotEmpty({ message: 'mustBeNotEmpty' })
lastName?: string;

@ApiPropertyOptional({ example: '[email protected]' })
@IsOptional()
@IsNotEmpty()
@IsEmail()
@Transform(lowerCaseTransformer)
email?: string;

@ApiPropertyOptional()
@IsOptional()
@IsNotEmpty()
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/en/confirm-new-email.json
Original file line number Diff line number Diff line change
@@ -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."
}
33 changes: 33 additions & 0 deletions src/mail/mail-templates/confirm-new-email.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=">
<title>{{title}}</title>
</head>

<body style="margin:0;font-family:arial">
<table style="border:0;width:100%">
<tr style="background:#eeeeee">
<td style="padding:20px;color:#808080;text-align:center;font-size:40px;font-weight:600">
{{app_name}}
</td>
</tr>
<tr>
<td style="padding:20px;color:#808080;font-size:16px;font-weight:100">
{{text1}}<br>
{{text2}}<br>
{{text3}}
</td>
</tr>
<tr>
<td style="text-align:center">
<a href="{{url}}"
style="display:inline-block;padding:20px;background:#00838f;text-decoration:none;color:#ffffff">{{actionTitle}}</a>
</td>
</tr>
</table>
</body>

</html>
48 changes: 48 additions & 0 deletions src/mail/mail.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,52 @@ export class MailService {
},
});
}

async confirmNewEmail(mailData: MailData<{ hash: string }>): Promise<void> {
const i18n = I18nContext.current();
let emailConfirmTitle: MaybeType<string>;
let text1: MaybeType<string>;
let text2: MaybeType<string>;
let text3: MaybeType<string>;

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,
},
});
}
}
84 changes: 84 additions & 0 deletions test/user/auth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit 9e180c9

Please sign in to comment.