diff --git a/api/controllers/AdminController.ts b/api/controllers/AdminController.ts index 488c79cc..6ab76108 100644 --- a/api/controllers/AdminController.ts +++ b/api/controllers/AdminController.ts @@ -1,9 +1,10 @@ -import { JsonController, Post, UploadedFile, UseBefore, ForbiddenError, Body, Get } from 'routing-controllers'; +import { JsonController, Post, Patch, UploadedFile, UseBefore, ForbiddenError, Body, Get } from 'routing-controllers'; import { UserAuthentication } from '../middleware/UserAuthentication'; import { CreateBonusRequest, CreateMilestoneRequest, SubmitAttendanceForUsersRequest, + ModifyUserAccessLevelRequest, } from '../validators/AdminControllerRequests'; import { File, @@ -13,6 +14,8 @@ import { UploadBannerResponse, GetAllEmailsResponse, SubmitAttendanceForUsersResponse, + ModifyUserAccessLevelResponse, + GetAllUserAccessLevelsResponse, } from '../../types'; import { AuthenticatedUser } from '../decorators/AuthenticatedUser'; import UserAccountService from '../../services/UserAccountService'; @@ -79,4 +82,21 @@ export class AdminController { const attendances = await this.attendanceService.submitAttendanceForUsers(emails, event, asStaff, currentUser); return { error: null, attendances }; } + + @Patch('/access') + async updateUserAccessLevel(@Body() modifyUserAccessLevelRequest: ModifyUserAccessLevelRequest, + @AuthenticatedUser() currentUser: UserModel): Promise { + if (!PermissionsService.canModifyUserAccessLevel(currentUser)) throw new ForbiddenError(); + const { accessUpdates } = modifyUserAccessLevelRequest; + const emails = accessUpdates.map((e) => e.user.toLowerCase()); + const updatedUsers = await this.userAccountService.updateUserAccessLevels(accessUpdates, emails, currentUser); + return { error: null, updatedUsers }; + } + + @Get('/access') + async getAllUsersWithAccessLevels(@AuthenticatedUser() user: UserModel): Promise { + if (!PermissionsService.canSeeAllUserAccessLevels(user)) throw new ForbiddenError(); + const users = await this.userAccountService.getAllFullUserProfiles(); + return { error: null, users }; + } } diff --git a/api/validators/AdminControllerRequests.ts b/api/validators/AdminControllerRequests.ts index ca8ea91b..3739d4d2 100644 --- a/api/validators/AdminControllerRequests.ts +++ b/api/validators/AdminControllerRequests.ts @@ -1,13 +1,18 @@ -import { Allow, ArrayNotEmpty, IsDefined, IsNotEmpty, IsPositive, IsUUID, ValidateNested } from 'class-validator'; +import { Allow, ArrayNotEmpty, IsDefined, IsIn, IsNotEmpty, IsPositive, IsUUID, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; import { CreateBonusRequest as ICreateBonusRequest, CreateMilestoneRequest as ICreateMilestoneRequest, SubmitAttendanceForUsersRequest as ISubmitAttendanceForUsersRequest, + ModifyUserAccessLevelRequest as IModifyUserAccessLevelRequest, Milestone as IMilestone, Bonus as IBonus, + UserAccessUpdates as IUserAccessUpdates, + UserAccessType, } from '../../types'; +const validUserAccessTypes = Object.values(UserAccessType); + export class Milestone implements IMilestone { @IsDefined() @IsNotEmpty() @@ -58,3 +63,18 @@ export class SubmitAttendanceForUsersRequest implements ISubmitAttendanceForUser @Allow() asStaff?: boolean; } + +export class UserAccessUpdates implements IUserAccessUpdates { + @IsDefined() + user: string; + + @IsDefined() + @IsIn(validUserAccessTypes) + accessType: UserAccessType; +} +export class ModifyUserAccessLevelRequest implements IModifyUserAccessLevelRequest { + @Type(() => UserAccessUpdates) + @IsDefined() + @ValidateNested() + accessUpdates: UserAccessUpdates[]; +} diff --git a/package.json b/package.json index bcab0b6d..c765afe9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@acmucsd/membership-portal", - "version": "2.10.1", + "version": "2.11.1", "description": "REST API for ACM UCSD's membership portal.", "main": "index.d.ts", "files": [ diff --git a/repositories/UserRepository.ts b/repositories/UserRepository.ts index 6e738605..17f32eba 100644 --- a/repositories/UserRepository.ts +++ b/repositories/UserRepository.ts @@ -91,4 +91,12 @@ export class UserRepository extends BaseRepository { }) .execute(); } + + public async getUserInfoAndAccessTypes() { + const profiles = await this.repository + .createQueryBuilder() + .select(['uuid', 'handle', 'email', 'UserModel.firstName', 'UserModel.lastName', 'UserModel.accessType']) + .getRawMany(); + return profiles; + } } diff --git a/services/PermissionsService.ts b/services/PermissionsService.ts index d702e7cd..ed1b9421 100644 --- a/services/PermissionsService.ts +++ b/services/PermissionsService.ts @@ -81,4 +81,12 @@ export default class PermissionsService { public static canSeeAllVisibleResumes(user: UserModel) { return user.isAdmin(); } + + public static canModifyUserAccessLevel(user: UserModel): boolean { + return user.isAdmin(); + } + + public static canSeeAllUserAccessLevels(user: UserModel): boolean { + return user.isAdmin(); + } } diff --git a/services/UserAccountService.ts b/services/UserAccountService.ts index fc1a7e0f..3a17d175 100644 --- a/services/UserAccountService.ts +++ b/services/UserAccountService.ts @@ -4,6 +4,7 @@ import { InjectManager } from 'typeorm-typedi-extensions'; import { EntityManager } from 'typeorm'; import * as moment from 'moment'; import * as faker from 'faker'; +import { UserAccessUpdates } from 'api/validators/AdminControllerRequests'; import Repositories, { TransactionsManager } from '../repositories'; import { Uuid, @@ -192,4 +193,68 @@ export default class UserAccountService { return userProfile; }); } + + public checkDuplicateEmails(emails: string[]) { + const emailSet = emails.reduce((set, email) => { + set.add(email); + return set; + }, new Set()); + + if (emailSet.size !== emails.length) { + throw new BadRequestError('Duplicate emails found in request'); + } + } + + public async updateUserAccessLevels(accessUpdates: UserAccessUpdates[], emails: string[], + currentUser: UserModel): Promise { + return this.transactions.readWrite(async (txn) => { + this.checkDuplicateEmails(emails); + + // Strip out the user emails & validate that users exist + const userRepository = Repositories.user(txn); + const users = await userRepository.findByEmails(emails); + const emailsFound = users.map((user) => user.email); + const emailsNotFound = emails.filter((email) => !emailsFound.includes(email)); + + if (emailsNotFound.length > 0) { + throw new BadRequestError(`Couldn't find accounts matching these emails: ${emailsNotFound}`); + } + + const emailToUserMap = users.reduce((map, user) => { + map[user.email] = user; + return map; + }, {}); + + const updatedUsers = await Promise.all(accessUpdates.map(async (accessUpdate, index) => { + const { user: userEmail, accessType } = accessUpdate; + + const currUser = emailToUserMap[userEmail]; + const oldAccess = currUser.accessType; + + const updatedUser = await userRepository.upsertUser(currUser, { accessType }); + + const activity = { + user: currentUser, + type: ActivityType.ACCOUNT_ACCESS_LEVEL_UPDATE, + description: `${currentUser.email} changed ${updatedUser.email}'s + access level from ${oldAccess} to ${accessType}`, + }; + + await Repositories + .activity(txn) + .logActivity(activity); + + return updatedUser; + })); + + return updatedUsers; + }); + } + + public async getAllFullUserProfiles(): Promise { + const users = await this.transactions.readOnly(async (txn) => Repositories + .user(txn) + .findAll()); + return users.map((user) => user.getFullUserProfile()); + } } diff --git a/tests/admin.test.ts b/tests/admin.test.ts index 81d80e00..021f8c6d 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -1,6 +1,9 @@ +import { BadRequestError, ForbiddenError } from 'routing-controllers'; +import { In } from 'typeorm'; import { ActivityScope, ActivityType, SubmitAttendanceForUsersRequest, UserAccessType } from '../types'; import { ControllerFactory } from './controllers'; import { DatabaseConnection, EventFactory, PortalState, UserFactory } from './data'; +import { UserModel } from '../models/UserModel'; beforeAll(async () => { await DatabaseConnection.connect(); @@ -182,3 +185,179 @@ describe('bonus points submission', () => { expect(getNoBonusUserResponse.user.points).toEqual(0); }); }); + +describe('updating user access level', () => { + test('updates the access level of the user', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + + const staffUser = UserFactory.fake({ accessType: UserAccessType.STAFF }); + const standardUser = UserFactory.fake({ accessType: UserAccessType.STANDARD }); + const marketingUser = UserFactory.fake({ accessType: UserAccessType.MARKETING }); + const merchStoreDistributorUser = UserFactory.fake({ accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }); + + await new PortalState() + .createUsers(admin, staffUser, standardUser, marketingUser, merchStoreDistributorUser) + .write(); + + const adminController = ControllerFactory.admin(conn); + + const accessLevelResponse = await adminController.updateUserAccessLevel({ + accessUpdates: [ + { user: staffUser.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, + { user: standardUser.email, accessType: UserAccessType.MARKETING }, + { user: marketingUser.email, accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }, + { user: merchStoreDistributorUser.email, accessType: UserAccessType.STAFF }, + ], + }, admin); + + const repository = conn.getRepository(UserModel); + const updatedUsers = await repository.find({ + email: In([staffUser.email, standardUser.email, marketingUser.email, merchStoreDistributorUser.email]), + }); + + expect(updatedUsers[0].email).toEqual(staffUser.email); + expect(updatedUsers[0].accessType).toEqual(UserAccessType.MERCH_STORE_MANAGER); + expect(accessLevelResponse.updatedUsers[0].accessType).toEqual(UserAccessType.MERCH_STORE_MANAGER); + + expect(updatedUsers[1].email).toEqual(standardUser.email); + expect(updatedUsers[1].accessType).toEqual(UserAccessType.MARKETING); + expect(accessLevelResponse.updatedUsers[1].accessType).toEqual(UserAccessType.MARKETING); + + expect(updatedUsers[2].email).toEqual(marketingUser.email); + expect(updatedUsers[2].accessType).toEqual(UserAccessType.MERCH_STORE_DISTRIBUTOR); + expect(accessLevelResponse.updatedUsers[2].accessType).toEqual(UserAccessType.MERCH_STORE_DISTRIBUTOR); + + expect(updatedUsers[3].email).toEqual(merchStoreDistributorUser.email); + expect(updatedUsers[3].accessType).toEqual(UserAccessType.STAFF); + expect(accessLevelResponse.updatedUsers[3].accessType).toEqual(UserAccessType.STAFF); + }); + + test('attempt to update when user is not an admin', async () => { + const conn = await DatabaseConnection.get(); + const standard = UserFactory.fake({ accessType: UserAccessType.STANDARD }); + + const staffUser = UserFactory.fake({ accessType: UserAccessType.STAFF }); + const standardUser = UserFactory.fake({ accessType: UserAccessType.STANDARD }); + const marketingUser = UserFactory.fake({ accessType: UserAccessType.MARKETING }); + const merchStoreDistributorUser = UserFactory.fake({ accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }); + + await new PortalState() + .createUsers(staffUser, standardUser, marketingUser, merchStoreDistributorUser, standard) + .write(); + + const adminController = ControllerFactory.admin(conn); + + await expect(async () => { + await adminController.updateUserAccessLevel({ + accessUpdates: [ + { user: staffUser.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, + { user: standardUser.email, accessType: UserAccessType.MARKETING }, + { user: marketingUser.email, accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }, + { user: merchStoreDistributorUser.email, accessType: UserAccessType.STAFF }, + ], + }, standard); + }).rejects.toThrow(ForbiddenError); + + const repository = conn.getRepository(UserModel); + const updatedUsers = await repository.find({ + email: In([staffUser.email, standardUser.email, marketingUser.email, merchStoreDistributorUser.email]), + }); + + expect(updatedUsers[0].email).toEqual(staffUser.email); + expect(updatedUsers[0].accessType).toEqual(UserAccessType.STAFF); + expect(updatedUsers[1].email).toEqual(standardUser.email); + expect(updatedUsers[1].accessType).toEqual(UserAccessType.STANDARD); + expect(updatedUsers[2].email).toEqual(marketingUser.email); + expect(updatedUsers[2].accessType).toEqual(UserAccessType.MARKETING); + expect(updatedUsers[3].email).toEqual(merchStoreDistributorUser.email); + expect(updatedUsers[3].accessType).toEqual(UserAccessType.MERCH_STORE_DISTRIBUTOR); + }); + + test('attempt to update duplicate users', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + + const userOne = UserFactory.fake({ accessType: UserAccessType.STAFF, email: 'smhariha@ucsd.edu' }); + + await new PortalState() + .createUsers(userOne, admin) + .write(); + + const adminController = ControllerFactory.admin(conn); + + await expect(async () => { + await adminController.updateUserAccessLevel({ + accessUpdates: [ + { user: userOne.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, + { user: userOne.email, accessType: UserAccessType.STAFF }, + ], + }, admin); + }).rejects.toThrow(BadRequestError); + + const repository = conn.getRepository(UserModel); + const updatedUsers = await repository.findOne({ email: userOne.email }); + + expect(updatedUsers.email).toEqual(userOne.email); + expect(updatedUsers.accessType).toEqual(UserAccessType.STAFF); + }); + + test('admin ability to demote another admin', async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + + const secondAdmin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + + await new PortalState() + .createUsers(admin, secondAdmin) + .write(); + + const adminController = ControllerFactory.admin(conn); + + const accessLevelResponse = await adminController.updateUserAccessLevel({ + accessUpdates: [ + { user: secondAdmin.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, + ], + }, admin); + + const repository = conn.getRepository(UserModel); + const updatedUser = await repository.findOne({ email: secondAdmin.email }); + + expect(updatedUser.email).toEqual(secondAdmin.email); + expect(updatedUser.accessType).toEqual(UserAccessType.MERCH_STORE_MANAGER); + expect(accessLevelResponse.updatedUsers[0].accessType).toEqual(UserAccessType.MERCH_STORE_MANAGER); + }); + + test("ensure that the updating user's access level is not changed", async () => { + const conn = await DatabaseConnection.get(); + const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN }); + + const staffUser = UserFactory.fake({ accessType: UserAccessType.STAFF }); + const standardUser = UserFactory.fake({ accessType: UserAccessType.STANDARD }); + const marketingUser = UserFactory.fake({ accessType: UserAccessType.MARKETING }); + const merchStoreDistributorUser = UserFactory.fake({ accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }); + + await new PortalState() + .createUsers(staffUser, standardUser, marketingUser, merchStoreDistributorUser, admin) + .write(); + + const adminController = ControllerFactory.admin(conn); + + await adminController.updateUserAccessLevel({ + accessUpdates: [ + { user: staffUser.email, accessType: UserAccessType.MERCH_STORE_MANAGER }, + { user: standardUser.email, accessType: UserAccessType.MARKETING }, + { user: marketingUser.email, accessType: UserAccessType.MERCH_STORE_DISTRIBUTOR }, + { user: merchStoreDistributorUser.email, accessType: UserAccessType.STAFF }, + ], + }, admin); + + const repository = conn.getRepository(UserModel); + const existingAdmin = await repository.find({ + email: admin.email, + }); + + expect(existingAdmin[0].email).toEqual(admin.email); + expect(existingAdmin[0].accessType).toEqual(UserAccessType.ADMIN); + }); +}); diff --git a/types/ApiRequests.ts b/types/ApiRequests.ts index 34769b9e..2f5b50aa 100644 --- a/types/ApiRequests.ts +++ b/types/ApiRequests.ts @@ -1,4 +1,4 @@ -import { FeedbackStatus, FeedbackType, SocialMediaType } from './Enums'; +import { FeedbackStatus, FeedbackType, SocialMediaType, UserAccessType } from './Enums'; import { Uuid } from '.'; // REQUEST TYPES @@ -127,6 +127,15 @@ export interface SubmitAttendanceForUsersRequest { asStaff?: boolean; } +export interface UserAccessUpdates { + user: string; + accessType: UserAccessType; +} + +export interface ModifyUserAccessLevelRequest { + accessUpdates: UserAccessUpdates[]; +} + // EVENT export interface OptionalEventProperties { diff --git a/types/ApiResponses.ts b/types/ApiResponses.ts index 1e1057df..c508f7ff 100644 --- a/types/ApiResponses.ts +++ b/types/ApiResponses.ts @@ -39,6 +39,14 @@ export interface SubmitAttendanceForUsersResponse extends ApiResponse { attendances: PublicAttendance[]; } +export interface ModifyUserAccessLevelResponse extends ApiResponse { + updatedUsers: PrivateProfile[]; +} + +export interface GetAllUserAccessLevelsResponse extends ApiResponse { + users: PrivateProfile[]; +} + // ATTENDANCE export interface PublicAttendance { diff --git a/types/Enums.ts b/types/Enums.ts index d4a43288..5185b06b 100644 --- a/types/Enums.ts +++ b/types/Enums.ts @@ -29,6 +29,7 @@ export enum ActivityType { ACCOUNT_RESET_PASS = 'ACCOUNT_RESET_PASS', ACCOUNT_RESET_PASS_REQUEST = 'ACCOUNT_RESET_PASS_REQUEST', ACCOUNT_UPDATE_INFO = 'ACCOUNT_UPDATE_INFO', + ACCOUNT_ACCESS_LEVEL_UPDATE = 'ACCOUNT_ACCESS_LEVEL_UPDATE', ACCOUNT_LOGIN = 'ACCOUNT_LOGIN', ATTEND_EVENT = 'ATTEND_EVENT', ATTEND_EVENT_AS_STAFF = 'ATTEND_EVENT_AS_STAFF',