Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modify Users' Access Types #357

Merged
merged 30 commits into from
Dec 2, 2023
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c86b097
requests/responses types, validator, controller
nik-dange Jun 4, 2023
a04a851
api request + half of service
nik-dange Jun 23, 2023
3fcb598
core functionality done
nik-dange Jul 4, 2023
1545e3d
fixme note for apiresponses
nik-dange Jul 4, 2023
b2547fb
added admin restrictions
nik-dange Jul 4, 2023
d07b429
get route and query
nik-dange Jul 8, 2023
25a19d1
fixed bug for email array being out of order
nik-dange Jul 8, 2023
b76deb9
half of unit tests
nik-dange Jul 15, 2023
e917e3c
more tests
nik-dange Jul 16, 2023
d3ec460
validator edits
nik-dange Jul 29, 2023
f7dac2e
restruct. api request,checks for duplicate users
nik-dange Aug 5, 2023
0800759
fixed tests and linting
nik-dange Aug 5, 2023
bc5570c
renamed request and made duplicate email function
nik-dange Aug 18, 2023
e42c68b
new user map and activity log
nik-dange Aug 20, 2023
a7f3833
fixed tests and linting
nik-dange Aug 20, 2023
be2f0e0
fixed typing of initial request
nik-dange Sep 17, 2023
4af8c60
remove unncessary comments and maybe a new type
nik-dange Oct 14, 2023
8f2c55c
removed check for demoting admin/updated tests
nik-dange Oct 18, 2023
26ebb1c
linting :(
nik-dange Oct 18, 2023
e3a582b
Merge branch 'master' of https://github.com/acmucsd/membership-portal…
nik-dange Oct 18, 2023
6cb37d1
fixed tests and allowed promotion to admin
nik-dange Oct 21, 2023
91116fc
marcelo is a data structures goat
nik-dange Oct 21, 2023
5a841f7
cleaning up comments
nik-dange Oct 21, 2023
adf8822
linting
nik-dange Nov 7, 2023
57c032a
Merge branch 'master' of https://github.com/acmucsd/membership-portal…
nik-dange Nov 7, 2023
b6ea537
removed unnecessary async/await for checkDupEmails
nik-dange Nov 7, 2023
d627ea2
Merge branch 'master' of https://github.com/acmucsd/membership-portal…
nik-dange Dec 2, 2023
b3ed72c
marcelo's naming changes
nik-dange Dec 2, 2023
dd3467a
linting :(
nik-dange Dec 2, 2023
4953f07
bumped semver
nik-dange Dec 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion api/controllers/AdminController.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -13,6 +14,8 @@ import {
UploadBannerResponse,
GetAllEmailsResponse,
SubmitAttendanceForUsersResponse,
ModifyUserAccessLevelResponse,
GetAllUserAccessLevelsResponse,
} from '../../types';
import { AuthenticatedUser } from '../decorators/AuthenticatedUser';
import UserAccountService from '../../services/UserAccountService';
Expand Down Expand Up @@ -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<ModifyUserAccessLevelResponse> {
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<GetAllUserAccessLevelsResponse> {
if (!PermissionsService.canSeeAllUserAccessLevels(user)) throw new ForbiddenError();
const users = await this.userAccountService.getAllUsersWithAccessLevels();
return { error: null, users };
}
}
22 changes: 21 additions & 1 deletion api/validators/AdminControllerRequests.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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[];
}
8 changes: 8 additions & 0 deletions repositories/UserRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,12 @@ export class UserRepository extends BaseRepository<UserModel> {
})
.execute();
}

public async getUserInfoAndAccessTypes() {
const profiles = await this.repository
.createQueryBuilder()
.select(['uuid', 'handle', 'email', 'UserModel.firstName', 'UserModel.lastName', 'UserModel.accessType'])
.getRawMany();
return profiles;
}
}
8 changes: 8 additions & 0 deletions services/PermissionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
66 changes: 66 additions & 0 deletions services/UserAccountService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -192,4 +193,69 @@ export default class UserAccountService {
return userProfile;
});
}

public async checkDuplicateEmails(emails: string[]) {
const emailSet = emails.reduce((set, email) => {
set.add(email);
return set;
}, new Set<string>());

if (emailSet.size !== emails.length) {
throw new BadRequestError('Duplicate emails found in request');
}
}

public async updateUserAccessLevels(accessUpdates: UserAccessUpdates[], emails: string[],
nik-dange marked this conversation as resolved.
Show resolved Hide resolved
currentUser: UserModel): Promise<PrivateProfile[]> {
return this.transactions.readWrite(async (txn) => {

await 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, accessType } = accessUpdate;

const currUser = emailToUserMap[user];
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 getAllUsersWithAccessLevels(): Promise<PrivateProfile[]> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just double checking, are you thinking that we might have a type for just the user with accesslevels in the future? If that is not the case, we can just change the name of the function to getAllFullUserProfile()

const users = await this.transactions.readOnly(async (txn) => Repositories
.user(txn)
.findAll());
return users.map((user) => user.getFullUserProfile());
}
}
179 changes: 179 additions & 0 deletions tests/admin.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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);
});
nik-dange marked this conversation as resolved.
Show resolved Hide resolved

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);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also add another test making sure the role of the user is not updated

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The role of the current user making the request?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the role of all users in the input of the changes requested

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the goal is that when an error is thrown, the request should not make changes to the database

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if there is any questions regarding for what I requested


test('attempt to update duplicate users', async () => {
nik-dange marked this conversation as resolved.
Show resolved Hide resolved
const conn = await DatabaseConnection.get();
const admin = UserFactory.fake({ accessType: UserAccessType.ADMIN });

const userOne = UserFactory.fake({ accessType: UserAccessType.STAFF, email: '[email protected]' });

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);
});
});
11 changes: 10 additions & 1 deletion types/ApiRequests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FeedbackStatus, FeedbackType, SocialMediaType } from './Enums';
import { FeedbackStatus, FeedbackType, SocialMediaType, UserAccessType } from './Enums';
import { Uuid } from '.';

// REQUEST TYPES
Expand Down Expand Up @@ -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 {
Expand Down
Loading