From 52480d06f58d1acb16b99cbaa1cd124aa2a3a406 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 13:28:49 -0400 Subject: [PATCH 01/10] #4384 remove rules tab from guest view --- .../src/controllers/rules.controllers.ts | 13 +- src/backend/src/services/rules.services.ts | 65 +++++++- src/backend/tests/unit/rule.test.ts | 144 +++++++++++------- src/frontend/src/layouts/Sidebar/Sidebar.tsx | 2 +- src/frontend/src/pages/RulesPage/Rules.tsx | 16 +- 5 files changed, 170 insertions(+), 70 deletions(-) diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 12779b980f..5f1b1b2e01 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -124,7 +124,7 @@ export default class RulesController { static async getAllRulesetTypes(req: Request, res: Response, next: NextFunction) { try { - const rulesets = await RulesService.getAllRulesetTypes(req.organization, req.currentCar?.carId); + const rulesets = await RulesService.getAllRulesetTypes(req.currentUser, req.organization, req.currentCar?.carId); res.status(200).json(rulesets); } catch (error: unknown) { next(error); @@ -135,6 +135,7 @@ export default class RulesController { try { const { rulesetTypeId } = req.params as Record; const rulesets = await RulesService.getRulesetsByRulesetType( + req.currentUser, rulesetTypeId, req.organization.organizationId, req.currentCar?.carId @@ -149,6 +150,7 @@ export default class RulesController { try { const { rulesetTypeId } = req.params as Record; const rulesetType = await RulesService.getRulesetType( + req.currentUser, rulesetTypeId, req.organization.organizationId, req.currentCar?.carId @@ -265,7 +267,7 @@ export default class RulesController { static async getChildRules(req: Request, res: Response, next: NextFunction) { try { const { ruleId: parentRuleId } = req.params as Record; - const childrenRules: Rule[] = await RulesService.getChildRules(parentRuleId, req.organization); + const childrenRules: Rule[] = await RulesService.getChildRules(req.currentUser, parentRuleId, req.organization); res.status(200).json(childrenRules); } catch (error: unknown) { @@ -278,6 +280,7 @@ export default class RulesController { const { rulesetId, projectId } = req.params as Record; const rules = await RulesService.getUnassignedRulesForProjectRuleset( + req.currentUser, rulesetId, projectId, req.organization.organizationId @@ -292,7 +295,7 @@ export default class RulesController { try { const { rulesetId, projectId } = req.params as Record; - const projectRules = await RulesService.getProjectRules(rulesetId, projectId, req.organization); + const projectRules = await RulesService.getProjectRules(req.currentUser, rulesetId, projectId, req.organization); res.status(200).json(projectRules); } catch (error: unknown) { @@ -303,7 +306,7 @@ export default class RulesController { static async getTopLevelRules(req: Request, res: Response, next: NextFunction) { try { const { rulesetId } = req.params as Record; - const rules = await RulesService.getTopLevelRules(rulesetId, req.organization.organizationId); + const rules = await RulesService.getTopLevelRules(req.currentUser, rulesetId, req.organization.organizationId); res.status(200).json(rules); } catch (error: unknown) { next(error); @@ -313,7 +316,7 @@ export default class RulesController { static async getAllRulesForRuleset(req: Request, res: Response, next: NextFunction) { try { const { rulesetId } = req.params as Record; - const rules = await RulesService.getAllRulesForRuleset(rulesetId, req.organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(req.currentUser, rulesetId, req.organization.organizationId); res.status(200).json(rules); } catch (error: unknown) { next(error); diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 6207b313e9..8470d26817 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -722,7 +722,11 @@ export default class RulesService { return rulesetTransformer(deletedRuleset); } - static async getAllRulesetTypes(organization: Organization, carId?: string): Promise { + static async getAllRulesetTypes(user: User, organization: Organization, carId?: string): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view ruleset types'); + } + const rulesets = await prisma.ruleset_Type.findMany({ where: { organizationId: organization.organizationId, @@ -735,12 +739,17 @@ export default class RulesService { /** * Gets a ruleset type for a given ruleset type ID + * @param user the user requesting the ruleset type * @param rulesetTypeId id of ruleset type * @param organizationId id of organization * @param carId optional id of the car to scope revision file counts to * @returns ruleset type associated with provided ruleset type ID */ - static async getRulesetType(rulesetTypeId: string, organizationId: string, carId?: string): Promise { + static async getRulesetType(user: User, rulesetTypeId: string, organizationId: string, carId?: string): Promise { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view ruleset types'); + } + const rulesetType = await prisma.ruleset_Type.findUnique({ where: { rulesetTypeId, @@ -763,12 +772,22 @@ export default class RulesService { /** * Gets rulesets for a given ruleset type + * @param user the user requesting the rulesets * @param rulesetTypeId id of ruleset type * @param organizationId id of organization * @param carId optional id of the car to filter rulesets by * @returns rulesets associated with provided ruleset type */ - static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string, carId?: string): Promise { + static async getRulesetsByRulesetType( + user: User, + rulesetTypeId: string, + organizationId: string, + carId?: string + ): Promise { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rulesets'); + } + const rulesets = await prisma.ruleset.findMany({ where: { rulesetTypeId, @@ -1178,11 +1197,16 @@ export default class RulesService { /** * Gets all subrules of a specific rule. + * @param user the user requesting the child rules * @param ruleId the ID of the parent rule * @param organization the organization the rule belongs to * @returns an array of all child rules (the Rule object) */ - static async getChildRules(ruleId: string, organization: Organization): Promise { + static async getChildRules(user: User, ruleId: string, organization: Organization): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rules'); + } + // Verify the parent rule exists and belongs to the organization const parentRule = await prisma.rule.findUnique({ where: { ruleId }, @@ -1226,12 +1250,17 @@ export default class RulesService { /** * Gets rules assignable to a project that are not already assigned to it. * A project can belong to multiple teams, so rules from all of its teams are shown. + * @param user the user requesting the unassigned rules * @param rulesetId ruleset the rules are in * @param projectId the project the rules would be assigned to * @param organizationId the organization id * @returns the rules on one of the project's teams that are not already actively assigned to this project */ - static async getUnassignedRulesForProjectRuleset(rulesetId: string, projectId: string, organizationId: string) { + static async getUnassignedRulesForProjectRuleset(user: User, rulesetId: string, projectId: string, organizationId: string) { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view unassigned rules'); + } + const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, select: { @@ -1300,12 +1329,22 @@ export default class RulesService { /** * Gets all rules associated with a specific project and ruleset + * @param user the user requesting the project rules * @param rulesetId the id of the ruleset * @param projectId the id of the project * @param organization the organization the project and ruleset belong to * @returns Array of ProjectRule objects */ - static async getProjectRules(rulesetId: string, projectId: string, organization: Organization): Promise { + static async getProjectRules( + user: User, + rulesetId: string, + projectId: string, + organization: Organization + ): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view project rules'); + } + const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, include: { @@ -1366,10 +1405,15 @@ export default class RulesService { /** * Gets all rules with no parent id + * @param user the user requesting the top-level rules * @param rulesetId id of ruleset * @returns an array of rules with no parent Id */ - static async getTopLevelRules(rulesetId: string, organizationId: string) { + static async getTopLevelRules(user: User, rulesetId: string, organizationId: string) { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rules'); + } + const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, select: { @@ -1409,11 +1453,16 @@ export default class RulesService { /** * Gets every rule in a ruleset in a single query instead of walking it level by level. + * @param user the user requesting the rules * @param rulesetId id of ruleset * @param organizationId the organization the ruleset belongs to * @returns a flat array of every rule in the ruleset */ - static async getAllRulesForRuleset(rulesetId: string, organizationId: string): Promise { + static async getAllRulesForRuleset(user: User, rulesetId: string, organizationId: string): Promise { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rules'); + } + const ruleset = await prisma.ruleset.findUnique({ where: { rulesetId }, select: { diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index ad8fa3e458..7b2ec59f59 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -409,7 +409,7 @@ describe('Create Rules Tests', () => { await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId); - const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); expect(assignedRuleIds).toHaveLength(3); // grandchild, child, topLevelRule expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId, grandchild.ruleId])); @@ -444,7 +444,7 @@ describe('Create Rules Tests', () => { // adding sibling must not error or duplicate the already-present parent/root rules await RulesService.createProjectRule(aquaman, organization, grandchild2.ruleId, project.projectId); - const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); expect(assignedRuleIds).toHaveLength(4); // grandchild1, grandchild2, child, topLevelRule expect(assignedRuleIds).toEqual( @@ -474,7 +474,7 @@ describe('Create Rules Tests', () => { await RulesService.createProjectRule(aquaman, organization, child.ruleId, project.projectId); - const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); expect(assignedRuleIds).toHaveLength(2); // child and topLevelRule, not grandchild expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId])); @@ -510,7 +510,7 @@ describe('Create Rules Tests', () => { ).rejects.toThrow(new DeletedException('Rule', child.ruleId)); // nothing should have been assigned (not the grandchild, the deleted parent, or the root) - const projectRules = await RulesService.getProjectRules(rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); expect(projectRules).toHaveLength(0); const grandchildProjectRule = await prisma.project_Rule.findUnique({ @@ -576,7 +576,7 @@ describe('Create Rules Tests', () => { describe('Get rulesets by ruleset type', () => { it('Successful get rulesets by ruleset types', async () => { - const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + const rulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId); expect(rulesets.length).toBe(1); expect(rulesets[0].name).toBe('2025 FSAE Rules'); expect(rulesets[0].active).toBeTruthy(); @@ -591,7 +591,7 @@ describe('Create Rules Tests', () => { }); await RulesService.deleteRuleset(rulesetId, batman.userId, orgId); - const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + const rulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId); expect(rulesets.length).toBe(0); }); @@ -606,7 +606,7 @@ describe('Create Rules Tests', () => { createdBy: { connect: { userId: batman.userId } } } }); - const rulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + const rulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId); expect(rulesets.length).toBe(2); expect(rulesets[0].name).toBe('2025 FSAE Rules2'); expect(rulesets[1].name).toBe('2025 FSAE Rules'); @@ -639,15 +639,15 @@ describe('Create Rules Tests', () => { }); // 2 total rulesets for this type - const allRulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + const allRulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId); expect(allRulesets.length).toBe(2); // 1 ruleset when filtered to the original car - const originalCarRulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId, carId); + const originalCarRulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId, carId); expect(originalCarRulesets.length).toBe(1); expect(originalCarRulesets[0].rulesetId).toBe(rulesetId); - const otherCarRulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId, otherCar.carId); + const otherCarRulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId, otherCar.carId); // 1 ruleset when filtered to the other car expect(otherCarRulesets.length).toBe(1); @@ -660,7 +660,7 @@ describe('Create Rules Tests', () => { const parentRule = await RulesService.createRule(batman, 'T.1', 'Parent Rule', rulesetId, organization); await RulesService.createRule(batman, 'T.1.1', 'Child Rule 1', rulesetId, organization, parentRule.ruleId); await RulesService.createRule(batman, 'T.1.2', 'Child Rule 2', rulesetId, organization, parentRule.ruleId); - const childRules = await RulesService.getChildRules(parentRule.ruleId, organization); + const childRules = await RulesService.getChildRules(aquaman, parentRule.ruleId, organization); expect(childRules.length).toBe(2); expect(childRules[0].ruleCode).toBe('T.1.1'); expect(childRules[1].ruleCode).toBe('T.1.2'); @@ -677,24 +677,24 @@ describe('Create Rules Tests', () => { parentRule.ruleId ); await RulesService.deleteRule(childRule.ruleId, batman, organization); - const childRules = await RulesService.getChildRules(parentRule.ruleId, organization); + const childRules = await RulesService.getChildRules(aquaman, parentRule.ruleId, organization); expect(childRules.length).toBe(0); }); it('Successfully gets child rules after adding child rule', async () => { const parentRule = await RulesService.createRule(batman, 'T.3', 'Parent Rule', rulesetId, organization); await RulesService.createRule(batman, 'T.3.1', 'Child Rule 1', rulesetId, organization, parentRule.ruleId); - const childRulesAfterOne = await RulesService.getChildRules(parentRule.ruleId, organization); + const childRulesAfterOne = await RulesService.getChildRules(aquaman, parentRule.ruleId, organization); expect(childRulesAfterOne.length).toBe(1); await RulesService.createRule(batman, 'T.3.2', 'Child Rule 2', rulesetId, organization, parentRule.ruleId); - const childRulesAfterTwo = await RulesService.getChildRules(parentRule.ruleId, organization); + const childRulesAfterTwo = await RulesService.getChildRules(aquaman, parentRule.ruleId, organization); expect(childRulesAfterTwo.length).toBe(2); expect(childRulesAfterTwo[0].ruleCode).toBe('T.3.1'); expect(childRulesAfterTwo[1].ruleCode).toBe('T.3.2'); }); it('Fails if parent rule does not exist', async () => { - await expect(async () => await RulesService.getChildRules('fake-rule-id', organization)).rejects.toThrow( + await expect(async () => await RulesService.getChildRules(aquaman, 'fake-rule-id', organization)).rejects.toThrow( new NotFoundException('Rule', 'fake-rule-id') ); }); @@ -702,7 +702,7 @@ describe('Create Rules Tests', () => { it('Fails if parent rule is deleted', async () => { const parentRule = await RulesService.createRule(batman, 'T.4', 'Parent Rule', rulesetId, organization); await RulesService.deleteRule(parentRule.ruleId, batman, organization); - await expect(async () => await RulesService.getChildRules(parentRule.ruleId, organization)).rejects.toThrow( + await expect(async () => await RulesService.getChildRules(aquaman, parentRule.ruleId, organization)).rejects.toThrow( new DeletedException('Rule', parentRule.ruleId) ); }); @@ -764,7 +764,7 @@ describe('Create Rules Tests', () => { createdByUserId: otherBatman.userId } }); - await expect(async () => await RulesService.getChildRules(otherParentRule.ruleId, organization)).rejects.toThrow( + await expect(async () => await RulesService.getChildRules(aquaman, otherParentRule.ruleId, organization)).rejects.toThrow( new InvalidOrganizationException('Rule') ); }); @@ -1494,7 +1494,7 @@ describe('Rule Tests', () => { describe('Get all ruleset types', () => { it('Successful get all ruleset types', async () => { - const rulesetTypes = await RulesService.getAllRulesetTypes(organization); + const rulesetTypes = await RulesService.getAllRulesetTypes(admin, organization); expect(rulesetTypes.length).toEqual(2); expect(rulesetTypes[0].name).toEqual('FSAE'); expect(rulesetTypes[1].name).toEqual('Ruleset Type with no Active Rulesets or Anything'); @@ -1507,7 +1507,7 @@ describe('Rule Tests', () => { organizationId: orgId } }); - const rulesetTypes = await RulesService.getAllRulesetTypes(organization); + const rulesetTypes = await RulesService.getAllRulesetTypes(admin, organization); expect(rulesetTypes.length).toEqual(3); expect(rulesetTypes[2].name).toEqual('FSAE2'); }); @@ -1520,7 +1520,7 @@ describe('Rule Tests', () => { deletedByUserId: admin.userId } }); - const rulesetTypes = await RulesService.getAllRulesetTypes(organization); + const rulesetTypes = await RulesService.getAllRulesetTypes(admin, organization); expect(rulesetTypes.length).toEqual(1); }); }); @@ -1774,13 +1774,13 @@ describe('Rule Tests', () => { }); it('Successfully deletes the ruleset type', async () => { - let rulesetTypes = await RulesService.getAllRulesetTypes(organization); + let rulesetTypes = await RulesService.getAllRulesetTypes(admin, organization); expect(rulesetTypes.length).toEqual(2); const appAdmin = await createTestUser(batmanAppAdmin, orgId); const result = await RulesService.deleteRulesetType(appAdmin, fsaeRulesetType.rulesetTypeId, organization); - rulesetTypes = await RulesService.getAllRulesetTypes(organization); + rulesetTypes = await RulesService.getAllRulesetTypes(admin, organization); expect(rulesetTypes.length).toEqual(1); @@ -1801,10 +1801,10 @@ describe('Rule Tests', () => { } }); - let rulesets = await RulesService.getRulesetsByRulesetType(fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); + let rulesets = await RulesService.getRulesetsByRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); expect(rulesets.length).toBe(1); await RulesService.deleteRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, organization); - rulesets = await RulesService.getRulesetsByRulesetType(fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); + rulesets = await RulesService.getRulesetsByRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); expect(rulesets.length).toBe(0); }); }); @@ -1830,7 +1830,7 @@ describe('Rule Tests', () => { } }); await expect( - RulesService.getUnassignedRulesForProjectRuleset( + RulesService.getUnassignedRulesForProjectRuleset(admin, otherRuleset.rulesetId, project.projectId, organization.organizationId @@ -1842,7 +1842,7 @@ describe('Rule Tests', () => { const { ruleset1 } = await setupRules(car); const otherOrgProject = await createTestProject(admin, otherOrg.organizationId); await expect( - RulesService.getUnassignedRulesForProjectRuleset( + RulesService.getUnassignedRulesForProjectRuleset(admin, ruleset1.rulesetId, otherOrgProject.projectId, organization.organizationId @@ -1851,7 +1851,7 @@ describe('Rule Tests', () => { }); it('fails if ruleset does not exist', async () => { await expect( - RulesService.getUnassignedRulesForProjectRuleset( + RulesService.getUnassignedRulesForProjectRuleset(admin, 'nonexistent-ruleset-id', project.projectId, organization.organizationId @@ -1862,7 +1862,7 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); await expect( - RulesService.getUnassignedRulesForProjectRuleset(ruleset1.rulesetId, 'fake-project-id', organization.organizationId) + RulesService.getUnassignedRulesForProjectRuleset(admin, ruleset1.rulesetId, 'fake-project-id', organization.organizationId) ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); }); it("successfully returns rules on the project's teams that are not already assigned to the project", async () => { @@ -1891,7 +1891,7 @@ describe('Rule Tests', () => { createdByUserId: admin.userId } }); - const rules = await RulesService.getUnassignedRulesForProjectRuleset( + const rules = await RulesService.getUnassignedRulesForProjectRuleset(admin, ruleset1.rulesetId, project.projectId, organization.organizationId @@ -1925,7 +1925,7 @@ describe('Rule Tests', () => { await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); await RulesService.toggleRuleTeam(leafRule1.ruleId, secondTeam.teamId, admin, organization); - const rules = await RulesService.getUnassignedRulesForProjectRuleset( + const rules = await RulesService.getUnassignedRulesForProjectRuleset(admin, ruleset1.rulesetId, project.projectId, organization.organizationId @@ -1939,7 +1939,7 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); - const rules = await RulesService.getUnassignedRulesForProjectRuleset( + const rules = await RulesService.getUnassignedRulesForProjectRuleset(admin, ruleset1.rulesetId, project.projectId, organization.organizationId @@ -1956,7 +1956,7 @@ describe('Rule Tests', () => { await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - const projectRules = await RulesService.getProjectRules(topLevelRule.rulesetId, projectRule.projectId, organization); + const projectRules = await RulesService.getProjectRules(admin, topLevelRule.rulesetId, projectRule.projectId, organization); expect(projectRules.length).toBe(1); expect(projectRules[0].projectRuleId).toBe(projectRule.projectRuleId); @@ -1967,7 +1967,7 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const projectRules = await RulesService.getProjectRules(topLevelRule.rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(admin, topLevelRule.rulesetId, project.projectId, organization); expect(projectRules.length).toBe(0); }); @@ -1984,13 +1984,13 @@ describe('Rule Tests', () => { }); await expect( - async () => await RulesService.getProjectRules(topLevelRule.rulesetId, project.projectId, organization) + async () => await RulesService.getProjectRules(admin, topLevelRule.rulesetId, project.projectId, organization) ).rejects.toThrow(new DeletedException('Project', project.projectId)); }); it('Get project rules fails if ruleset does not exist', async () => { await expect( - async () => await RulesService.getProjectRules('fake-ruleset-id', project.projectId, organization) + async () => await RulesService.getProjectRules(admin, 'fake-ruleset-id', project.projectId, organization) ).rejects.toThrow(new NotFoundException('Ruleset', 'fake-ruleset-id')); }); @@ -1999,7 +1999,7 @@ describe('Rule Tests', () => { const { topLevelRule } = await setupRules(car); await expect( - async () => await RulesService.getProjectRules(topLevelRule.rulesetId, 'fake-project-id', organization) + async () => await RulesService.getProjectRules(admin, topLevelRule.rulesetId, 'fake-project-id', organization) ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); }); @@ -2012,17 +2012,26 @@ describe('Rule Tests', () => { }); await expect( - async () => await RulesService.getProjectRules(topLevelRule.rulesetId, project.projectId, organization) + async () => await RulesService.getProjectRules(admin, topLevelRule.rulesetId, project.projectId, organization) ).rejects.toThrow(new DeletedException('Ruleset', topLevelRule.rulesetId)); }); }); describe('Get Top Level Rules', () => { + it('Fails if user is a guest', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect(RulesService.getTopLevelRules(guest, ruleset1.rulesetId, organization.organizationId)).rejects.toThrow( + new AccessDeniedGuestException('view rules') + ); + }); + it('Successful get all rules with no parent id', async () => { const car = await createUniqueCar(orgId); const { ruleset1, topLevelRule } = await setupRules(car); - const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getTopLevelRules(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.length).toEqual(3); expect(rules.map((r) => r.ruleCode).sort()).toEqual(['A2', 'B2', 'T']); @@ -2043,7 +2052,7 @@ describe('Rule Tests', () => { } }); - const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getTopLevelRules(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.length).toEqual(4); expect(rules.map((r) => r.ruleCode).sort()).toEqual(['A', 'A2', 'B2', 'T']); @@ -2063,14 +2072,14 @@ describe('Rule Tests', () => { } }); - const rules = await RulesService.getTopLevelRules(ruleset.rulesetId, organization.organizationId); + const rules = await RulesService.getTopLevelRules(admin, ruleset.rulesetId, organization.organizationId); expect(rules.length).toEqual(0); }); it('Does not return child rules', async () => { const car = await createUniqueCar(orgId); const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); - const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getTopLevelRules(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.length).toEqual(3); expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)).toBeDefined(); @@ -2090,17 +2099,26 @@ describe('Rule Tests', () => { } }); - const rules = await RulesService.getTopLevelRules(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getTopLevelRules(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)).toBeUndefined(); }); }); describe('Get All Rules For Ruleset', () => { + it('Fails if user is a guest', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect( + RulesService.getAllRulesForRuleset(guest, ruleset1.rulesetId, organization.organizationId) + ).rejects.toThrow(new AccessDeniedGuestException('view rules')); + }); + it('Successfully gets every rule in a ruleset, including children', async () => { const car = await createUniqueCar(orgId); const { ruleset1, topLevelRule, leafRule1, leafRule2, referencedRule, referencingRule } = await setupRules(car); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.length).toEqual(5); const ruleIds = rules.map((r) => r.ruleId); @@ -2115,7 +2133,7 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); const topRule = rules.find((r) => r.ruleId === topLevelRule.ruleId); const leaf1 = rules.find((r) => r.ruleId === leafRule1.ruleId); @@ -2143,7 +2161,7 @@ describe('Rule Tests', () => { } }); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)).toBeDefined(); expect(rules.find((r) => r.ruleId === otherRule.ruleId)).toBeUndefined(); @@ -2158,7 +2176,7 @@ describe('Rule Tests', () => { data: { dateDeleted: new Date(), deletedByUserId: admin.userId } }); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); expect(rules.find((r) => r.ruleId === leafRule1.ruleId)).toBeUndefined(); }); @@ -2166,7 +2184,7 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); for (let i = 0; i < rules.length - 1; i++) { expect(rules[i].ruleCode <= rules[i + 1].ruleCode).toBe(true); @@ -2186,12 +2204,12 @@ describe('Rule Tests', () => { } }); - const rules = await RulesService.getAllRulesForRuleset(ruleset.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset.rulesetId, organization.organizationId); expect(rules.length).toEqual(0); }); it('Fails when ruleset does not exist', async () => { - await expect(RulesService.getAllRulesForRuleset('fake-ruleset-id', organization.organizationId)).rejects.toThrow( + await expect(RulesService.getAllRulesForRuleset(admin, 'fake-ruleset-id', organization.organizationId)).rejects.toThrow( new NotFoundException('Ruleset', 'fake-ruleset-id') ); }); @@ -2206,24 +2224,40 @@ describe('Rule Tests', () => { }); await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); - await expect(RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId)).rejects.toThrow( + await expect(RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId)).rejects.toThrow( new DeletedException('Ruleset', ruleset1.rulesetId) ); }); it('Fails if ruleset is in the wrong org', async () => { const car = await createUniqueCar(orgId); - const { ruleset1 } = await setupRules(car); + const otherOrgRulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'Other Org FHE', + createdByUserId: admin.userId, + organizationId: otherOrg.organizationId + } + }); + const otherRuleset = await prisma.ruleset.create({ + data: { + name: '2024', + fileId: 'other-fhe-2024-all-rules', + active: true, + rulesetTypeId: otherOrgRulesetType.rulesetTypeId, + carId: car.carId, + createdByUserId: admin.userId + } + }); - await expect(RulesService.getAllRulesForRuleset(ruleset1.rulesetId, otherOrg.organizationId)).rejects.toThrow( - InvalidOrganizationException - ); + await expect( + RulesService.getAllRulesForRuleset(admin, otherRuleset.rulesetId, organization.organizationId) + ).rejects.toThrow(InvalidOrganizationException); }); }); describe('Get Ruleset Type', () => { it('Successfully gets a ruleset type by ID', async () => { - const rulesetType = await RulesService.getRulesetType(fsaeRulesetType.rulesetTypeId, organization.organizationId); + const rulesetType = await RulesService.getRulesetType(admin, fsaeRulesetType.rulesetTypeId, organization.organizationId); expect(rulesetType).toBeDefined(); expect(rulesetType.rulesetTypeId).toBe(fsaeRulesetType.rulesetTypeId); expect(rulesetType.name).toBe(fsaeRulesetType.name); diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 735adb53ba..8105a23613 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -164,7 +164,7 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid icon: , route: routes.SPONSORS }, - { + !onGuestHomePage && { name: 'Rules', icon: , route: routes.RULES diff --git a/src/frontend/src/pages/RulesPage/Rules.tsx b/src/frontend/src/pages/RulesPage/Rules.tsx index 3ac570f131..6d44288c59 100644 --- a/src/frontend/src/pages/RulesPage/Rules.tsx +++ b/src/frontend/src/pages/RulesPage/Rules.tsx @@ -1,12 +1,26 @@ // switch route page for rules -import { Route, Switch } from 'react-router-dom'; +import { Redirect, Route, Switch } from 'react-router-dom'; +import { isGuest } from 'shared'; import { routes } from '../../utils/routes'; +import { useCurrentUser } from '../../hooks/users.hooks'; import RulesetTypePage from './RulesetTypePage'; import RulesetPage from './RulesetPage'; import RulesetEditPage from './RulesetEditPage'; import RulesetViewPage from './RulesetViewPage'; const RulesPage: React.FC = () => { + const user = useCurrentUser(); + + if (isGuest(user.role)) { + return ( + + ); + } + return ( From aca2c0a7604ed5151b5b7d305d41856853349325 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 14:50:55 -0400 Subject: [PATCH 02/10] #4384 remove unprivileged UI for members --- src/backend/src/services/rules.services.ts | 12 +-- src/backend/tests/unit/rule.test.ts | 25 +++-- .../ProjectRules/ProjectRulesTab.tsx | 100 +++++++++--------- .../src/pages/RulesPage/RulesetEditPage.tsx | 12 ++- .../src/pages/RulesPage/RulesetPage.tsx | 19 ++-- .../RulesPage/components/RulesetTable.tsx | 92 ++++++++-------- 6 files changed, 148 insertions(+), 112 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 8470d26817..bcfaa23736 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -135,9 +135,9 @@ export default class RulesService { referencedRuleIds: string[] = [], imageFileIds: string[] = [] ) { - // Check user has permission (members and above) - if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { - throw new AccessDeniedException('Only members and above can create rules'); + // Check user has permission (leadership and above) + if (!(await userHasPermission(user.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('Only leadership and above can create rules'); } // Verify ruleset exists and belongs to organization @@ -869,9 +869,9 @@ export default class RulesService { * */ static async toggleRuleTeam(ruleId: string, teamId: string, user: User, org: Organization) { - // Checks that the user is not a guest - if (!(await userHasPermission(user.userId, org.organizationId, notGuest))) { - throw new AccessDeniedGuestException('Toggle Rule Team'); + // Checks that the user is leadership and above + if (!(await userHasPermission(user.userId, org.organizationId, isLeadership))) { + throw new AccessDeniedException('Only leadership and above can assign rules to teams'); } // Checks that the rule exists and is not deleted diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 7b2ec59f59..e7c44863f9 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -165,7 +165,14 @@ describe('Create Rules Tests', () => { it('fails when guest tries to create a rule', async () => { await expect(RulesService.createRule(wonderwoman, 'T.1.1', 'Some rule', rulesetId, organization)).rejects.toThrow( - new AccessDeniedException('Only members and above can create rules') + new AccessDeniedException('Only leadership and above can create rules') + ); + }); + + it('fails when a member tries to create a rule', async () => { + const member = await createTestUser(financeMember, orgId); + await expect(RulesService.createRule(member, 'T.1.1', 'Some rule', rulesetId, organization)).rejects.toThrow( + new AccessDeniedException('Only leadership and above can create rules') ); }); @@ -266,10 +273,9 @@ describe('Create Rules Tests', () => { ).rejects.toThrow(new DeletedException('Referenced Rule', rule1.ruleId)); }); - it('allows members and above to create rules', async () => { - await RulesService.createRule(aquaman, 'T.1.1', 'Member created rule', rulesetId, organization); - await RulesService.createRule(aquaman, 'T.1.2', 'Leadership created rule', rulesetId, organization); - await RulesService.createRule(superman, 'T.1.3', 'Admin created rule', rulesetId, organization); + it('allows leadership and above to create rules', async () => { + await RulesService.createRule(aquaman, 'T.1.1', 'Leadership created rule', rulesetId, organization); + await RulesService.createRule(superman, 'T.1.2', 'Admin created rule', rulesetId, organization); }); describe('Create ruleset', () => { @@ -1613,7 +1619,14 @@ describe('Rule Tests', () => { const { topLevelRule } = await setupRules(car); await expect( async () => await RulesService.toggleRuleTeam(topLevelRule.ruleId, '', guest, organization) - ).rejects.toThrow(new AccessDeniedGuestException('Toggle Rule Team')); + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can assign rules to teams')); + }); + it('Fails if user is a member', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await expect( + async () => await RulesService.toggleRuleTeam(topLevelRule.ruleId, '', nonLeadership, organization) + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can assign rules to teams')); }); it('Fails if rule does not exist', async () => { await expect(async () => await RulesService.toggleRuleTeam('fake-rule-id', '', admin, organization)).rejects.toThrow( diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 41d033a874..eb198a07e9 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -19,7 +19,7 @@ import { IconButton, Tooltip } from '@mui/material'; -import { Project, ProjectRule, Rule } from 'shared'; +import { Project, ProjectRule, Rule, isLeadership } from 'shared'; import LoadingIndicator from '../../../../components/LoadingIndicator'; import ErrorPage from '../../../ErrorPage'; import RuleRow from '../../../RulesPage/RuleRow'; @@ -34,6 +34,7 @@ import { useSetRuleCompletion, useCreateProjectRule } from '../../../../hooks/rules.hooks'; +import { useCurrentUser } from '../../../../hooks/users.hooks'; import { useToast } from '../../../../hooks/toasts.hooks'; import { InfoOutlined } from '@mui/icons-material'; import { useHistory } from 'react-router-dom'; @@ -50,6 +51,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const toast = useToast(); const theme = useTheme(); const history = useHistory(); + const user = useCurrentUser(); // State for modals and popovers const [selectedRulesetTypeIndex, setSelectedRulesetTypeIndex] = useState(0); @@ -312,58 +314,60 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { }} > - - - {/* Assign Rules Tooltip */} - 0 - ? `Assign rules to the ${teamNames.join(', ')} team${ - teamNames.length === 1 ? '' : 's' - } to add them to this project` - : 'Add a team to this project to assign rules' - } - arrow - slotProps={{ tooltip: { sx: { textAlign: 'center' } } }} - > - e.stopPropagation()} - sx={{ - padding: '5px', - color: 'text.secondary' - }} + {isLeadership(user.role) && ( + + + {/* Assign Rules Tooltip */} + 0 + ? `Assign rules to the ${teamNames.join(', ')} team${ + teamNames.length === 1 ? '' : 's' + } to add them to this project` + : 'Add a team to this project to assign rules' + } + arrow + slotProps={{ tooltip: { sx: { textAlign: 'center' } } }} + > + e.stopPropagation()} + sx={{ + padding: '5px', + color: 'text.secondary' + }} + > + + + + {/* Assign Rules Button */} + + activeRuleset && + history.push( + `${routes.RULESET_EDIT.replace(':rulesetId', activeRuleset.rulesetId)}/assign-rules${ + teamId ? `?teamId=${teamId}` : '' + }` + ) + } > - - - - {/* Assign Rules Button */} + Assign Rules + + + {/* Add Rule Button */} - activeRuleset && - history.push( - `${routes.RULESET_EDIT.replace(':rulesetId', activeRuleset.rulesetId)}/assign-rules${ - teamId ? `?teamId=${teamId}` : '' - }` - ) - } + variant="contained" + sx={{ color: '#ededed' }} + onClick={() => setAddRuleModalOpen(true)} + disabled={teamNames.length === 0 || hasNoActiveRuleset} > - Assign Rules + Add Rule - {/* Add Rule Button */} - setAddRuleModalOpen(true)} - disabled={teamNames.length === 0 || hasNoActiveRuleset} - > - Add Rule - - + )} {/* Update Status Popover */} diff --git a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx index f75a9a0f93..ec72cc6cc8 100644 --- a/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx @@ -6,7 +6,7 @@ import { Box, Button, CircularProgress, Paper, Table, TableBody, TableContainer, TextField, useTheme } from '@mui/material'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useMemo, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { Redirect, useParams } from 'react-router-dom'; import PageLayout from '../../components/PageLayout'; import FullPageTabs from '../../components/FullPageTabs'; import { routes } from '../../utils/routes'; @@ -37,8 +37,9 @@ import { useFetchFullRuleTree } from '../../hooks/rules.hooks'; import { countRulesToDelete, compareRuleCodes } from '../../utils/rules.utils'; -import { Rule } from 'shared'; +import { Rule, isLeadership } from 'shared'; import { useToast } from '../../hooks/toasts.hooks'; +import { useCurrentUser } from '../../hooks/users.hooks'; import { useRuleTreeNavigation } from './useRuleTreeNavigation'; /** @@ -47,6 +48,7 @@ import { useRuleTreeNavigation } from './useRuleTreeNavigation'; */ const RulesetEditPage: React.FC = () => { const { rulesetId } = useParams<{ rulesetId: string; tabValue?: string }>(); //why tab value?? + const user = useCurrentUser(); const [tabValue, setTabValue] = useState(0); const defaultTab = 'edit-rules'; @@ -137,6 +139,12 @@ const RulesetEditPage: React.FC = () => { return ; } + // creating, editing, deleting, and assigning rules all require leadership and above + // if the user is not leadership, redirect them to the view page for this ruleset + if (!isLeadership(user.role)) { + return ; + } + const handleAddRuleSection = () => { setShowAddRuleSectionModal(true); }; diff --git a/src/frontend/src/pages/RulesPage/RulesetPage.tsx b/src/frontend/src/pages/RulesPage/RulesetPage.tsx index 5f89869cf8..b8da11e5af 100644 --- a/src/frontend/src/pages/RulesPage/RulesetPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -4,8 +4,10 @@ */ import { useParams } from 'react-router-dom'; import React from 'react'; +import { isLeadership } from 'shared'; import { useToast } from '../../hooks/toasts.hooks'; import { useCreateRuleset, useDeleteRuleset, useParseRuleset } from '../../hooks/rules.hooks'; +import { useCurrentUser } from '../../hooks/users.hooks'; import { NERButton } from '../../components/NERButton'; import AddNewFileModal from './components/AddNewFileModal'; import PageLayout from '../../components/PageLayout'; @@ -22,6 +24,7 @@ import ErrorPage from '../ErrorPage'; */ const RulesetPage: React.FC = () => { const { rulesetTypeId } = useParams<{ rulesetTypeId: string }>(); + const user = useCurrentUser(); const { mutateAsync: createRuleset } = useCreateRuleset(); const { mutateAsync: parseRuleset } = useParseRuleset(); @@ -121,13 +124,15 @@ const RulesetPage: React.FC = () => { }} > {/* Add New File Button */} - setAddFileModalShow(!AddFileModalShow)} - > - Add New File - + {isLeadership(user.role) && ( + setAddFileModalShow(!AddFileModalShow)} + > + Add New File + + )} setAddFileModalShow(false)} diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx index d494d3d2d6..7904dc6b38 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx @@ -23,7 +23,8 @@ import { useHistory, useParams } from 'react-router-dom'; import LoadingIndicator from '../../../components/LoadingIndicator'; import ErrorPage from '../../ErrorPage'; import { useDeleteRuleset, useRulesetsByType, useUpdateRuleset } from '../../../hooks/rules.hooks'; -import { Ruleset } from 'shared'; +import { useCurrentUser } from '../../../hooks/users.hooks'; +import { Ruleset, isLeadership } from 'shared'; import { routes } from '../../../utils/routes'; import { useToast } from '../../../hooks/toasts.hooks'; import { Delete } from '@mui/icons-material'; @@ -43,6 +44,7 @@ const RulesetTable: React.FC = () => { const { rulesetTypeId } = useParams(); const toast = useToast(); const history = useHistory(); + const user = useCurrentUser(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -192,27 +194,29 @@ const RulesetTable: React.FC = () => { /> - handleEditRuleset(ruleset.rulesetId)} - disabled={!hasRules(ruleset)} - sx={{ - backgroundColor: theme.palette.grey[800], - color: theme.palette.getContrastText(theme.palette.grey[600]), - '&:hover': { - backgroundColor: theme.palette.grey[700] - }, - marginRight: '10px', - padding: '4px', - lineHeight: 1, - borderRadius: '6px', - '&.Mui-disabled': { - backgroundColor: theme.palette.grey[900], - color: theme.palette.grey[600] - } - }} - > - Edit/Assign Rules - + {isLeadership(user.role) && ( + handleEditRuleset(ruleset.rulesetId)} + disabled={!hasRules(ruleset)} + sx={{ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[600]), + '&:hover': { + backgroundColor: theme.palette.grey[700] + }, + marginRight: '10px', + padding: '4px', + lineHeight: 1, + borderRadius: '6px', + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[900], + color: theme.palette.grey[600] + } + }} + > + Edit/Assign Rules + + )} handleViewRuleset(ruleset.rulesetId)} disabled={!hasRules(ruleset)} @@ -291,27 +295,29 @@ const RulesetTable: React.FC = () => { /> - handleEditRuleset(ruleset.rulesetId)} - disabled={!hasRules(ruleset)} - sx={{ - backgroundColor: theme.palette.grey[800], - color: theme.palette.getContrastText(theme.palette.grey[600]), - '&:hover': { - backgroundColor: theme.palette.grey[700] - }, - marginRight: '10px', - padding: '4px', - lineHeight: 1, - borderRadius: '6px', - '&.Mui-disabled': { - backgroundColor: theme.palette.grey[900], - color: theme.palette.grey[600] - } - }} - > - Edit/Assign Rules - + {isLeadership(user.role) && ( + handleEditRuleset(ruleset.rulesetId)} + disabled={!hasRules(ruleset)} + sx={{ + backgroundColor: theme.palette.grey[800], + color: theme.palette.getContrastText(theme.palette.grey[600]), + '&:hover': { + backgroundColor: theme.palette.grey[700] + }, + marginRight: '10px', + padding: '4px', + lineHeight: 1, + borderRadius: '6px', + '&.Mui-disabled': { + backgroundColor: theme.palette.grey[900], + color: theme.palette.grey[600] + } + }} + > + Edit/Assign Rules + + )} handleViewRuleset(ruleset.rulesetId)} disabled={!hasRules(ruleset)} From 47b4adcd3cdc150c9ed5324008769018b57896ba Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 16:38:13 -0400 Subject: [PATCH 03/10] #4384 lead + head can delete rulesets they created --- src/backend/src/services/rules.services.ts | 23 +++- src/backend/tests/unit/rule.test.ts | 130 +++++++++++++++++---- 2 files changed, 126 insertions(+), 27 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index bcfaa23736..3900b22584 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -704,10 +704,15 @@ export default class RulesService { static async deleteRuleset(rulesetId: string, deleterId: string, organizationId: string) { const ruleset = await RulesService.getRulesetWithQueryArgs(rulesetId); + // admins can delete any ruleset; leadership and heads can only delete a ruleset they created themselves + const isCreator = deleterId === ruleset.createdByUserId; const hasPermission = - (await userHasPermission(deleterId, organizationId, isAdmin)) || deleterId === ruleset.createdByUserId; + (await userHasPermission(deleterId, organizationId, isAdmin)) || + (isCreator && (await userHasPermission(deleterId, organizationId, isLeadership))); - if (!hasPermission) throw new AccessDeniedException('Only admins can delete a ruleset.'); + if (!hasPermission) { + throw new AccessDeniedException('You do not have permissions to delete this ruleset.'); + } if (ruleset.active) { throw new HttpException(400, 'Cannot delete an active ruleset. Please deactivate it first.'); @@ -745,7 +750,12 @@ export default class RulesService { * @param carId optional id of the car to scope revision file counts to * @returns ruleset type associated with provided ruleset type ID */ - static async getRulesetType(user: User, rulesetTypeId: string, organizationId: string, carId?: string): Promise { + static async getRulesetType( + user: User, + rulesetTypeId: string, + organizationId: string, + carId?: string + ): Promise { if (!(await userHasPermission(user.userId, organizationId, notGuest))) { throw new AccessDeniedGuestException('view ruleset types'); } @@ -1256,7 +1266,12 @@ export default class RulesService { * @param organizationId the organization id * @returns the rules on one of the project's teams that are not already actively assigned to this project */ - static async getUnassignedRulesForProjectRuleset(user: User, rulesetId: string, projectId: string, organizationId: string) { + static async getUnassignedRulesForProjectRuleset( + user: User, + rulesetId: string, + projectId: string, + organizationId: string + ) { if (!(await userHasPermission(user.userId, organizationId, notGuest))) { throw new AccessDeniedGuestException('view unassigned rules'); } diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index e7c44863f9..46168e1a99 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -649,11 +649,21 @@ describe('Create Rules Tests', () => { expect(allRulesets.length).toBe(2); // 1 ruleset when filtered to the original car - const originalCarRulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId, carId); + const originalCarRulesets = await RulesService.getRulesetsByRulesetType( + aquaman, + rulesetType.rulesetTypeId, + orgId, + carId + ); expect(originalCarRulesets.length).toBe(1); expect(originalCarRulesets[0].rulesetId).toBe(rulesetId); - const otherCarRulesets = await RulesService.getRulesetsByRulesetType(aquaman, rulesetType.rulesetTypeId, orgId, otherCar.carId); + const otherCarRulesets = await RulesService.getRulesetsByRulesetType( + aquaman, + rulesetType.rulesetTypeId, + orgId, + otherCar.carId + ); // 1 ruleset when filtered to the other car expect(otherCarRulesets.length).toBe(1); @@ -770,9 +780,9 @@ describe('Create Rules Tests', () => { createdByUserId: otherBatman.userId } }); - await expect(async () => await RulesService.getChildRules(aquaman, otherParentRule.ruleId, organization)).rejects.toThrow( - new InvalidOrganizationException('Rule') - ); + await expect( + async () => await RulesService.getChildRules(aquaman, otherParentRule.ruleId, organization) + ).rejects.toThrow(new InvalidOrganizationException('Rule')); }); }); describe('Update ruleset status', () => { @@ -1474,7 +1484,52 @@ describe('Rule Tests', () => { await expect( async () => await RulesService.deleteRuleset(ruleset1.rulesetId, nonLeadership.userId, organization.organizationId) - ).rejects.toThrow(new AccessDeniedException('Only admins can delete a ruleset.')); + ).rejects.toThrow( + new AccessDeniedException('Only admins, or leadership/heads who created this ruleset, can delete a ruleset.') + ); + }); + it('Delete ruleset succeeds if a leadership user created the ruleset', async () => { + const car = await createUniqueCar(orgId); + const leadershipUser = await createTestUser(aquamanLeadership, orgId); + const ruleset = await prisma.ruleset.create({ + data: { + name: 'Leadership Ruleset', + fileId: 'leadership-created-ruleset-file', + active: false, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: leadershipUser.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + const deleted = await RulesService.deleteRuleset( + ruleset.rulesetId, + leadershipUser.userId, + organization.organizationId + ); + + expect(deleted).toBeDefined(); + expect(deleted.rulesetId).toBe(ruleset.rulesetId); + }); + it('Delete ruleset fails if a leadership user did not create the ruleset', async () => { + const car = await createUniqueCar(orgId); + const leadershipUser = await createTestUser(aquamanLeadership, orgId); + const ruleset = await prisma.ruleset.create({ + data: { + name: 'Admin Created Ruleset', + fileId: 'admin-created-ruleset-file', + active: false, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + await expect( + async () => await RulesService.deleteRuleset(ruleset.rulesetId, leadershipUser.userId, organization.organizationId) + ).rejects.toThrow( + new AccessDeniedException('Only admins, or leadership/heads who created this ruleset, can delete a ruleset.') + ); }); it('Delete ruleset fails if ruleset was already deleted', async () => { const car = await createUniqueCar(orgId); @@ -1814,7 +1869,11 @@ describe('Rule Tests', () => { } }); - let rulesets = await RulesService.getRulesetsByRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); + let rulesets = await RulesService.getRulesetsByRulesetType( + admin, + fsaeRulesetType2WithRevisionFiles.rulesetTypeId, + orgId + ); expect(rulesets.length).toBe(1); await RulesService.deleteRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, organization); rulesets = await RulesService.getRulesetsByRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); @@ -1843,7 +1902,8 @@ describe('Rule Tests', () => { } }); await expect( - RulesService.getUnassignedRulesForProjectRuleset(admin, + RulesService.getUnassignedRulesForProjectRuleset( + admin, otherRuleset.rulesetId, project.projectId, organization.organizationId @@ -1855,7 +1915,8 @@ describe('Rule Tests', () => { const { ruleset1 } = await setupRules(car); const otherOrgProject = await createTestProject(admin, otherOrg.organizationId); await expect( - RulesService.getUnassignedRulesForProjectRuleset(admin, + RulesService.getUnassignedRulesForProjectRuleset( + admin, ruleset1.rulesetId, otherOrgProject.projectId, organization.organizationId @@ -1864,7 +1925,8 @@ describe('Rule Tests', () => { }); it('fails if ruleset does not exist', async () => { await expect( - RulesService.getUnassignedRulesForProjectRuleset(admin, + RulesService.getUnassignedRulesForProjectRuleset( + admin, 'nonexistent-ruleset-id', project.projectId, organization.organizationId @@ -1875,7 +1937,12 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); await expect( - RulesService.getUnassignedRulesForProjectRuleset(admin, ruleset1.rulesetId, 'fake-project-id', organization.organizationId) + RulesService.getUnassignedRulesForProjectRuleset( + admin, + ruleset1.rulesetId, + 'fake-project-id', + organization.organizationId + ) ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); }); it("successfully returns rules on the project's teams that are not already assigned to the project", async () => { @@ -1904,7 +1971,8 @@ describe('Rule Tests', () => { createdByUserId: admin.userId } }); - const rules = await RulesService.getUnassignedRulesForProjectRuleset(admin, + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + admin, ruleset1.rulesetId, project.projectId, organization.organizationId @@ -1938,7 +2006,8 @@ describe('Rule Tests', () => { await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); await RulesService.toggleRuleTeam(leafRule1.ruleId, secondTeam.teamId, admin, organization); - const rules = await RulesService.getUnassignedRulesForProjectRuleset(admin, + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + admin, ruleset1.rulesetId, project.projectId, organization.organizationId @@ -1952,7 +2021,8 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { ruleset1 } = await setupRules(car); const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); - const rules = await RulesService.getUnassignedRulesForProjectRuleset(admin, + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + admin, ruleset1.rulesetId, project.projectId, organization.organizationId @@ -1969,7 +2039,12 @@ describe('Rule Tests', () => { await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); - const projectRules = await RulesService.getProjectRules(admin, topLevelRule.rulesetId, projectRule.projectId, organization); + const projectRules = await RulesService.getProjectRules( + admin, + topLevelRule.rulesetId, + projectRule.projectId, + organization + ); expect(projectRules.length).toBe(1); expect(projectRules[0].projectRuleId).toBe(projectRule.projectRuleId); @@ -1980,7 +2055,12 @@ describe('Rule Tests', () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); - const projectRules = await RulesService.getProjectRules(admin, topLevelRule.rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules( + admin, + topLevelRule.rulesetId, + project.projectId, + organization + ); expect(projectRules.length).toBe(0); }); @@ -2222,9 +2302,9 @@ describe('Rule Tests', () => { }); it('Fails when ruleset does not exist', async () => { - await expect(RulesService.getAllRulesForRuleset(admin, 'fake-ruleset-id', organization.organizationId)).rejects.toThrow( - new NotFoundException('Ruleset', 'fake-ruleset-id') - ); + await expect( + RulesService.getAllRulesForRuleset(admin, 'fake-ruleset-id', organization.organizationId) + ).rejects.toThrow(new NotFoundException('Ruleset', 'fake-ruleset-id')); }); it('Fails when ruleset is deleted', async () => { @@ -2237,9 +2317,9 @@ describe('Rule Tests', () => { }); await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); - await expect(RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId)).rejects.toThrow( - new DeletedException('Ruleset', ruleset1.rulesetId) - ); + await expect( + RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId) + ).rejects.toThrow(new DeletedException('Ruleset', ruleset1.rulesetId)); }); it('Fails if ruleset is in the wrong org', async () => { @@ -2270,7 +2350,11 @@ describe('Rule Tests', () => { describe('Get Ruleset Type', () => { it('Successfully gets a ruleset type by ID', async () => { - const rulesetType = await RulesService.getRulesetType(admin, fsaeRulesetType.rulesetTypeId, organization.organizationId); + const rulesetType = await RulesService.getRulesetType( + admin, + fsaeRulesetType.rulesetTypeId, + organization.organizationId + ); expect(rulesetType).toBeDefined(); expect(rulesetType.rulesetTypeId).toBe(fsaeRulesetType.rulesetTypeId); expect(rulesetType.name).toBe(fsaeRulesetType.name); From 3ecc4c7c5aff85dc6cd68646b9c565f4fbb06a63 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 17:16:34 -0400 Subject: [PATCH 04/10] #4384 members update project rule status if in project team --- src/backend/src/services/rules.services.ts | 34 +++++++++++++-- src/backend/tests/unit/rule.test.ts | 41 +++++++++++++++---- .../ProjectRules/ProjectRulesTab.tsx | 6 ++- .../components/RulesetGeneralView.tsx | 16 +++++--- 4 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 3900b22584..a4897ea562 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -21,12 +21,14 @@ import { NotFoundException } from '../utils/errors.utils.js'; import { userHasPermission } from '../utils/users.utils.js'; +import { isUserPartOfTeams } from '../utils/teams.utils.js'; import { getProjectRuleQueryArgs, getRulesetQueryArgs, getRulePreviewQueryArgs, getRulesetTypeQueryArgs } from '../prisma-query-args/rules.query-args.js'; +import { getTeamPreviewQueryArgs } from '../prisma-query-args/teams.query-args.js'; import { ruleTransformer, projectRuleTransformer, @@ -818,7 +820,9 @@ export default class RulesService { /** * Sets the completion of a rule. Completion is global to the rule, so marking it complete - * (or incomplete) is reflected everywhere the rule appears. + * (or incomplete) is reflected everywhere the rule appears. Leadership and above can update + * completion anywhere; members can only update it within a project whose team they're on + * (general-view updates, i.e. no projectId, always require leadership). * @param submitter the user updating the completion * @param organization the organization of the rule * @param ruleId the id of the rule to update @@ -833,8 +837,32 @@ export default class RulesService { isComplete: boolean, projectId?: string ): Promise { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { - throw new AccessDeniedException('You do not have permissions to update rule completion'); + const hasOrgWidePermission = await userHasPermission(submitter.userId, organization.organizationId, isLeadership); + + if (!hasOrgWidePermission) { + if (!projectId) { + throw new AccessDeniedException('You do not have permissions to update rule completion'); + } + + const project = await prisma.project.findUnique({ + where: { projectId }, + include: { + wbsElement: true, + teams: getTeamPreviewQueryArgs(organization.organizationId) + } + }); + + if (!project) { + throw new NotFoundException('Project', projectId); + } + + if (project.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Project'); + } + + if (!isUserPartOfTeams(project.teams, submitter)) { + throw new AccessDeniedException('You do not have permissions to update rule completion for this project'); + } } const rule = await prisma.rule.findUnique({ diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 46168e1a99..b58162688e 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1260,15 +1260,46 @@ describe('Rule Tests', () => { expect(updatedRule.completedInProject).toBeUndefined(); }); - it('Set rule completion fails if user does not have permission', async () => { + it('Set rule completion fails if a member is not on the project team', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); await expect( async () => await RulesService.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true, project.projectId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule completion for this project')); + }); + + it('Set rule completion fails if a member tries to update the general-view status', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + await expect( + async () => await RulesService.setRuleCompletion(nonLeadership, organization, topLevelRule.ruleId, true) ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule completion')); }); + + it('Set rule completion succeeds if a member is on the project team', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const memberProject = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await prisma.team.update({ + where: { teamId: testTeam.teamId }, + data: { members: { connect: { userId: nonLeadership.userId } } } + }); + + const updatedRule = await RulesService.setRuleCompletion( + nonLeadership, + organization, + topLevelRule.ruleId, + true, + memberProject.projectId + ); + + expect(updatedRule.isComplete).toBe(true); + expect(updatedRule.completedBy?.firstName).toBe(nonLeadership.firstName); + expect(updatedRule.completedInProject?.projectId).toBe(memberProject.projectId); + }); }); describe('Edit Rule', () => { @@ -1484,9 +1515,7 @@ describe('Rule Tests', () => { await expect( async () => await RulesService.deleteRuleset(ruleset1.rulesetId, nonLeadership.userId, organization.organizationId) - ).rejects.toThrow( - new AccessDeniedException('Only admins, or leadership/heads who created this ruleset, can delete a ruleset.') - ); + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to delete this ruleset.')); }); it('Delete ruleset succeeds if a leadership user created the ruleset', async () => { const car = await createUniqueCar(orgId); @@ -1527,9 +1556,7 @@ describe('Rule Tests', () => { await expect( async () => await RulesService.deleteRuleset(ruleset.rulesetId, leadershipUser.userId, organization.organizationId) - ).rejects.toThrow( - new AccessDeniedException('Only admins, or leadership/heads who created this ruleset, can delete a ruleset.') - ); + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to delete this ruleset.')); }); it('Delete ruleset fails if ruleset was already deleted', async () => { const car = await createUniqueCar(orgId); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index eb198a07e9..37c5aa5790 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -42,6 +42,7 @@ import { routes } from '../../../../utils/routes'; import RuleStatusTag from '../../../RulesPage/components/RuleStatusTag'; import { NERButton } from '../../../../components/NERButton'; import { compareRuleCodes } from '../../../../utils/rules.utils'; +import { isUserOnTeam } from '../../../../utils/teams.utils'; interface ProjectRulesTabProps { project: Project; @@ -90,6 +91,9 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { const teamId = project.teams[0]?.teamId || ''; const teamNames = project.teams.map((team) => team.teamName); + // leadership can update status anywhere; members can only update it for projects whose team they're on + const canUpdateStatus = isLeadership(user.role) || project.teams.some((team) => isUserOnTeam(team, user)); + // Convert project rules to rules for display // Sorted by rule code so both top-level rows and their children render in stable numeric order const projectRuleList = useMemo(() => { @@ -192,7 +196,7 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { rule={rule} allRules={projectRuleList} popoverOpen={isPopoverOpenForRule} - onClick={isLeafRule ? (e) => handleStatusClick(e, rule) : undefined} + onClick={isLeafRule && canUpdateStatus ? (e) => handleStatusClick(e, rule) : undefined} /> ); }; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx index c8eb7166eb..1215dde6f7 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -1,11 +1,12 @@ import React, { useMemo, useState } from 'react'; import { Box, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; -import { Rule } from 'shared'; +import { Rule, isLeadership } from 'shared'; import RuleRow from '../RuleRow'; import RuleStatusTag from './RuleStatusTag'; import RuleContent from './RuleContent'; import UpdateStatusPopover from '../../ProjectDetailPage/ProjectViewContainer/ProjectRules/UpdateStatusPopover'; import { useSetRuleCompletion } from '../../../hooks/rules.hooks'; +import { useCurrentUser } from '../../../hooks/users.hooks'; import { useToast } from '../../../hooks/toasts.hooks'; import { compareRuleCodes } from '../../../utils/rules.utils'; @@ -30,6 +31,7 @@ const RulesetGeneralView: React.FC = ({ }) => { const theme = useTheme(); const toast = useToast(); + const user = useCurrentUser(); const [statusPopoverAnchor, setStatusPopoverAnchor] = useState(null); const [selectedRule, setSelectedRule] = useState(null); @@ -76,10 +78,14 @@ const RulesetGeneralView: React.FC = ({ { - setSelectedRule(r); - setStatusPopoverAnchor(e.currentTarget); - }} + onClick={ + isLeadership(user.role) + ? (e) => { + setSelectedRule(r); + setStatusPopoverAnchor(e.currentTarget); + } + : undefined + } /> )} backgroundColor={tableBackgroundColor} From 9f917212c9b8c70bf42f1a2c6ed8d7c71c8250bc Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 11:27:15 -0400 Subject: [PATCH 05/10] #4384 reset button permissions --- src/backend/src/services/rules.services.ts | 10 +++--- src/backend/tests/unit/rule.test.ts | 34 +++++++++++-------- .../ProjectRules/ProjectRulesTab.tsx | 11 +++--- .../src/pages/RulesPage/RulesetViewPage.tsx | 11 ++++-- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 9e0154fa48..a462bc4075 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1040,15 +1040,15 @@ export default class RulesService { /** * Resets every rule's general-view status back to Pending, for a whole ruleset. * Does not affect any rule's status within a project. Never creates history entries, - * since reverting to PENDING is not tracked. + * since reverting to PENDING is not tracked. Only admins and above can do this. * @param submitter the user resetting the statuses * @param organization the organization of the ruleset * @param rulesetId the id of the ruleset to reset * @returns the number of rules that were reset */ static async resetRulesetStatuses(submitter: User, organization: Organization, rulesetId: string): Promise { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { - throw new AccessDeniedException('You do not have permissions to update rule status'); + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedException('You do not have permissions to reset rule status'); } const ruleset = await prisma.ruleset.findUnique({ @@ -1080,7 +1080,7 @@ export default class RulesService { * Resets every project rule's status back to Pending, for a single project, scoped to a * single ruleset (a project can have rules from multiple ruleset types). Does not affect * any rule's general-view status, or its status in any other project. Never creates history - * entries, since reverting to PENDING is not tracked. + * entries, since reverting to PENDING is not tracked. Allowed for leadership and up. * @param submitter the user resetting the statuses * @param organization the organization of the project and ruleset * @param rulesetId the ruleset to scope the reset to @@ -1094,7 +1094,7 @@ export default class RulesService { projectId: string ): Promise { if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { - throw new AccessDeniedException('You do not have permissions to update rule status'); + throw new AccessDeniedException('You do not have permissions to reset project rule status'); } const ruleset = await prisma.ruleset.findUnique({ diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index a19bd929b9..0d6262ac87 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1305,7 +1305,7 @@ describe('Rule Tests', () => { await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.FAIL); - const projectRules2 = await RulesService.getProjectRules(ruleset1.rulesetId, project2.projectId, organization); + const projectRules2 = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project2.projectId, organization); const rule2Entry = projectRules2.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); expect(rule2Entry?.status).toBe(RuleStatus.PENDING); @@ -1325,7 +1325,7 @@ describe('Rule Tests', () => { expect(count).toBe(2); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); const updatedTopLevel = rules.find((r) => r.ruleId === topLevelRule.ruleId); const updatedLeaf = rules.find((r) => r.ruleId === leafRule1.ruleId); @@ -1375,7 +1375,7 @@ describe('Rule Tests', () => { await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); - const rules = await RulesService.getAllRulesForRuleset(ruleset2.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset2.rulesetId, organization.organizationId); const untouchedRule = rules.find((r) => r.ruleId === otherRule.ruleId); expect(untouchedRule?.status).toBe(RuleStatus.PASS); @@ -1405,7 +1405,7 @@ describe('Rule Tests', () => { expect(count).toBe(1); - const projectRules = await RulesService.getProjectRules(ruleset1.rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project.projectId, organization); const updated = projectRules.find((pr) => pr.projectRuleId === projectRule.projectRuleId); expect(updated?.status).toBe(RuleStatus.PENDING); @@ -1466,7 +1466,7 @@ describe('Rule Tests', () => { await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project1.projectId); - const project2Rules = await RulesService.getProjectRules(ruleset1.rulesetId, project2.projectId, organization); + const project2Rules = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project2.projectId, organization); const untouched = project2Rules.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); expect(untouched?.status).toBe(RuleStatus.PASS); @@ -1496,32 +1496,38 @@ describe('Rule Tests', () => { await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); - const projectRules = await RulesService.getProjectRules(ruleset2.rulesetId, project.projectId, organization); + const projectRules = await RulesService.getProjectRules(admin, ruleset2.rulesetId, project.projectId, organization); const untouched = projectRules.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); expect(untouched?.status).toBe(RuleStatus.PASS); }); - it('Set rule completion succeeds if a member is on the project team', async () => { + it('Set project rule status succeeds if a member is on the project team', async () => { const car = await createUniqueCar(orgId); const { topLevelRule } = await setupRules(car); const memberProject = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + memberProject.projectId + ); await prisma.team.update({ where: { teamId: testTeam.teamId }, data: { members: { connect: { userId: nonLeadership.userId } } } }); - const updatedRule = await RulesService.setRuleCompletion( + const updatedProjectRule = await RulesService.setProjectRuleStatus( nonLeadership, organization, - topLevelRule.ruleId, - true, - memberProject.projectId + projectRule.projectRuleId, + RuleStatus.PASS ); - expect(updatedRule.isComplete).toBe(true); - expect(updatedRule.completedBy?.firstName).toBe(nonLeadership.firstName); - expect(updatedRule.completedInProject?.projectId).toBe(memberProject.projectId); + expect(updatedProjectRule.status).toBe(RuleStatus.PASS); + expect(updatedProjectRule.statusUpdatedBy?.firstName).toBe(nonLeadership.firstName); + expect(updatedProjectRule.statusUpdatedBy?.lastName).toBe(nonLeadership.lastName); }); }); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 299bb0ba2e..954ed686e9 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -104,9 +104,6 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { // leadership can update status anywhere; members can only update it for projects whose team they're on const canUpdateStatus = isLeadership(user.role) || project.teams.some((team) => isUserOnTeam(team, user)); - // leadership can update status anywhere; members can only update it for projects whose team they're on - const canUpdateStatus = isLeadership(user.role) || project.teams.some((team) => isUserOnTeam(team, user)); - // Convert project rules to rules for display, merging in each rule's local status for this project // Sorted by rule code so both top-level rows and their children render in stable numeric order const projectRuleList = useMemo(() => { @@ -277,9 +274,11 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { {areAllExpanded ? 'Collapse All' : 'Expand All'} - setShowResetModal(true)}> - Reset Status - + {isLeadership(user.role) && ( + setShowResetModal(true)}> + Reset Status + + )} )} diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index a10dc159b7..c2d3110c8d 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { isLeadership } from 'shared'; import FullPageTabs from '../../components/FullPageTabs'; import PageLayout from '../../components/PageLayout'; import { NERButton } from '../../components/NERButton'; @@ -18,10 +19,12 @@ import { useFetchFullRuleTree, useResetRulesetStatuses } from '../../hooks/rules.hooks'; +import { useCurrentUser } from '../../hooks/users.hooks'; import { useRuleTreeNavigation } from './useRuleTreeNavigation'; import { useTeamRuleOrganization } from './useTeamRuleOrganization'; const RulesetViewPage = () => { + const user = useCurrentUser(); const [tabIndex, setTabIndex] = useState(0); const [showResetModal, setShowResetModal] = useState(false); // bumped after a reset to force RulesetGeneralView to remount, clearing any open popover/history modal @@ -126,9 +129,11 @@ const RulesetViewPage = () => { {areAllExpanded ? 'Collapse All' : 'Expand All'} - setShowResetModal(true)}> - Reset Status - + {isLeadership(user.role) && ( + setShowResetModal(true)}> + Reset Status + + )} )} {tabIndex === 1 && ( From befd28cb1b94db1356221d3c40716657e8093437 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 18:53:34 -0400 Subject: [PATCH 06/10] #4384 reset status gen view + delete ruleset removed for members --- .../src/pages/RulesPage/RulesetViewPage.tsx | 4 +-- .../RulesPage/components/RulesetTable.tsx | 30 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index c2d3110c8d..7a1e9fc9f3 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { isLeadership } from 'shared'; +import { isAdmin } from 'shared'; import FullPageTabs from '../../components/FullPageTabs'; import PageLayout from '../../components/PageLayout'; import { NERButton } from '../../components/NERButton'; @@ -129,7 +129,7 @@ const RulesetViewPage = () => { {areAllExpanded ? 'Collapse All' : 'Expand All'} - {isLeadership(user.role) && ( + {isAdmin(user.role) && ( setShowResetModal(true)}> Reset Status diff --git a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx index 7904dc6b38..1ca51d644f 100644 --- a/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx +++ b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx @@ -56,6 +56,8 @@ const RulesetTable: React.FC = () => { return ruleset.ruleAmount > 0; }; + const canDelete = isLeadership(user.role); + // Table header configuration const headCells = [ { id: 'fileName', label: 'File Name' }, @@ -64,7 +66,7 @@ const RulesetTable: React.FC = () => { { id: 'car', label: 'Car' }, { id: 'isActive', label: 'Active?' }, { id: 'actions', label: 'Actions' }, - { id: 'delete', label: '' } + ...(canDelete ? [{ id: 'delete', label: '' }] : []) ]; const handleToggleActive = (ruleset: Ruleset) => { @@ -237,7 +239,13 @@ const RulesetTable: React.FC = () => { > View Rules - + {canDelete && ( + + )} @@ -265,7 +273,7 @@ const RulesetTable: React.FC = () => { {/* Table rows with ruleset data */} {rulesets.length === 0 ? ( - + No Rulesets Found @@ -339,13 +347,15 @@ const RulesetTable: React.FC = () => { View Rules - - - + {canDelete && ( + + + + )} )) )} From 869cc4a840cf06599e56a6c6ed0f94863921a16c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sun, 23 Aug 2026 14:05:24 -0400 Subject: [PATCH 07/10] #4384 test ts fix --- src/backend/tests/unit/rule.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 2d86565975..127dcffd87 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1367,7 +1367,11 @@ describe('Rule Tests', () => { // so FAIL rolls all the way up the chain await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.FAIL); - const rulesBeforeDelete = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rulesBeforeDelete = await RulesService.getAllRulesForRuleset( + admin, + ruleset1.rulesetId, + organization.organizationId + ); expect(rulesBeforeDelete.find((r) => r.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); expect(rulesBeforeDelete.find((r) => r.ruleId === grandparentRule.ruleId)?.status).toBe(RuleStatus.FAIL); @@ -1375,7 +1379,11 @@ describe('Rule Tests', () => { // status should reset to Pending, and that change should keep propagating up to grandparentRule await RulesService.deleteRule(childRule.ruleId, admin, organization); - const rulesAfterDelete = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rulesAfterDelete = await RulesService.getAllRulesForRuleset( + admin, + ruleset1.rulesetId, + organization.organizationId + ); const updatedParent = rulesAfterDelete.find((r) => r.ruleId === parentRule.ruleId); const updatedGrandparent = rulesAfterDelete.find((r) => r.ruleId === grandparentRule.ruleId); @@ -1410,7 +1418,7 @@ describe('Rule Tests', () => { // marking the child Pass rolls parent rule up to Pass too await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.PASS); - const rules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); const parentRule = rules.find((r) => r.ruleId === rule.ruleId); expect(parentRule!.status).toBe(RuleStatus.PASS); @@ -1423,7 +1431,7 @@ describe('Rule Tests', () => { // deleting the only child makes rule a leaf again, so it should reset to Pending await RulesService.deleteRule(childRule.ruleId, admin, organization); - const allRules = await RulesService.getAllRulesForRuleset(ruleset1.rulesetId, organization.organizationId); + const allRules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); const updatedRule = allRules.find((r) => r.ruleId === rule.ruleId); expect(updatedRule?.status).toBe(RuleStatus.PENDING); @@ -1479,6 +1487,7 @@ describe('Rule Tests', () => { await RulesService.setProjectRuleStatus(admin, organization, childProjectRule.projectRuleId, RuleStatus.FAIL); const projectRulesBeforeDelete = await RulesService.getProjectRules( + admin, ruleset1.rulesetId, project.projectId, organization @@ -1491,6 +1500,7 @@ describe('Rule Tests', () => { await RulesService.deleteProjectRule(childProjectRule.projectRuleId, admin, organization); const projectRulesAfterDelete = await RulesService.getProjectRules( + admin, ruleset1.rulesetId, project.projectId, organization From cec3abe9b0b325e820710ea30728308d27224047 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sun, 23 Aug 2026 14:36:19 -0400 Subject: [PATCH 08/10] #4384 test fix --- src/backend/tests/unit/rule.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 127dcffd87..e50f8ebedd 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1555,7 +1555,7 @@ describe('Rule Tests', () => { await expect( async () => await RulesService.resetRulesetStatuses(nonLeadership, organization, ruleset1.rulesetId) - ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to reset rule status')); }); it('Reset status only affects the given ruleset', async () => { @@ -1640,7 +1640,7 @@ describe('Rule Tests', () => { await expect( async () => await RulesService.resetProjectRuleStatuses(nonLeadership, organization, ruleset1.rulesetId, project.projectId) - ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to reset project rule status')); }); it('Reset project status only affects the given project', async () => { From a95136a6f0eb67e6eaf05a4fbe028580fbf1fa13 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 24 Aug 2026 17:25:21 -0400 Subject: [PATCH 09/10] #4384 leads + heads can edit --- src/backend/src/services/rules.services.ts | 16 ++++++++-------- src/backend/tests/unit/rule.test.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 0589be771c..6b727ef88f 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -362,8 +362,8 @@ export default class RulesService { } }); - if (!(await userHasPermission(deleter.userId, org.organizationId, isAdmin))) { - throw new AccessDeniedAdminOnlyException('delete rules'); + if (!(await userHasPermission(deleter.userId, org.organizationId, isLeadership))) { + throw new AccessDeniedException('Only leadership and above can delete rules'); } if (!rule) throw new NotFoundException('Rule', ruleId); @@ -581,8 +581,8 @@ export default class RulesService { organization: Organization, parentRuleId?: string ) { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) - throw new AccessDeniedAdminOnlyException('edit a rule'); + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('Only leadership and above can edit a rule'); const currentRule = await prisma.rule.findUnique({ where: { ruleId }, @@ -673,8 +673,8 @@ export default class RulesService { * @returns the updated rule */ static async addRuleReferences(submitter: User, ruleId: string, referencedRuleId: string, organization: Organization) { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) - throw new AccessDeniedAdminOnlyException('edit a rule'); + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('Only leadership and above can edit a rule'); const rule = await prisma.rule.findUnique({ where: { ruleId }, @@ -732,8 +732,8 @@ export default class RulesService { * @returns the updated rule */ static async removeRuleReferences(submitter: User, ruleId: string, referencedRuleId: string, organization: Organization) { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) - throw new AccessDeniedAdminOnlyException('edit a rule'); + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('Only leadership and above can edit a rule'); const rule = await prisma.rule.findUnique({ where: { ruleId }, diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index e50f8ebedd..c14671c0e5 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -1733,7 +1733,7 @@ describe('Rule Tests', () => { }); describe('Edit Rule', () => { - it('Fails if user is not an admin', async () => { + it('Fails if user is not leadership or above', async () => { const car = await createUniqueCar(orgId); const { leafRule1 } = await setupRules(car); await expect( @@ -1746,7 +1746,7 @@ describe('Rule Tests', () => { ['newfile'], organization ) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('edit a rule')); + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can edit a rule')); }); it('Fails if rule doesn`t exist', async () => { @@ -2852,13 +2852,13 @@ describe('Rule Tests', () => { ); }); - it('Fails adding referenced rule if user is not admin', async () => { + it('Fails adding referenced rule if user is not leadership or above', async () => { const car = await createUniqueCar(orgId); const { topLevelRule, referencedRule } = await setupRules(car); await expect( async () => await RulesService.addRuleReferences(nonLeadership, topLevelRule.ruleId, referencedRule.ruleId, organization) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('edit a rule')); + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can edit a rule')); }); it('Fails adding referenced rule if rule does not exist', async () => { @@ -2937,13 +2937,13 @@ describe('Rule Tests', () => { ).rejects.toThrow(new HttpException(400, 'A rule cannot reference itself')); }); - it('Fails removing referenced rule if user is not admin', async () => { + it('Fails removing referenced rule if user is not leadership or above', async () => { const car = await createUniqueCar(orgId); const { referencedRule, referencingRule } = await setupRules(car); await expect( async () => await RulesService.removeRuleReferences(nonLeadership, referencingRule.ruleId, referencedRule.ruleId, organization) - ).rejects.toThrow(new AccessDeniedAdminOnlyException('edit a rule')); + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can edit a rule')); }); it('Fails removing referenced rule if rule does not exist', async () => { From afc212b791e1a392dc3f8febe0513734f4ecef69 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Mon, 24 Aug 2026 18:18:02 -0400 Subject: [PATCH 10/10] #4384 heads can reset in gen view --- src/backend/src/services/rules.services.ts | 4 ++-- src/backend/tests/unit/rule.test.ts | 14 ++++++++++++++ .../src/pages/RulesPage/RulesetViewPage.tsx | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 6b727ef88f..d421db5362 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1062,14 +1062,14 @@ export default class RulesService { /** * Resets every rule's general-view status back to Pending, for a whole ruleset. * Does not affect any rule's status within a project. Never creates history entries, - * since reverting to PENDING is not tracked. Only admins and above can do this. + * since reverting to PENDING is not tracked. Only heads and above can do this. * @param submitter the user resetting the statuses * @param organization the organization of the ruleset * @param rulesetId the id of the ruleset to reset * @returns the number of rules that were reset */ static async resetRulesetStatuses(submitter: User, organization: Organization, rulesetId: string): Promise { - if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { throw new AccessDeniedException('You do not have permissions to reset rule status'); } diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index c14671c0e5..152488030d 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -6,6 +6,7 @@ import { wonderwomanGuest, batmanAppAdmin, aquamanLeadership, + greenlanternHead, alfred, flashAdmin } from '../test-data/users.test-data'; @@ -941,6 +942,7 @@ describe('Rule Tests', () => { let orgId: string; let otherOrg: Organization; let admin: User; + let head: User; let nonLeadership: User; let guest: User; let project: Project; @@ -952,6 +954,7 @@ describe('Rule Tests', () => { organization = await createTestOrganization(); orgId = organization.organizationId; admin = await createTestUser(supermanAdmin, organization.organizationId); + head = await createTestUser(greenlanternHead, organization.organizationId); nonLeadership = await createTestUser(financeMember, organization.organizationId); guest = await createTestUser(wonderwomanGuest, organization.organizationId); project = await createTestProject(admin, organization.organizationId); @@ -1558,6 +1561,17 @@ describe('Rule Tests', () => { ).rejects.toThrow(new AccessDeniedException('You do not have permissions to reset rule status')); }); + it('Reset status succeeds for a head (non-admin) user', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, leafRule1 } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); + + const count = await RulesService.resetRulesetStatuses(head, organization, ruleset1.rulesetId); + + expect(count).toBe(1); + }); + it('Reset status only affects the given ruleset', async () => { const car = await createUniqueCar(orgId); const { ruleset1, ruleset2, leafRule1 } = await setupRules(car); diff --git a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx index 7a1e9fc9f3..6674f736ff 100644 --- a/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { isAdmin } from 'shared'; +import { isHead } from 'shared'; import FullPageTabs from '../../components/FullPageTabs'; import PageLayout from '../../components/PageLayout'; import { NERButton } from '../../components/NERButton'; @@ -129,7 +129,7 @@ const RulesetViewPage = () => { {areAllExpanded ? 'Collapse All' : 'Expand All'} - {isAdmin(user.role) && ( + {isHead(user.role) && ( setShowResetModal(true)}> Reset Status