From d2aaf9df6a3b6c61a9f71352077605e8b0061eac Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Sat, 4 Apr 2026 01:53:26 +1100 Subject: [PATCH] PM-4700: allow project managers to create projects What was broken Project Manager users could open the Projects page, but the New Project action was hidden and the create route redirected them back to the list. Root cause (if identifiable) The shared work-app project permission helper only treated copilots and talent managers as eligible to create projects when no project context was present. What was changed Updated the project management permission helper so Project Manager roles can create new projects while keeping existing project edit access membership-based. Added a Projects list regression test to verify the New Project action renders for Project Manager users. Any added/updated tests Updated permissions.utils.spec.ts to cover Project Manager create access and preserve membership requirements for editing. Added ProjectsListPage.spec.tsx to cover the Projects page header action. --- .../src/lib/utils/permissions.utils.spec.ts | 11 +- .../work/src/lib/utils/permissions.utils.ts | 14 +- .../ProjectsListPage.spec.tsx | 158 ++++++++++++++++++ 3 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx 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 7379b6f0d..3967496e5 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.spec.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.spec.ts @@ -54,6 +54,11 @@ describe('permissions.utils project management helpers', () => { .toBe(true) }) + it('allows project managers to create projects without a project context', () => { + expect(checkCanManageProject(['Project Manager'], '123')) + .toBe(true) + }) + it('requires project manager or copilot membership for talent manager edit access', () => { expect(checkCanManageProject(['Talent Manager'], '123', managedProject)) .toBe(true) @@ -61,8 +66,10 @@ describe('permissions.utils project management helpers', () => { .toBe(false) }) - it('does not expand project-manager creation access beyond the work-manager change', () => { - expect(checkCanManageProject(['Project Manager'], '123')) + it('still requires project membership for project manager edit access', () => { + expect(checkCanManageProject(['Project Manager'], '123', managedProject)) + .toBe(true) + expect(checkCanManageProject(['Project Manager'], '456', managedProject)) .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 70e8a43c0..b2107c008 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.ts @@ -264,9 +264,9 @@ export function checkProjectMembership( /** * Returns whether the caller can manage project ownership and billing flows. * - * Admins always qualify. Copilots and Talent Managers can create projects and - * can manage an existing project when they hold a manager or copilot - * membership on that project. + * Admins always qualify. Copilots, Project Managers, and Talent Managers can + * create projects. Managing an existing project still requires a manager or + * copilot membership on that project. * * @param userRoles caller roles from the decoded auth token or app context. * @param userId logged-in user identifier used for project membership checks. @@ -282,12 +282,12 @@ export function checkCanManageProject( return true } - if (!hasCopilotRole(userRoles) && !checkTalentManager(userRoles)) { - return false + if (!project) { + return hasCopilotRole(userRoles) || hasManagerRole(userRoles) } - if (!project) { - return true + if (!hasCopilotRole(userRoles) && !hasManagerRole(userRoles)) { + return false } const normalizedRole = normalizeValue(getProjectMemberByUserId(project, userId)?.role) diff --git a/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx new file mode 100644 index 000000000..79f069146 --- /dev/null +++ b/src/apps/work/src/pages/projects/ProjectsListPage/ProjectsListPage.spec.tsx @@ -0,0 +1,158 @@ +/* eslint-disable no-var, global-require, @typescript-eslint/no-var-requires */ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { Context, PropsWithChildren, ReactNode } from 'react' +import { + render, + screen, +} from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import { WorkAppContextModel } from '../../../lib/models/WorkAppContextModel.model' +import { useFetchProjectsList } from '../../../lib/hooks' + +import { ProjectsListPage } from './ProjectsListPage' + +var mockWorkAppContext: Context + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + }, +})) +jest.mock('tc-auth-lib', () => ({ + decodeToken: jest.fn(), +})) +jest.mock('../../../lib/services/resources.service', () => ({ + fetchResourceRoles: jest.fn(), + fetchResources: jest.fn(), +})) +jest.mock('~/apps/admin/src/lib', () => ({ + TableLoading: () =>
Loading
, +}), { + virtual: true, +}) +jest.mock('~/apps/review/src/lib', () => ({ + PageWrapper: ( + props: PropsWithChildren<{ pageTitle?: string; rightHeader?: ReactNode }>, + ) => ( +
+
{props.rightHeader}
+

{props.pageTitle}

+
{props.children}
+
+ ), +}), { + virtual: true, +}) +jest.mock('~/libs/ui', () => ({ + Button: (props: { label: string }) => ( + + ), +}), { + virtual: true, +}) +jest.mock('../../../lib/components', () => ({ + Pagination: () =>
Pagination
, + ProjectsFilter: () =>
Projects Filter
, + ProjectsTable: () =>
Projects Table
, +})) +jest.mock('../../../lib/contexts', () => { + const React = require('react') as typeof import('react') + + mockWorkAppContext = React.createContext({ + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: undefined, + userRoles: [], + }) + + return { + WorkAppContext: mockWorkAppContext, + } +}) +jest.mock('../../../lib/hooks', () => ({ + useFetchProjectsList: jest.fn(), +})) +jest.mock('../../../lib/constants', () => ({ + PROJECTS_PAGE_SIZE: 10, +})) +jest.mock('../../../lib/utils', () => ({ + checkCanManageProject: jest.requireActual('../../../lib/utils/permissions.utils').checkCanManageProject, +})) + +const mockedUseFetchProjectsList = useFetchProjectsList as jest.Mock + +const defaultContextValue: WorkAppContextModel = { + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: { + email: 'user@example.com', + exp: 0, + handle: 'work-user', + iat: 0, + roles: ['topcoder user'], + userId: 12345, + } as WorkAppContextModel['loginUserInfo'], + userRoles: ['topcoder user'], +} + +const projectManagerContextValue: WorkAppContextModel = { + ...defaultContextValue, + isManager: true, + loginUserInfo: { + ...defaultContextValue.loginUserInfo, + roles: ['project manager'], + } as WorkAppContextModel['loginUserInfo'], + userRoles: ['project manager'], +} + +function renderPage(contextValue: WorkAppContextModel = defaultContextValue): void { + const MockWorkAppContext = mockWorkAppContext + + render( + + + + + , + ) +} + +describe('ProjectsListPage', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockedUseFetchProjectsList.mockReturnValue({ + error: undefined, + isLoading: false, + isValidating: false, + metadata: { + page: 1, + perPage: 10, + total: 0, + }, + mutate: jest.fn(), + projects: [], + }) + }) + + it('renders the new project action for project managers', () => { + renderPage(projectManagerContextValue) + + const newProjectButton = screen.getByRole('button', { name: 'New Project' }) + const newProjectLink = screen.getByRole('link', { name: 'New Project' }) + + expect(newProjectButton) + .toBeTruthy() + expect(newProjectLink.getAttribute('href')) + .toBe('/projects/new') + }) +})