Skip to content

Commit

Permalink
refactor, add update my password flow
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh committed Oct 11, 2024
1 parent 868df8d commit a0b614f
Show file tree
Hide file tree
Showing 11 changed files with 153 additions and 41 deletions.
2 changes: 2 additions & 0 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NotificationsModule } from '@api/modules/notifications/notifications.mo
import { AdminModule } from '@api/modules/admin/admin.module';
import { ImportModule } from '@api/modules/import/import.module';
import { ApiEventsModule } from '@api/modules/api-events/api-events.module';
import { UsersModule } from '@api/modules/users/users.module';

@Module({
imports: [
Expand All @@ -16,6 +17,7 @@ import { ApiEventsModule } from '@api/modules/api-events/api-events.module';
ApiEventsModule,
AdminModule,
ImportModule,
UsersModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
2 changes: 1 addition & 1 deletion api/src/modules/auth/authentication.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class AuthenticationController {
return tsRestHandler(
authContract.resetPassword,
async ({ body: { password } }) => {
await this.authService.updatePassword(user, password);
await this.authService.resetPassword(user, password);
return {
body: null,
status: 201,
Expand Down
23 changes: 19 additions & 4 deletions api/src/modules/auth/authentication.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { CreateUserDto } from '@shared/dtos/users/create-user.dto';
import { randomBytes } from 'node:crypto';
import { SendWelcomeEmailCommand } from '@api/modules/notifications/email/commands/send-welcome-email.command';
import { JwtManager } from '@api/modules/auth/services/jwt.manager';
import { SignUpDto } from '@shared/schemas/auth/sign-up.schema';
import {
SignUpDto,
UpdateUserPasswordDto,
} from '@shared/schemas/auth/sign-up.schema';
import { UserSignedUpEvent } from '@api/modules/admin/events/user-signed-up.event';

@Injectable()
Expand Down Expand Up @@ -56,7 +59,7 @@ export class AuthenticationService {
throw new UnauthorizedException();
}
user.isActive = true;
await this.usersService.updatePassword(user, newPassword);
await this.usersService.saveNewHashedPassword(user, newPassword);
this.eventBus.publish(new UserSignedUpEvent(user.id, user.email));
}

Expand All @@ -67,7 +70,19 @@ export class AuthenticationService {
throw new UnauthorizedException();
}

async updatePassword(user: User, newPassword: string): Promise<void> {
await this.usersService.updatePassword(user, newPassword);
async updatePassword(user: User, dto: UpdateUserPasswordDto): Promise<User> {
const { password, newPassword } = dto;
if (await this.isPasswordValid(user, password)) {
return this.usersService.saveNewHashedPassword(user, newPassword);
}
throw new UnauthorizedException();
}

async resetPassword(user: User, newPassword: string): Promise<void> {
await this.usersService.saveNewHashedPassword(user, newPassword);
}

async isPasswordValid(user: User, password: string): Promise<boolean> {
return bcrypt.compare(password, user.password);
}
}
42 changes: 18 additions & 24 deletions api/src/modules/users/users.controller.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
import {
Controller,
ClassSerializerInterceptor,
Body,
Param,
ParseUUIDPipe,
UseInterceptors,
HttpStatus,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';

import { UsersService } from './users.service';
import { tsRestHandler, TsRestHandler } from '@ts-rest/nest';
import { GetUser } from '@api/decorators/get-user.decorator';
import { User } from '@shared/entities/users/user.entity';
import { usersContract as c } from '@shared/contracts/users.contract';

import { UpdateUserPasswordDto } from '@shared/dtos/users/update-user-password.dto';
import { UpdateUserDto } from '@shared/dtos/users/update-user.dto';
import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard';
import { AuthenticationService } from '@api/modules/auth/authentication.service';

@Controller()
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(JwtAuthGuard)
export class UsersController {
constructor(private usersService: UsersService) {}
constructor(
private usersService: UsersService,
private auth: AuthenticationService,
) {}

@TsRestHandler(c.findMe)
async findMe(@GetUser() user: User): Promise<any> {
Expand All @@ -35,27 +36,20 @@ export class UsersController {
}

@TsRestHandler(c.updatePassword)
async updatePassword(
@Body() dto: UpdateUserPasswordDto['newPassword'],
@GetUser() user: User,
): Promise<any> {
return tsRestHandler(c.updatePassword, async () => {
const updatedUser = await this.usersService.updatePassword(user, dto);
async updatePassword(@GetUser() user: User): Promise<any> {
return tsRestHandler(c.updatePassword, async ({ body }) => {
const updatedUser = await this.auth.updatePassword(user, body);
return { body: { data: updatedUser }, status: HttpStatus.OK };
});
}

@TsRestHandler(c.updateUser)
async update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateUserDto,
): Promise<any> {
return tsRestHandler(c.updateUser, async () => {
const user = await this.usersService.update(id, dto);
//return { body: { data: user }, status: HttpStatus.CREATED };
return { body: { data: user }, status: HttpStatus.CREATED };
});
}
// @TsRestHandler(c.updateMe)
// async update(@GetUser() user: User): Promise<any> {
// return tsRestHandler(c.updateMe, async () => {
// const user = await this.usersService.update(user.id, dto);
// return { body: { data: user }, status: HttpStatus.CREATED };
// });
// }

@TsRestHandler(c.deleteMe)
async deleteMe(@GetUser() user: User): Promise<any> {
Expand Down
5 changes: 3 additions & 2 deletions api/src/modules/users/users.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@shared/entities/users/user.entity';
import { UsersController } from '@api/modules/users/users.controller';
import { AuthModule } from '@api/modules/auth/auth.module';

@Module({
imports: [TypeOrmModule.forFeature([User])],
imports: [TypeOrmModule.forFeature([User]), forwardRef(() => AuthModule)],
providers: [UsersService],
exports: [UsersService],
controllers: [UsersController],
Expand Down
3 changes: 1 addition & 2 deletions api/src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { AppBaseService } from '@api/utils/app-base.service';
import { CreateUserDto } from '@shared/dtos/users/create-user.dto';
import { UpdateUserDto } from '@shared/dtos/users/update-user.dto';
import { AppInfoDTO } from '@api/utils/info.dto';

@Injectable()
export class UsersService extends AppBaseService<
User,
Expand Down Expand Up @@ -40,7 +39,7 @@ export class UsersService extends AppBaseService<
return this.userRepository.save(newUser);
}

async updatePassword(user: User, newPassword: string) {
async saveNewHashedPassword(user: User, newPassword: string) {
user.password = await bcrypt.hash(newPassword, 10);
return this.userRepository.save(user);
}
Expand Down
101 changes: 101 additions & 0 deletions api/test/integration/users/users-me.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createUser } from '@shared/lib/entity-mocks';
import { TestManager } from '../../utils/test-manager';
import { User } from '@shared/entities/users/user.entity';
import { HttpStatus } from '@nestjs/common';
import { usersContract } from '@shared/contracts/users.contract';

describe('Users ME (e2e)', () => {
let testManager: TestManager;
let authToken: string;
let testUser: User;

beforeAll(async () => {
testManager = await TestManager.createTestManager();
});

beforeEach(async () => {
await testManager.clearDatabase();

const { jwtToken, user } = await testManager.setUpTestUser();
authToken = jwtToken;
testUser = user;
});

afterAll(async () => {
await testManager.close();
});

it('should return only my own info', async () => {
const createdUsers: User[] = [];
for (const n of Array(3).keys()) {
createdUsers.push(
await testManager.mocks().createUser({
email: `user${n}@mail.com`,
}),
);
}
const { jwtToken } = await testManager.logUserIn(createdUsers[0]);
const response = await testManager
.request()
.get(usersContract.findMe.path)
.set('Authorization', `Bearer ${jwtToken}`);

expect(response.status).toBe(HttpStatus.OK);
expect(response.body.data.id).toEqual(createdUsers[0].id);
});

it('should update my own password', async () => {
const user = await testManager
.mocks()
.createUser({ email: '[email protected]' });

const { jwtToken, password: oldPassword } =
await testManager.logUserIn(user);
const newPassword = 'newPassword';
const response = await testManager
.request()
.patch(usersContract.updatePassword.path)
.send({ password: oldPassword, newPassword })
.set('Authorization', `Bearer ${jwtToken}`);

expect(response.status).toBe(200);
expect(response.body.data.id).toEqual(user.id);

const { jwtToken: noToken } = await testManager.logUserIn({
...user,
password: oldPassword,
});
expect(noToken).toBeUndefined();

const { jwtToken: newToken } = await testManager.logUserIn({
...user,
password: newPassword,
});

expect(newToken).toBeDefined();
});
it('should update a user', async () => {
const user = await createUser(testManager.getDataSource(), {
email: '[email protected]',
});

const { jwtToken } = await testManager.logUserIn(user);
const updatedUser = { email: '[email protected]' };
const response = await testManager
.request()
.patch('/users/' + user.id)
.send(updatedUser)
.set('Authorization', `Bearer ${jwtToken}`);
expect(response.status).toBe(201);
expect(response.body.data.email).toEqual(updatedUser.email);

// Previous token should work after updating the user's email
const userMeResponse = await testManager
.request()
.get('/users/me')
.set('Authorization', `Bearer ${jwtToken}`);

expect(userMeResponse.status).toBe(200);
expect(userMeResponse.body.data.email).toEqual(updatedUser.email);
});
});
1 change: 0 additions & 1 deletion shared/contracts/admin.contract.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { initContract } from "@ts-rest/core";
import { JSONAPIError } from "@shared/dtos/json-api.error";
import { CreateUserSchema } from "@shared/schemas/users/create-user.schema";

// TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc.
Expand Down
1 change: 0 additions & 1 deletion shared/contracts/auth.contract.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { initContract } from "@ts-rest/core";
import { LogInSchema } from "@shared/schemas/auth/login.schema";
import { UserWithAccessToken } from "@shared/dtos/users/user.dto";
import { JSONAPIError } from "@shared/dtos/json-api.error";
import { TokenTypeSchema } from "@shared/schemas/auth/token-type.schema";
import { z } from "zod";
import { BearerTokenSchema } from "@shared/schemas/auth/bearer-token.schema";
Expand Down
10 changes: 4 additions & 6 deletions shared/contracts/users.contract.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { initContract } from "@ts-rest/core";
import { JSONAPIError } from "@shared/dtos/json-api.error";
import { generateEntityQuerySchema } from "@shared/schemas/query-param.schema";
import { User } from "@shared/entities/users/user.entity";
import { UserDto } from "@shared/dtos/users/user.dto";
import { UpdateUserPasswordDto } from "@shared/dtos/users/update-user-password.dto";
import { z } from "zod";
import { UpdateUserDto } from "@shared/dtos/users/update-user.dto";
import { PasswordSchema } from "@shared/schemas/auth/login.schema";

import { ApiResponse } from "@shared/dtos/global/api-response.dto";
import { UpdateUserPasswordSchema } from "@shared/schemas/auth/sign-up.schema";

const contract = initContract();
export const usersContract = contract.router({
Expand All @@ -20,9 +18,9 @@ export const usersContract = contract.router({
},
query: generateEntityQuerySchema(User),
},
updateUser: {
updateMe: {
method: "PATCH",
path: "/users/:id",
path: "/users",
pathParams: z.object({
id: z.coerce.string(),
}),
Expand All @@ -38,7 +36,7 @@ export const usersContract = contract.router({
responses: {
200: contract.type<ApiResponse<UserDto>>(),
},
body: contract.type<UpdateUserPasswordDto>(),
body: UpdateUserPasswordSchema,
summary: "Update password of the user",
},
deleteMe: {
Expand Down
4 changes: 4 additions & 0 deletions shared/schemas/auth/sign-up.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,8 @@ export const SignUpSchema = z.object({
.max(32, "Password must be less than 32 characters"),
});

export const UpdateUserPasswordSchema = SignUpSchema;

export type UpdateUserPasswordDto = z.infer<typeof UpdateUserPasswordSchema>;

export type SignUpDto = z.infer<typeof SignUpSchema>;

0 comments on commit a0b614f

Please sign in to comment.