Skip to content

Commit

Permalink
Add userProfile controller and PUT /userProfile/:id endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
pbn4 committed Sep 22, 2021
1 parent 473a1f7 commit f7cbeee
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 21 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ All notable changes to this project will be documented in this file. The format
- Add POST /users/invite endpoint and extend PUT /users/confirm with optional password change ([#1801](https://github.com/bloom-housing/bloom/pull/1801))
- Add `isPartner` filter to GET /user/list endpoint ([#1830](https://github.com/bloom-housing/bloom/pull/1830))
- Add logic for connecting newly created user account to existing applications (matching based on applicant.emailAddress) ([#1807](https://github.com/bloom-housing/bloom/pull/1807))
- Added new userProfile resource and endpoint `PUT /userProfile/:id` suited specifically for users updating their own profiles ([#1862](https://github.com/bloom-housing/bloom/pull/1862))

- Changed:
- Endpoint `PUT /user/:id` is admin only now, because it allows edits over entire `user` table ([#1862](https://github.com/bloom-housing/bloom/pull/1862))

## Frontend

Expand Down
3 changes: 2 additions & 1 deletion backend/core/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { EmailModule } from "../shared/email/email.module"
import { PasswordService } from "./services/password.service"
import { JurisdictionsModule } from "../jurisdictions/jurisdictions.module"
import { Application } from "../applications/entities/application.entity"
import { UserProfileController } from "./controllers/user-profile.controller"

@Module({
imports: [
Expand All @@ -38,6 +39,6 @@ import { Application } from "../applications/entities/application.entity"
],
providers: [LocalStrategy, JwtStrategy, AuthService, AuthzService, UserService, PasswordService],
exports: [AuthzService, AuthService, UserService],
controllers: [AuthController, UserController],
controllers: [AuthController, UserController, UserProfileController],
})
export class AuthModule {}
4 changes: 3 additions & 1 deletion backend/core/src/auth/authz_policy.csv
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ p, user, application, !r.obj || (r.sub == r.obj.user_id), (read|submit)
p, anonymous, application, true, submit

p, admin, user, true, .*
p, user, user, !r.obj || (r.sub == r.obj.id), (read|update)
p, admin, userProfile, true, .*
p, user, user, !r.obj || (r.sub == r.obj.id), read
p, user, userProfile, !r.obj || (r.sub == r.obj.id), (read|update)
p, anonymous, user, true, create

p, admin, asset, true, .*
Expand Down
31 changes: 31 additions & 0 deletions backend/core/src/auth/controllers/user-profile.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Body, Controller, Put, Request, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"
import { Request as ExpressRequest } from "express"
import { ResourceType } from "../decorators/resource-type.decorator"
import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options"
import { UserService } from "../services/user.service"
import { AuthzGuard } from "../guards/authz.guard"
import { UserDto } from "../dto/user.dto"
import { mapTo } from "../../shared/mapTo"
import { AuthContext } from "../types/auth-context"
import { User } from "../entities/user.entity"
import { DefaultAuthGuard } from "../guards/default.guard"
import { UserProfileUpdateDto } from "../dto/user-profile.dto"

@Controller("userProfile")
@ApiBearerAuth()
@ApiTags("userProfile")
@ResourceType("userProfile")
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
export class UserProfileController {
constructor(private readonly userService: UserService) {}
@Put(":id")
@UseGuards(DefaultAuthGuard, AuthzGuard)
@ApiOperation({ summary: "Update profile user", operationId: "update" })
async update(
@Request() req: ExpressRequest,
@Body() dto: UserProfileUpdateDto
): Promise<UserDto> {
return mapTo(UserDto, await this.userService.update(dto, new AuthContext(req.user as User)))
}
}
5 changes: 3 additions & 2 deletions backend/core/src/auth/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { LoginResponseDto } from "../dto/login-response.dto"
import { authzActions } from "../enum/authz-actions.enum"
import { UserCreateQueryParams } from "../dto/user-create-query-params"
import { UserFilterParams } from "../dto/user-filter-params"
import { DefaultAuthGuard } from "../guards/default.guard"

@Controller("user")
@ApiBearerAuth()
Expand All @@ -48,7 +49,7 @@ export class UserController {
constructor(private readonly userService: UserService) {}

@Get()
@UseGuards(OptionalAuthGuard, AuthzGuard)
@UseGuards(DefaultAuthGuard, AuthzGuard)
profile(@Request() req): UserDto {
return mapTo(UserDto, req.user)
}
Expand Down Expand Up @@ -101,7 +102,7 @@ export class UserController {
}

@Put(":id")
@UseGuards(OptionalAuthGuard, AuthzGuard)
@UseGuards(DefaultAuthGuard, AuthzGuard)
@ApiOperation({ summary: "Update user", operationId: "update" })
async update(@Request() req: ExpressRequest, @Body() dto: UserUpdateDto): Promise<UserDto> {
return mapTo(UserDto, await this.userService.update(dto, new AuthContext(req.user as User)))
Expand Down
46 changes: 46 additions & 0 deletions backend/core/src/auth/dto/user-profile.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { PickType } from "@nestjs/swagger"
import { User } from "../entities/user.entity"
import { Expose, Type } from "class-transformer"
import {
IsDefined,
IsNotEmpty,
IsOptional,
IsString,
Matches,
ValidateIf,
ValidateNested,
} from "class-validator"
import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum"
import { passwordRegex } from "../../shared/password-regex"
import { IdDto } from "../../shared/dto/id.dto"

export class UserProfileUpdateDto extends PickType(User, [
"id",
"firstName",
"middleName",
"lastName",
"dob",
"createdAt",
"updatedAt",
"language",
] as const) {
@Expose()
@IsOptional({ groups: [ValidationsGroupsEnum.default] })
@IsString({ groups: [ValidationsGroupsEnum.default] })
@Matches(passwordRegex, {
message: "passwordTooWeak",
groups: [ValidationsGroupsEnum.default],
})
password?: string

@Expose()
@ValidateIf((o) => o.password, { groups: [ValidationsGroupsEnum.default] })
@IsNotEmpty({ groups: [ValidationsGroupsEnum.default] })
currentPassword?: string

@Expose()
@IsDefined({ groups: [ValidationsGroupsEnum.default] })
@ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true })
@Type(() => IdDto)
jurisdictions: IdDto[]
}
7 changes: 7 additions & 0 deletions backend/core/src/auth/dto/user-update.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Expose, Type } from "class-transformer"
import {
IsDate,
IsDefined,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
Expand All @@ -18,6 +19,7 @@ import { UserDto } from "./user.dto"

export class UserUpdateDto extends OmitType(UserDto, [
"id",
"email",
"createdAt",
"updatedAt",
"leasingAgentInListings",
Expand All @@ -29,6 +31,11 @@ export class UserUpdateDto extends OmitType(UserDto, [
@IsUUID(4, { groups: [ValidationsGroupsEnum.default] })
id?: string

@Expose()
@IsOptional({ groups: [ValidationsGroupsEnum.default] })
@IsEmail({}, { groups: [ValidationsGroupsEnum.default] })
email?: string

@Expose()
@IsOptional({ groups: [ValidationsGroupsEnum.default] })
@IsDate({ groups: [ValidationsGroupsEnum.default] })
Expand Down
12 changes: 1 addition & 11 deletions backend/core/src/auth/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,24 +94,14 @@ export class UserService {
return result
}

async update(dto: Partial<UserUpdateDto>, authContext: AuthContext) {
async update(dto: UserUpdateDto, authContext: AuthContext) {
const user = await this.find({
id: dto.id,
})
if (!user) {
throw new NotFoundException()
}

await this.authzService.canOrThrow(authContext.user, "user", authzActions.update, {
...dto,
})

if (user.confirmedAt?.getTime() !== dto.confirmedAt?.getTime()) {
await this.authzService.canOrThrow(authContext.user, "user", authzActions.confirm, {
...dto,
})
}

let passwordHash
if (dto.password) {
if (!dto.currentPassword) {
Expand Down
4 changes: 2 additions & 2 deletions backend/core/test/authz/authz.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe("Authz", () => {

describe("user", () => {
it(`should not allow anonymous user to GET to get any user profile`, async () => {
await supertest(app.getHttpServer()).get(userEndpoint).expect(403)
await supertest(app.getHttpServer()).get(userEndpoint).expect(401)
})

it(`should allow a logged in user to GET to get any user profile`, async () => {
Expand Down Expand Up @@ -225,7 +225,7 @@ describe("Authz", () => {

expect(profileRes.body.roles).toBe(null)
await supertest(app.getHttpServer())
.put(`/user/${profileRes.body.id}`)
.put(`/userProfile/${profileRes.body.id}`)
.send({ ...profileRes.body, roles: { isAdmin: true, isPartner: false } })
.set(...setAuthorization(userAccessToken))
.expect(200)
Expand Down
59 changes: 58 additions & 1 deletion backend/core/test/user/user.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { UserInviteDto } from "../../src/auth/dto/user-invite.dto"
import { Listing } from "../../src/listings/entities/listing.entity"
import { Repository } from "typeorm"
import { Jurisdiction } from "../../src/jurisdictions/entities/jurisdiction.entity"
import { UserProfileUpdateDto } from "../../src/auth/dto/user-profile.dto"
import { Language } from "../../src/shared/types/language-enum"

// Cypress brings in Chai types for the global expect, but we want to use jest
// expect here so we need to re-declare it.
Expand Down Expand Up @@ -270,7 +272,7 @@ describe("Applications", () => {
await supertest(app.getHttpServer())
.put(`/user/${user2UpdateDto.id}`)
.send(user2UpdateDto)
.expect(403)
.expect(401)
})

it("should allow user to resend confirmation", async () => {
Expand Down Expand Up @@ -398,4 +400,59 @@ describe("Applications", () => {
const token = await getUserAccessToken(app, newUser.email, password)
expect(token).toBeDefined()
})

it("should allow user to update user profile throguh PUT /userProfile/:id endpoint", async () => {
const userCreateDto: UserCreateDto = {
password: "Abcdef1!",
passwordConfirmation: "Abcdef1!",
email: "userProfile@b.com",
emailConfirmation: "userProfile@b.com",
firstName: "First",
middleName: "Mid",
lastName: "Last",
dob: new Date(),
language: Language.en,
}
const userCreateResponse = await supertest(app.getHttpServer())
.post(`/user/`)
.set("jurisdictionName", "Alameda")
.send(userCreateDto)
.expect(201)

const userService = await app.resolve<UserService>(UserService)
const user = await userService.findByEmail(userCreateDto.email)

await supertest(app.getHttpServer())
.put(`/user/confirm/`)
.send({ token: user.confirmationToken })
.expect(200)

const userAccessToken = await getUserAccessToken(
app,
userCreateDto.email,
userCreateDto.password
)

const userProfileUpdateDto: UserProfileUpdateDto = {
id: userCreateResponse.body.id,
createdAt: userCreateResponse.body.createdAt,
updatedAt: userCreateResponse.body.updatedAt,
jurisdictions: userCreateResponse.body.jurisdictions,
...userCreateDto,
currentPassword: userCreateDto.password,
firstName: "NewFirstName",
}

await supertest(app.getHttpServer())
.put(`/userProfile/${userCreateResponse.body.id}`)
.send(userProfileUpdateDto)
.expect(401)

const userProfileUpdateResponse = await supertest(app.getHttpServer())
.put(`/userProfile/${userCreateResponse.body.id}`)
.send(userProfileUpdateDto)
.set(...setAuthorization(userAccessToken))
.expect(200)
expect(userProfileUpdateResponse.body.firstName).toBe(userProfileUpdateDto.firstName)
})
})
Loading

0 comments on commit f7cbeee

Please sign in to comment.