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
4 changes: 4 additions & 0 deletions src/apps/work/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ 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.

Non-member Talent Managers see the Users tab and Assets Library read-only.
`checkCanAddProjectAssets` limits the Assets Library's add file and add link
actions to Administrators and project members.

## Navigation styles

The Work subnavigation uses regular black text and a bold active item on desktop
Expand Down
16 changes: 16 additions & 0 deletions src/apps/work/src/lib/utils/permissions.utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
canManageEngagementManagers,
canModifyChallenge,
canViewAllEngagements,
checkCanAddProjectAssets,
checkCanEditProjectDetails,
checkCanManageProject,
checkIsUserInvitedToProject,
Expand Down Expand Up @@ -198,6 +199,21 @@ describe('permissions.utils project management helpers', () => {
.toBe(true)
})

it('shows project asset add actions only to admins and project members', () => {
expect(checkCanAddProjectAssets(['administrator'], '999', managedProject))
.toBe(true)
expect(checkCanAddProjectAssets(['Talent Manager'], '456', managedProject))
.toBe(true)
expect(checkCanAddProjectAssets(['Topcoder User'], 789, managedProject))
.toBe(true)
expect(checkCanAddProjectAssets(['Talent Manager'], '999', managedProject))
.toBe(false)
expect(checkCanAddProjectAssets(['Topcoder Talent Manager'], '999', managedProject))
.toBe(false)
expect(checkCanAddProjectAssets(['administrator'], '999', undefined))
.toBe(false)
})

it('allows challenge modification for admins and the normalized challenge creator', () => {
expect(canModifyChallenge({
challenge,
Expand Down
25 changes: 25 additions & 0 deletions src/apps/work/src/lib/utils/permissions.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,31 @@ export function checkCanEditProjectDetails(
return normalizedRole === PROJECT_ROLES.MANAGER
}

/**
* Returns whether the caller can add files or links to a project's Assets Library.
*
* Admins and project members keep the add actions. Non-member viewers, such as
* Talent Managers opening a non-internal project, get a read-only library
* because the projects API rejects their attachment create requests.
*
* @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 Assets Library is displayed.
* @returns `true` when the add file and add link actions should be shown; otherwise `false`.
* @throws Does not throw; an unavailable project returns `false`.
*/
export function checkCanAddProjectAssets(
userRoles: string[],
userId: number | string | undefined,
project: Project | undefined,
): boolean {
if (!project) {
return false
}

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

export function checkAdminOrPmOrTaskManager(
token: string,
project?: Project,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */
import type { Context, PropsWithChildren, ReactNode } from 'react'
import {
fireEvent,
render,
screen,
within,
Expand All @@ -15,6 +16,7 @@ import {
useFetchProjectMembers,
} from '../../../lib/hooks'
import {
checkCanAddProjectAssets,
checkCanEditProjectDetails,
checkCanManageProject,
} from '../../../lib/utils'
Expand Down Expand Up @@ -121,13 +123,15 @@ jest.mock('../../../lib/services', () => ({
updateProjectAttachment: jest.fn(),
}))
jest.mock('../../../lib/utils', () => ({
checkCanAddProjectAssets: jest.fn(() => true),
checkCanEditProjectDetails: jest.fn(() => false),
checkCanManageProject: jest.fn(() => false),
}))

const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchProjectAttachments = useFetchProjectAttachments as jest.Mock
const mockedUseFetchProjectMembers = useFetchProjectMembers as jest.Mock
const mockedCheckCanAddProjectAssets = checkCanAddProjectAssets as jest.Mock
const mockedCheckCanEditProjectDetails = checkCanEditProjectDetails as jest.Mock
const mockedCheckCanManageProject = checkCanManageProject as jest.Mock

Expand Down Expand Up @@ -168,6 +172,7 @@ function renderPage(
describe('ProjectAssetsPage', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedCheckCanAddProjectAssets.mockReturnValue(true)
mockedCheckCanEditProjectDetails.mockReturnValue(false)
mockedCheckCanManageProject.mockReturnValue(false)

Expand Down Expand Up @@ -212,6 +217,82 @@ describe('ProjectAssetsPage', () => {
.toBeTruthy()
})

it('shows add actions to users who can add project assets', () => {
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
mutate: jest.fn(),
project: {
id: 200,
name: 'Payment Testing',
},
})

renderPage('/projects/200/assets')

expect(screen.getByRole('button', { name: 'Add New File' }))
.toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: /^Links/ }))
expect(screen.getByRole('button', { name: 'Add New Link' }))
.toBeTruthy()
})

it.each(['Talent Manager', 'Topcoder Talent Manager'])(
'shows a read-only assets library to non-member %s users',
role => {
mockedCheckCanAddProjectAssets.mockReturnValue(false)
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
mutate: jest.fn(),
project: {
id: 200,
members: [{ role: 'manager', userId: 99999 }],
name: 'Non-internal Project',
},
})
mockedUseFetchProjectAttachments.mockReturnValue({
attachments: [{
allowedUsers: [],
createdBy: '99999',
id: 1,
path: 'https://example.com/spec',
title: 'Project Spec',
type: 'link',
}],
error: undefined,
isLoading: false,
mutate: jest.fn(),
})
const loginUserInfo = {
email: 'tm@example.com',
exp: 0,
handle: 'tm-user',
iat: 0,
roles: [role],
userId: 12345,
} as WorkAppContextModel['loginUserInfo']

renderPage('/projects/200/assets', {
...defaultContextValue,
isAdmin: false,
isManager: true,
loginUserInfo,
userRoles: [role],
})

expect(mockedCheckCanAddProjectAssets)
.toHaveBeenCalledWith([role], 12345, expect.objectContaining({ id: 200 }))
expect(screen.queryByRole('button', { name: 'Add New File' }))
.toBeNull()
fireEvent.click(screen.getByRole('button', { name: /^Links/ }))
expect(screen.getByText('Project Spec'))
.toBeTruthy()
expect(screen.queryByRole('button', { name: 'Add New Link' }))
.toBeNull()
},
)

it('hides project edit action when a copilot can manage but cannot edit project details', () => {
mockedCheckCanManageProject.mockReturnValue(true)
mockedUseFetchProject.mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
updateProjectAttachment,
} from '../../../lib/services'
import {
checkCanAddProjectAssets,
checkCanEditProjectDetails,
checkCanManageProject,
} from '../../../lib/utils'
Expand Down Expand Up @@ -333,6 +334,11 @@ export const ProjectAssetsPage: FC = () => {
workAppContext.loginUserInfo?.userId,
projectResult.project,
)
const canAddAssets = checkCanAddProjectAssets(
workAppContext.userRoles,
workAppContext.loginUserInfo?.userId,
projectResult.project,
)

const [activeTab, setActiveTab] = useState<AssetsTab>('files')
const [isOpeningPicker, setIsOpeningPicker] = useState<boolean>(false)
Expand Down Expand Up @@ -836,17 +842,21 @@ export const ProjectAssetsPage: FC = () => {
<h4 className={styles.sectionTitle}>Assets Library</h4>

<div className={styles.headerActions}>
<Button
label={activeTab === 'files'
? 'Add New File'
: 'Add New Link'}
onClick={activeTab === 'files'
? handleOpenFilePicker
: handleOpenAddLinkModal}
primary
size='md'
disabled={isOpeningPicker}
/>
{canAddAssets
? (
<Button
label={activeTab === 'files'
? 'Add New File'
: 'Add New Link'}
onClick={activeTab === 'files'
? handleOpenFilePicker
: handleOpenAddLinkModal}
primary
size='md'
disabled={isOpeningPicker}
/>
)
: undefined}
<Link className={styles.backLink} to={`/projects/${projectId}/challenges`}>
<Button
label='Back'
Expand Down
Loading