Skip to content

Commit bc3c305

Browse files
authored
Merge pull request #1564 from Northeastern-Electric-Racing/#1425-allow-leads-edit-vendors
#1425 - Allow Finance Leads to Edit Vendors on the Admin Page
2 parents b1e9737 + 98234a1 commit bc3c305

13 files changed

Lines changed: 158 additions & 26 deletions

File tree

.github/workflows/run-tests.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ jobs:
2020
steps:
2121
- name: Checkout repository
2222
uses: actions/checkout@v3
23+
- name: Set env variables
24+
run: touch .env && echo "FINANCE_TEAM_ID=0" >> .env
2325
- name: Set up Node.js
2426
uses: actions/setup-node@v3
2527
with:

src/backend/src/prisma/seed.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import ChangeRequestsService from '../services/change-requests.services';
2222
import projectQueryArgs from '../prisma-query-args/projects.query-args';
2323
import TeamsService from '../services/teams.services';
2424
import WorkPackagesService from '../services/work-packages.services';
25-
import { ChangeRequest, ClubAccount, StandardChangeRequest, validateWBS, WbsElementStatus, WorkPackageStage } from 'shared';
25+
import { ClubAccount, StandardChangeRequest, validateWBS, WbsElementStatus, WorkPackageStage } from 'shared';
2626
import TasksService from '../services/tasks.services';
2727
import DescriptionBulletsService from '../services/description-bullets.services';
2828
import { seedProject } from './seed-data/projects.seed';
@@ -208,15 +208,12 @@ const performSeed: () => Promise<void> = async () => {
208208
batman,
209209
justiceLeague.teamId,
210210
[
211-
wonderwoman,
212211
flash,
213212
aquaman,
214213
superman,
215214
hawkMan,
216215
hawkWoman,
217-
cyborg,
218216
greenLantern,
219-
martianManhunter,
220217
lexLuther,
221218
hawkgirl,
222219
elongatedMan,
@@ -227,6 +224,11 @@ const performSeed: () => Promise<void> = async () => {
227224
hankHeywood
228225
].map((user) => user.userId)
229226
);
227+
await TeamsService.setTeamLeads(
228+
batman,
229+
justiceLeague.teamId,
230+
[wonderwoman, cyborg, martianManhunter].map((user) => user.userId)
231+
);
230232
await TeamsService.setTeamMembers(
231233
aang,
232234
avatarBenders.teamId,

src/backend/src/routes/reimbursement-requests.routes.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,6 @@ reimbursementRequestsRouter.post(
8686
reimbursementRequestsRouter.post(
8787
'/vendors/create',
8888
nonEmptyString(body('name')),
89-
body('allowedRefundSources').isArray(),
90-
isAccount(body('allowedRefundSources.*')),
9189
validateInputs,
9290
ReimbursementRequestController.createVendor
9391
);

src/backend/src/services/reimbursement-requests.services.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from 'shared';
2222
import prisma from '../prisma/prisma';
2323
import {
24+
isUserLeadOrHeadOfFinanceTeam,
2425
removeDeletedReceiptPictures,
2526
updateReimbursementProducts,
2627
validateReimbursementProducts,
@@ -451,7 +452,12 @@ export default class ReimbursementRequestService {
451452
* @returns the created vendor
452453
*/
453454
static async createVendor(submitter: User, name: string) {
454-
if (!isAdmin(submitter.role)) throw new AccessDeniedAdminOnlyException('create vendors');
455+
const failedAuthorizationException = new AccessDeniedException(
456+
'Only admins, finance leads, and finance heads can create vendors.'
457+
);
458+
459+
const isAuthorized = isAdmin(submitter.role) || (await isUserLeadOrHeadOfFinanceTeam(submitter));
460+
if (!isAuthorized) throw failedAuthorizationException;
455461

456462
const vendor = await prisma.vendor.create({
457463
data: {

src/backend/src/transformers/auth-user.transformer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { Prisma } from '@prisma/client';
22
import { AuthenticatedUser } from 'shared';
33
import authUserQueryArgs from '../prisma-query-args/auth-user.query-args';
4-
import { isAuthUserHeadOfFinance, isAuthUserOnFinance } from '../utils/reimbursement-requests.utils';
4+
import {
5+
isAuthUserHeadOfFinance,
6+
isAuthUserAtLeastLeadForFinance,
7+
isAuthUserOnFinance
8+
} from '../utils/reimbursement-requests.utils';
59

610
const authenticatedUserTransformer = (user: Prisma.UserGetPayload<typeof authUserQueryArgs>): AuthenticatedUser => {
711
return {
@@ -16,6 +20,7 @@ const authenticatedUserTransformer = (user: Prisma.UserGetPayload<typeof authUse
1620
favoritedProjectsId: user.favoriteProjects.map((project) => project.projectId),
1721
isFinance: isAuthUserOnFinance(user),
1822
isHeadOfFinance: isAuthUserHeadOfFinance(user),
23+
isAtLeastFinanceLead: isAuthUserAtLeastLeadForFinance(user),
1924
changeRequestsToReviewId: user.changeRequestsToReview.map((changeRequest) => changeRequest.crId)
2025
};
2126
};

src/backend/src/utils/reimbursement-requests.utils.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,17 +202,70 @@ const createNewProducts = async (products: ReimbursementProductCreateArgs[], rei
202202
}
203203
};
204204

205+
/**
206+
* Validates that the given user is on the finance team.
207+
*
208+
* @param user The user to validate.
209+
* @throws {AccessDeniedException} Fails validation when user is not on the
210+
* finance team.
211+
*/
205212
export const validateUserIsPartOfFinanceTeam = async (user: User) => {
213+
const isUserAuthorized = await isUserOnFinanceTeam(user);
214+
215+
if (!isUserAuthorized) {
216+
throw new AccessDeniedException(`You are not a member of the finance team!`);
217+
}
218+
};
219+
220+
/**
221+
* Determines if a user is part of the finance team.
222+
*
223+
* To be used for Prisma input validation of a plain User, as opposed to
224+
* <code>isAuthUserOnFinance</code>, which uses the additional fields
225+
* produced by authUserQueryArgs that are not in the User type by default.
226+
*
227+
* @param user the user to authenticate
228+
* @returns whether the user is on the finance team
229+
* @throws {HttpException} if finance team not found in database
230+
*/
231+
export const isUserOnFinanceTeam = async (user: User): Promise<boolean> => {
232+
if (!process.env.FINANCE_TEAM_ID) {
233+
throw new Error('FINANCE_TEAM_ID not in env');
234+
}
235+
206236
const financeTeam = await prisma.team.findUnique({
207237
where: { teamId: process.env.FINANCE_TEAM_ID },
208238
include: { head: true, leads: true, members: true }
209239
});
210240

211241
if (!financeTeam) throw new HttpException(500, 'Finance team does not exist!');
242+
return isUserOnTeam(financeTeam, user);
243+
};
212244

213-
if (!isUserOnTeam(financeTeam, user)) {
214-
throw new AccessDeniedException(`You are not a member of the finance team!`);
245+
/**
246+
* Determines if a user is lead or head of the finance team.
247+
*
248+
* To be used for Prisma input validation of a plain User, as opposed to
249+
* <code>isAuthUserAtLeastLeadForFinance</code>, which uses the additional fields
250+
* produced by authUserQueryArgs that are not in the User type by default.
251+
*
252+
* @param user the user to authenticate
253+
* @returns whether the user is lead or head of the finance team
254+
* @throws {HttpException} if finance team not found in database
255+
*/
256+
export const isUserLeadOrHeadOfFinanceTeam = async (user: User): Promise<boolean> => {
257+
if (!process.env.FINANCE_TEAM_ID) {
258+
throw new Error('FINANCE_TEAM_ID not in env');
215259
}
260+
261+
const financeTeam = await prisma.team.findUnique({
262+
where: { teamId: process.env.FINANCE_TEAM_ID },
263+
include: { head: true, leads: true }
264+
});
265+
266+
if (!financeTeam) throw new HttpException(500, 'Finance team does not exist!');
267+
268+
return user.userId === financeTeam.headId || financeTeam.leads.map((u) => u.userId).includes(user.userId);
216269
};
217270

218271
export const isAuthUserOnFinance = (user: Prisma.UserGetPayload<typeof authUserQueryArgs>) => {
@@ -226,6 +279,18 @@ export const isAuthUserOnFinance = (user: Prisma.UserGetPayload<typeof authUserQ
226279
);
227280
};
228281

282+
/**
283+
* Determines if the user is a finance lead or head.
284+
* @param user the user to check
285+
* @returns Whether they are a finance lead.
286+
*/
287+
export const isAuthUserAtLeastLeadForFinance = (user: Prisma.UserGetPayload<typeof authUserQueryArgs>) => {
288+
if (!process.env.FINANCE_TEAM_ID) return false;
289+
const financeTeamId = process.env.FINANCE_TEAM_ID;
290+
const { teamAsHead, teamsAsLead } = user;
291+
return teamAsHead?.teamId === financeTeamId || isTeamIdInList(financeTeamId, teamsAsLead);
292+
};
293+
229294
export const isAuthUserHeadOfFinance = (user: Prisma.UserGetPayload<typeof authUserQueryArgs>) => {
230295
return user.teamAsHead?.teamId === process.env.FINANCE_TEAM_ID;
231296
};

src/backend/tests/reimbursement-requests.test.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,24 @@ import {
2424
prismaReimbursementStatus,
2525
sharedGiveMeMyMoney
2626
} from './test-data/reimbursement-requests.test-data';
27-
import { alfred, batman, flash, sharedBatman, superman, wonderwoman, theVisitor } from './test-data/users.test-data';
27+
import {
28+
alfred,
29+
batman,
30+
flash,
31+
sharedBatman,
32+
superman,
33+
wonderwoman,
34+
theVisitor,
35+
aquaman,
36+
greenlantern
37+
} from './test-data/users.test-data';
2838
import reimbursementRequestQueryArgs from '../src/prisma-query-args/reimbursement-requests.query-args';
2939
import { Prisma, Reimbursement_Status_Type } from '@prisma/client';
3040
import {
3141
reimbursementRequestTransformer,
3242
reimbursementTransformer
3343
} from '../src/transformers/reimbursement-requests.transformer';
34-
import { prismaTeam1 } from './test-data/teams.test-data';
35-
import { justiceLeague, primsaTeam2 } from './test-data/teams.test-data';
44+
import { justiceLeague, prismaTeam1, primsaTeam2 } from './test-data/teams.test-data';
3645

3746
describe('Reimbursement Requests', () => {
3847
beforeEach(() => {});
@@ -42,6 +51,12 @@ describe('Reimbursement Requests', () => {
4251
});
4352

4453
describe('Vendor Tests', () => {
54+
beforeAll(() => {
55+
// Circular Dependency Check
56+
expect(prismaTeam1.head).toBeDefined();
57+
expect(primsaTeam2.head).toBeDefined();
58+
});
59+
4560
test('Get all vendors works', async () => {
4661
vi.spyOn(prisma.vendor, 'findMany').mockResolvedValue([]);
4762

@@ -51,13 +66,39 @@ describe('Reimbursement Requests', () => {
5166
expect(res).toStrictEqual([]);
5267
});
5368

54-
test('Create Vendor throws error if user is not admin', async () => {
55-
await expect(ReimbursementRequestService.createVendor(wonderwoman, 'HOLA BUDDY')).rejects.toThrow(
56-
new AccessDeniedAdminOnlyException('create vendors')
69+
test('Create Vendor throws error if user is not admin or finance lead', async () => {
70+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
71+
await expect(ReimbursementRequestService.createVendor(aquaman, 'HOLA BUDDY')).rejects.toThrow(
72+
new AccessDeniedException('Only admins, finance leads, and finance heads can create vendors.')
73+
);
74+
});
75+
76+
test('Create Vendor works for finance leads', async () => {
77+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
78+
vi.spyOn(prisma.vendor, 'create').mockResolvedValue(PopEyes);
79+
await expect(ReimbursementRequestService.createVendor(wonderwoman, 'HOLA BUDDY')).resolves.not.toThrow(
80+
new AccessDeniedException('Only admins, finance leads, and finance heads can create vendors.')
81+
);
82+
});
83+
84+
test('Create Vendor works for finance head', async () => {
85+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue({ ...primsaTeam2, headId: 5 });
86+
vi.spyOn(prisma.vendor, 'create').mockResolvedValue(PopEyes);
87+
await expect(ReimbursementRequestService.createVendor(greenlantern, 'HOLA BUDDY')).resolves.not.toThrow(
88+
new AccessDeniedException('Only admins, finance leads, and finance heads can create vendors.')
89+
);
90+
});
91+
92+
test('Create Vendor works for admin', async () => {
93+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(primsaTeam2);
94+
vi.spyOn(prisma.vendor, 'create').mockResolvedValue(PopEyes);
95+
await expect(ReimbursementRequestService.createVendor(flash, 'HOLA BUDDY')).resolves.not.toThrow(
96+
new AccessDeniedException('Only admins, finance leads, and finance heads can create vendors.')
5797
);
5898
});
5999

60100
test('Create Vendor Successfully returns vendor Id', async () => {
101+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValueOnce(prismaTeam1);
61102
vi.spyOn(prisma.vendor, 'create').mockResolvedValue(PopEyes);
62103

63104
const vendor = await ReimbursementRequestService.createVendor(batman, 'HOLA BUDDY');

src/backend/tests/test-data/users.test-data.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Role as PrismaRole, Theme, User as PrismaUser, User_Settings, User_Secure_Settings, Team } from '@prisma/client';
22
import { User as SharedUser } from 'shared';
3-
import { prismaTeam1 } from './teams.test-data';
43

54
export const batman: PrismaUser = {
65
userId: 1,
@@ -114,7 +113,9 @@ export const alfred: PrismaUser & { teamsAsMember: Team[]; teamsAsLead: Team[] }
114113
emailId: 'butler',
115114
role: PrismaRole.APP_ADMIN,
116115
googleAuthId: 'u',
117-
teamsAsMember: [prismaTeam1],
116+
// Do NOT put a team here! This will create a circular dependency that breaks tests.
117+
// Do this instead: { ...alfred, teamsAsMember: [<your team>]}
118+
teamsAsMember: [],
118119
teamsAsLead: []
119120
};
120121

src/frontend/src/layouts/NavTopBar/NavUserMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import ListItemText from '@mui/material/ListItemText';
1717
import ListItemIcon from '@mui/material/ListItemIcon';
1818
import SettingsIcon from '@mui/icons-material/Settings';
1919
import LogoutIcon from '@mui/icons-material/Logout';
20-
import { isHead } from 'shared';
20+
import { canAccessAdminTools } from '../../utils/users';
2121

2222
const NavUserMenu: React.FC = () => {
2323
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -115,7 +115,7 @@ const NavUserMenu: React.FC = () => {
115115
</ListItemIcon>
116116
<ListItemText>Settings</ListItemText>
117117
</MenuItem>
118-
{isHead(auth.user?.role) && <AdminTools />}
118+
{canAccessAdminTools(auth.user) && <AdminTools />}
119119
{import.meta.env.MODE === 'development' ? <DevLogout /> : <ProdLogout />}
120120
</Menu>
121121
</>

src/frontend/src/pages/AdminToolsPage/AdminTools.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { Redirect, Route, Switch } from 'react-router-dom';
2-
import { isHead } from 'shared';
32
import LoadingIndicator from '../../components/LoadingIndicator';
43
import { useAuth } from '../../hooks/auth.hooks';
54
import { routes } from '../../utils/routes';
65
import AdminToolsPage from './AdminToolsPage';
6+
import { canAccessAdminTools } from '../../utils/users';
77

88
const AdminTools: React.FC = () => {
99
const auth = useAuth();
1010

1111
if (!auth.user) return <LoadingIndicator />;
1212

13-
if (!isHead(auth.user.role)) {
13+
if (!canAccessAdminTools(auth.user)) {
1414
return (
1515
<Redirect
1616
to={{

0 commit comments

Comments
 (0)