From 352cb2511a3ed59c081c1bc213968db7a9deac7e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 2 Oct 2026 15:55:12 +1000 Subject: [PATCH] PM-6515 Allow Talent Managers to view non-member project workspaces --- src/apps/work/README.md | 13 ++++++ .../ProjectRouteAccessGuard.spec.tsx | 46 +++++++++++++++++++ .../ProjectRouteAccessGuard.tsx | 3 +- .../src/lib/utils/permissions.utils.spec.ts | 40 +++++++++++++++- .../work/src/lib/utils/permissions.utils.ts | 12 +++-- .../ChallengesListPage.spec.tsx | 45 ++++++++++++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) diff --git a/src/apps/work/README.md b/src/apps/work/README.md index f6c74eed8..53976ba85 100644 --- a/src/apps/work/README.md +++ b/src/apps/work/README.md @@ -20,6 +20,19 @@ The Work app provides work management capabilities for: `config/routes.config.ts` contains route ids and the `rootRoute` resolver based on the active subdomain. +## Project workspace access + +`checkProjectAccess` allows Administrators, Talent Managers, and Topcoder Talent +Managers to open projects returned by the projects API without project membership. +Other Work users require membership. This shared check governs project workspace +routes and project challenge pages, so Talent Managers can view non-internal +project details and challenges directly from the projects list. + +The projects API requires active membership for Talent Managers to read internal +projects. A missing or rejected project still blocks the workspace. Viewing a +project does not grant permission to edit its details, manage its billing or +members, or modify its challenges; those actions retain their separate checks. + ## Navigation styles The Work subnavigation uses regular black text and a bold active item on desktop diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx index fcd189e1f..d34b37389 100644 --- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx @@ -25,6 +25,18 @@ import { var mockWorkAppContext: Context +jest.mock('~/config', () => ({ + EnvironmentConfig: new Proxy({}, { + get: (): string => 'https://www.topcoder-dev.com', + }), +}), { virtual: true }) +jest.mock('@topcoder-platform/tc-auth-lib', () => ({ + decodeToken: jest.fn(), +})) +jest.mock('../../services/resources.service', () => ({ + fetchResourceRoles: jest.fn(), + fetchResources: jest.fn(), +})) jest.mock('~/apps/review/src/lib', () => ({ PageWrapper: ( props: PropsWithChildren<{ @@ -141,6 +153,40 @@ describe('ProjectRouteAccessGuard', () => { .toBeTruthy() }) + it.each(['Talent Manager', 'Topcoder Talent Manager'])('opens the workspace for non-member %s users', role => { + mockedCheckProjectAccess.mockImplementation(jest.requireActual('../../utils/permissions.utils') + .checkProjectAccess) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + project: { id: 200, members: [{ userId: 99999 }] }, + }) + + renderGuard('/projects/200/users', { ...defaultContextValue, userRoles: [role] }) + + expect(screen.getByText('Protected Project Users')) + .toBeTruthy() + expect(screen.queryByText(PROJECT_ACCESS_DENIED_MESSAGE)) + .toBeNull() + }) + + it.each(['Talent Manager', 'Topcoder Talent Manager'])('blocks %s when the API rejects the project', role => { + mockedCheckProjectAccess.mockImplementation(jest.requireActual('../../utils/permissions.utils') + .checkProjectAccess) + mockedUseFetchProject.mockReturnValue({ + error: new Error('Talent Managers must be active members to access internal projects'), + isLoading: false, + project: undefined, + }) + + renderGuard('/projects/200/users', { ...defaultContextValue, userRoles: [role] }) + + expect(screen.getByRole('link', { name: 'support@topcoder.com' })) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + }) + it('renders the protected route when cached project access survives a revalidation error', () => { mockedUseFetchProject.mockReturnValue({ error: new Error('Network unavailable'), diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx index 21dc756f4..d9dee6940 100644 --- a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx @@ -25,7 +25,8 @@ interface ProjectRouteAccessGuardProps extends PropsWithChildren { * Blocks project-scoped Work routes until the current user has project access. * * @param props child route content and fallback page title used while access is loading or denied. - * @returns child route content when the project exists and the caller is an admin or project member. + * @returns child route content when the API returns a project and the caller is an admin, Talent Manager, + * or project member. Internal-project restrictions for Talent Managers are enforced by the API. * @remarks Used by project workspace routes so unauthorized users do not mount pages that fetch project child data. * Access decisions use cached project data when available, so SWR revalidation errors do not block authorized users. * @throws Does not throw; missing project access renders the standard project access denial message. 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 374b165b7..758e79d82 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.spec.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.spec.ts @@ -149,7 +149,7 @@ describe('permissions.utils project management helpers', () => { .toBe('manager') }) - it('allows project workspace access for admins and project members only', () => { + it('allows project workspace access for admins and members while restricting other non-members', () => { expect(checkProjectAccess(['administrator'], '999', managedProject)) .toBe(true) expect(checkProjectAccess(['Project Manager'], '123', managedProject)) @@ -160,6 +160,44 @@ describe('permissions.utils project management helpers', () => { .toBe(false) }) + it.each([ + 'Talent Manager', + 'Topcoder Talent Manager', + ' TALENT MANAGER ', + ' topcoder talent manager ', + ])('allows %s to view an API-authorized project without membership', role => { + expect(checkProjectAccess([role], '999', managedProject)) + .toBe(true) + expect(checkProjectAccess([role], '999', { ...managedProject, members: [] })) + .toBe(true) + expect(checkProjectAccess([role], '999', undefined)) + .toBe(false) + expect(checkCanManageProject([role], '999', managedProject)) + .toBe(false) + expect(checkCanEditProjectDetails([role], '999', managedProject)) + .toBe(false) + expect(canModifyChallenge({ + challenge, + hasChallengeResourceWriteAccess: false, + loginUserInfo: { userId: 999 }, + project: managedProject, + userRoles: [role], + })) + .toBe(false) + }) + + it.each([ + 'copilot', + 'Topcoder User', + 'Project Manager', + 'Task Manager', + ])('requires membership for %s project workspace access', role => { + expect(checkProjectAccess([role], '999', managedProject)) + .toBe(false) + expect(checkProjectAccess([role], '123', managedProject)) + .toBe(true) + }) + it('allows challenge modification for admins and the normalized challenge creator', () => { expect(canModifyChallenge({ challenge, diff --git a/src/apps/work/src/lib/utils/permissions.utils.ts b/src/apps/work/src/lib/utils/permissions.utils.ts index d9827d124..835d849c7 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.ts @@ -362,14 +362,16 @@ export function checkProjectMembership( /** * Returns whether the caller can open project-scoped workspace pages. * - * Admins can access every project. Other work-app users must be listed in the - * project's membership payload before project details or child records can be - * displayed. + * Admins and Talent Managers can open projects returned by the projects API + * without membership. The API enforces Talent Manager membership for internal + * projects. Other work-app users must be listed in the project's membership + * payload before project details or child records can be displayed. * * @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 whose access should be evaluated. * @returns `true` when the caller may view the project workspace; otherwise `false`. + * @throws Does not throw; an unavailable project returns `false`. */ export function checkProjectAccess( userRoles: string[], @@ -380,7 +382,9 @@ export function checkProjectAccess( return false } - return hasAdminRole(userRoles) || checkProjectMembership(project, userId) + return hasAdminRole(userRoles) + || checkTalentManager(userRoles) + || checkProjectMembership(project, userId) } /** diff --git a/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx index 78da4ab8c..c26c23747 100644 --- a/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengesListPage/ChallengesListPage.spec.tsx @@ -29,6 +29,18 @@ import { ChallengesListPage } from './ChallengesListPage' var mockWorkAppContext: Context +jest.mock('~/config', () => ({ + EnvironmentConfig: new Proxy({}, { + get: (): string => 'https://www.topcoder-dev.com', + }), +}), { virtual: true }) +jest.mock('@topcoder-platform/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
, }), { @@ -358,6 +370,39 @@ describe('ChallengesListPage', () => { .toBe('/projects/200/edit') }) + it.each(['Talent Manager', 'Topcoder Talent Manager'])('loads project challenges for non-member %s users', role => { + mockedCheckProjectAccess.mockImplementation(jest.requireActual('../../../lib/utils/permissions.utils') + .checkProjectAccess) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + project: { + id: 200, + members: [{ userId: 99999 }], + name: 'Non-internal Project', + status: 'active', + }, + }) + + renderPage('/projects/200/challenges', '/projects/:projectId/challenges', { + ...defaultContextValue, + isCopilot: false, + isManager: true, + userRoles: [role], + }) + + expect(mockedUseFetchChallenges) + .toHaveBeenCalledWith(expect.objectContaining({ + enabled: true, + memberId: undefined, + projectId: '200', + })) + expect(screen.getByText('Challenges Table')) + .toBeTruthy() + expect(screen.queryByText('You don’t have access to this project. Please contact support@topcoder.com.')) + .toBeNull() + }) + it('waits for project access before fetching project challenges', () => { mockedUseFetchProject.mockReturnValue({ error: undefined,