From 30571b79df1da957d1b2c692b070db3a4d9a36f7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 4 May 2026 11:04:35 +1000 Subject: [PATCH] PM-4988: Hide project detail editing for copilots What was broken The Work app used the broader project management access helper for project detail editing, so copilot project members could see edit links and open the project editor for projects where they had copilot access. Root cause The existing helper intentionally allows copilot membership for general project write flows, but project detail editing needs a narrower Full Access check. What was changed Added a project-detail edit helper that requires admin access or manager-tier user access plus Full Access project membership. Updated the projects list edit affordance and project editor route guard to use the narrower helper while leaving other copilot write flows on the existing helper. Any added/updated tests Added permission utility coverage for Full Access project detail edits, including denial for copilot project membership and allowance for admins. --- .../src/lib/utils/permissions.utils.spec.ts | 19 ++++++++++++ .../work/src/lib/utils/permissions.utils.ts | 30 +++++++++++++++++++ .../ProjectEditorPage/ProjectEditorPage.tsx | 7 +++-- .../ProjectsListPage.spec.tsx | 2 ++ .../ProjectsListPage/ProjectsListPage.tsx | 7 +++-- 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/apps/work/src/lib/utils/permissions.utils.spec.ts b/src/apps/work/src/lib/utils/permissions.utils.spec.ts index 18be5fbff..b6c41a8fc 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.spec.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.spec.ts @@ -5,6 +5,7 @@ import type { Project } from '../models' import { canCreateEngagement, canViewAllEngagements, + checkCanEditProjectDetails, checkCanManageProject, checkIsUserInvitedToProject, checkProjectAccess, @@ -41,6 +42,10 @@ describe('permissions.utils project management helpers', () => { role: 'customer', userId: 456, }, + { + role: 'copilot', + userId: 789, + }, ], name: 'Managed project', status: 'active', @@ -77,6 +82,20 @@ describe('permissions.utils project management helpers', () => { .toBe(false) }) + it('requires full access membership for project details edits', () => { + expect(checkCanEditProjectDetails(['Talent Manager'], '123', managedProject)) + .toBe(true) + expect(checkCanEditProjectDetails(['Talent Manager'], '789', managedProject)) + .toBe(false) + expect(checkCanEditProjectDetails(['Project Manager'], '456', managedProject)) + .toBe(false) + }) + + it('allows admins to edit project details without membership', () => { + expect(checkCanEditProjectDetails(['administrator'], '999', managedProject)) + .toBe(true) + }) + it('limits engagement creation to admins and talent managers', () => { expect(canCreateEngagement(['copilot'])) .toBe(false) diff --git a/src/apps/work/src/lib/utils/permissions.utils.ts b/src/apps/work/src/lib/utils/permissions.utils.ts index bc4776a0c..3b678598d 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.ts @@ -326,6 +326,36 @@ export function checkCanManageProject( || normalizedRole === PROJECT_ROLES.MANAGER } +/** + * Returns whether the caller can edit an existing project's core details. + * + * Admins always qualify. Non-admin callers must hold a manager-tier user role + * and the project's Full Access membership. Copilot membership is excluded so + * copilot users can keep other write access without changing project details. + * + * @param userRoles caller roles from the decoded auth token or app context. + * @param userId logged-in user identifier used for project membership checks. + * @param project project context for the edit check. + * @returns `true` when the caller can edit project details. + */ +export function checkCanEditProjectDetails( + userRoles: string[], + userId: number | string | undefined, + project: Project | undefined, +): boolean { + if (hasAdminRole(userRoles)) { + return true + } + + if (!project || !hasManagerRole(userRoles)) { + return false + } + + const normalizedRole = normalizeValue(getProjectMemberByUserId(project, userId)?.role) + + return normalizedRole === PROJECT_ROLES.MANAGER +} + export function checkAdminOrPmOrTaskManager( token: string, project?: Project, diff --git a/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx b/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx index 9c1623e06..7e399a1ef 100644 --- a/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx +++ b/src/apps/work/src/pages/projects/ProjectEditorPage/ProjectEditorPage.tsx @@ -26,7 +26,10 @@ import { ErrorMessage, LoadingSpinner, } from '../../../lib/components' -import { checkCanManageProject } from '../../../lib/utils' +import { + checkCanEditProjectDetails, + checkCanManageProject, +} from '../../../lib/utils' import { ProjectEditorForm, @@ -119,7 +122,7 @@ export const ProjectEditorPage: FC = () => { const canCreateProject = checkCanManageProject(userRoles, loginUserInfo?.userId) const canManageProject = !!projectResult.project - && checkCanManageProject(userRoles, loginUserInfo?.userId, projectResult.project) + && checkCanEditProjectDetails(userRoles, loginUserInfo?.userId, projectResult.project) const shouldRedirect = shouldRedirectToProjects( isEdit, canCreateProject, diff --git a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx index 79f069146..47e18f6be 100644 --- a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx +++ b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx @@ -82,6 +82,8 @@ jest.mock('../../../lib/constants', () => ({ PROJECTS_PAGE_SIZE: 10, })) jest.mock('../../../lib/utils', () => ({ + checkCanEditProjectDetails: + jest.requireActual('../../../lib/utils/permissions.utils').checkCanEditProjectDetails, checkCanManageProject: jest.requireActual('../../../lib/utils/permissions.utils').checkCanManageProject, })) diff --git a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx index ce59849a6..56af8d257 100644 --- a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx +++ b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.tsx @@ -35,7 +35,10 @@ import { ProjectFilters, WorkAppContextModel, } from '../../../lib/models' -import { checkCanManageProject } from '../../../lib/utils' +import { + checkCanEditProjectDetails, + checkCanManageProject, +} from '../../../lib/utils' import styles from '../../../lib/components/ProjectsListPage/ProjectsListPage.module.scss' const DEFAULT_FILTERS: ProjectFilters = { @@ -127,7 +130,7 @@ export const ProjectsListPage: FC = () => { const canCreateProject = checkCanManageProject(userRoles, loginUserInfo?.userId) const canEditProject = useCallback( - (project: Project): boolean => checkCanManageProject(userRoles, loginUserInfo?.userId, project), + (project: Project): boolean => checkCanEditProjectDetails(userRoles, loginUserInfo?.userId, project), [loginUserInfo?.userId, userRoles], )