Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/apps/work/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ import {

var mockWorkAppContext: Context<WorkAppContextModel>

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<{
Expand Down Expand Up @@ -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'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 39 additions & 1 deletion src/apps/work/src/lib/utils/permissions.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions src/apps/work/src/lib/utils/permissions.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -380,7 +382,9 @@ export function checkProjectAccess(
return false
}

return hasAdminRole(userRoles) || checkProjectMembership(project, userId)
return hasAdminRole(userRoles)
|| checkTalentManager(userRoles)
|| checkProjectMembership(project, userId)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ import { ChallengesListPage } from './ChallengesListPage'

var mockWorkAppContext: Context<WorkAppContextModel>

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: () => <div>Loading</div>,
}), {
Expand Down Expand Up @@ -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,
Expand Down
Loading