From 4f6543748ab1105eb216863e158eac88750f8a66 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Mon, 5 Oct 2026 11:05:37 +1100 Subject: [PATCH] PM-6515 Make the Assets Library read-only for non-member Talent Managers Talent Managers can now open non-internal projects without membership, and projects-api-v6 PR #50 lets them read the project's attachments. The Assets Library still showed "Add New File" / "Add New Link" to them, but the API rejects their attachment creates, so a file picked through Filestack would upload and then fail to save. Add checkCanAddProjectAssets (admins and project members) and hide the add actions for other viewers. Co-Authored-By: Claude Opus 5.5 --- src/apps/work/README.md | 4 + .../src/lib/utils/permissions.utils.spec.ts | 16 ++++ .../work/src/lib/utils/permissions.utils.ts | 25 ++++++ .../ProjectAssetsPage.spec.tsx | 81 +++++++++++++++++++ .../ProjectAssetsPage/ProjectAssetsPage.tsx | 32 +++++--- 5 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/apps/work/README.md b/src/apps/work/README.md index 53976ba85..9f5d6db16 100644 --- a/src/apps/work/README.md +++ b/src/apps/work/README.md @@ -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 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 758e79d82..e58085f4c 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.spec.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.spec.ts @@ -10,6 +10,7 @@ import { canManageEngagementManagers, canModifyChallenge, canViewAllEngagements, + checkCanAddProjectAssets, checkCanEditProjectDetails, checkCanManageProject, checkIsUserInvitedToProject, @@ -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, diff --git a/src/apps/work/src/lib/utils/permissions.utils.ts b/src/apps/work/src/lib/utils/permissions.utils.ts index 835d849c7..7ece7abf5 100644 --- a/src/apps/work/src/lib/utils/permissions.utils.ts +++ b/src/apps/work/src/lib/utils/permissions.utils.ts @@ -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, diff --git a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx index 4a53baa5c..0e8f2515e 100644 --- a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx +++ b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.spec.tsx @@ -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, @@ -15,6 +16,7 @@ import { useFetchProjectMembers, } from '../../../lib/hooks' import { + checkCanAddProjectAssets, checkCanEditProjectDetails, checkCanManageProject, } from '../../../lib/utils' @@ -121,6 +123,7 @@ jest.mock('../../../lib/services', () => ({ updateProjectAttachment: jest.fn(), })) jest.mock('../../../lib/utils', () => ({ + checkCanAddProjectAssets: jest.fn(() => true), checkCanEditProjectDetails: jest.fn(() => false), checkCanManageProject: jest.fn(() => false), })) @@ -128,6 +131,7 @@ jest.mock('../../../lib/utils', () => ({ 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 @@ -168,6 +172,7 @@ function renderPage( describe('ProjectAssetsPage', () => { beforeEach(() => { jest.clearAllMocks() + mockedCheckCanAddProjectAssets.mockReturnValue(true) mockedCheckCanEditProjectDetails.mockReturnValue(false) mockedCheckCanManageProject.mockReturnValue(false) @@ -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({ diff --git a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx index 4f486ac03..2513b3247 100644 --- a/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx +++ b/src/apps/work/src/pages/assets/ProjectAssetsPage/ProjectAssetsPage.tsx @@ -59,6 +59,7 @@ import { updateProjectAttachment, } from '../../../lib/services' import { + checkCanAddProjectAssets, checkCanEditProjectDetails, checkCanManageProject, } from '../../../lib/utils' @@ -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('files') const [isOpeningPicker, setIsOpeningPicker] = useState(false) @@ -836,17 +842,21 @@ export const ProjectAssetsPage: FC = () => {

Assets Library

-