diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx index b924335c7..b791ed7d2 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.spec.tsx @@ -22,6 +22,7 @@ import { useFetchResources, type UseFetchChallengeResult, } from '../../../lib/hooks' +import type { WorkAppContextModel } from '../../../lib/models' import { deleteChallenge } from '../../../lib/services' import { checkProjectAccess, @@ -34,15 +35,7 @@ import { import { ChallengeEditorPage } from './ChallengeEditorPage' -var mockWorkAppContext: Context<{ - isAdmin: boolean - isAnonymous: boolean - isCopilot: boolean - isManager: boolean - isReadOnly: boolean - loginUserInfo: undefined - userRoles: string[] -}> +var mockWorkAppContext: Context jest.mock('~/apps/review/src/lib', () => ({ PageWrapper: ( @@ -142,15 +135,7 @@ jest.mock('../../../lib/constants', () => ({ jest.mock('../../../lib/contexts', () => { const React = require('react') as typeof import('react') - mockWorkAppContext = React.createContext<{ - isAdmin: boolean - isAnonymous: boolean - isCopilot: boolean - isManager: boolean - isReadOnly: boolean - loginUserInfo: undefined - userRoles: string[] - }>({ + mockWorkAppContext = React.createContext({ isAdmin: false, isAnonymous: false, isCopilot: false, @@ -288,7 +273,11 @@ function renderPageElement(route: string, path: string): JSX.Element { isCopilot: false, isManager: true, isReadOnly: false, - loginUserInfo: undefined, + loginUserInfo: { + handle: 'current-user', + roles: ['manager'], + userId: 12345, + }, userRoles: ['manager'], }} > @@ -345,6 +334,7 @@ describe('ChallengeEditorPage', () => { }) mockedCheckProjectAccess.mockReturnValue(true) mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, resourceRoles: [], }) mockedUseFetchResources.mockReturnValue({ @@ -552,7 +542,108 @@ describe('ChallengeEditorPage', () => { .toBe(false) }) - it('blocks project-scoped challenge views when project access is denied', async () => { + it('allows project-scoped challenge views when the user has challenge resource read access', async () => { + mockedCheckProjectAccess.mockReturnValue(false) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + project: { + id: '123', + members: [{ + userId: 99999, + }], + name: 'Restricted Project', + status: 'active', + }, + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [{ + fullReadAccess: true, + fullWriteAccess: false, + id: 'problem-tester-role', + isActive: true, + name: 'Problem Tester', + }], + }) + mockedUseFetchResources.mockReturnValue({ + isLoading: false, + resources: [{ + challengeId: '456', + memberId: '12345', + roleId: 'problem-tester-role', + }], + }) + + renderPage( + '/projects/123/challenges/456/view', + '/projects/:projectId/challenges/:challengeId/view', + ) + + await waitFor(() => { + expect(screen.getByText('Challenge View Form')) + .toBeTruthy() + }) + + expect(mockedUseFetchChallenge) + .toHaveBeenCalledWith('456') + expect(screen.queryByText('You don’t have access to this project. Please contact support@topcoder.com.')) + .toBeNull() + expect(screen.queryByRole('button', { name: 'Edit' })) + .toBeNull() + }) + + it( + 'shows edit action for project-scoped challenge views when the challenge resource grants write access', + async () => { + mockedCheckProjectAccess.mockReturnValue(false) + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + project: { + id: '123', + members: [{ + userId: 99999, + }], + name: 'Restricted Project', + status: 'active', + }, + }) + mockedUseFetchResourceRoles.mockReturnValue({ + isLoading: false, + resourceRoles: [{ + fullReadAccess: true, + fullWriteAccess: true, + id: 'problem-writer-role', + isActive: true, + name: 'Problem Writer', + }], + }) + mockedUseFetchResources.mockReturnValue({ + isLoading: false, + resources: [{ + challengeId: '456', + memberId: '12345', + roleId: 'problem-writer-role', + }], + }) + + renderPage( + '/projects/123/challenges/456/view', + '/projects/:projectId/challenges/:challengeId/view', + ) + + await waitFor(() => { + expect(screen.getByText('Challenge View Form')) + .toBeTruthy() + }) + + expect(screen.getByRole('button', { name: 'Edit' })) + .toBeTruthy() + }, + ) + + it('blocks project-scoped challenge views when project and challenge resource access are denied', async () => { mockedCheckProjectAccess.mockReturnValue(false) mockedUseFetchProject.mockReturnValue({ error: undefined, @@ -578,7 +669,7 @@ describe('ChallengeEditorPage', () => { }) expect(mockedUseFetchChallenge) - .toHaveBeenCalledWith(undefined) + .toHaveBeenCalledWith('456') expect(screen.queryByText('Challenge View Form')) .toBeNull() expect(screen.queryByRole('heading', { name: 'View Edit test' })) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx index b1eb06770..404e5af9e 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/ChallengeEditorPage.tsx @@ -43,6 +43,10 @@ import type { UseFetchChallengeResult, UseFetchProjectResult, } from '../../../lib/hooks' +import type { + Resource, + ResourceRole, +} from '../../../lib/models' import { deleteChallenge, patchChallenge, @@ -168,6 +172,11 @@ interface ChallengeProjectAccessState { isLoading: boolean } +interface ChallengeResourceAccessState { + canRead: boolean + canWrite: boolean +} + /** * Normalizes a project identifier from route, challenge, or created challenge data. * @@ -185,6 +194,64 @@ function normalizeProjectId(projectId: number | string | undefined): string | un return normalizedProjectId || undefined } +/** + * Normalizes a user identifier for challenge resource membership comparisons. + * + * @param userId user id from auth context or a resource API payload. + * @returns trimmed user id text when present; otherwise an empty string. + * @throws Does not throw. + */ +function normalizeUserId(userId: number | string | undefined): string { + if (userId === undefined || userId === null) { + return '' + } + + return String(userId) + .trim() +} + +/** + * Resolves challenge-level read and write access from the current user's resource roles. + * + * @param resources resources assigned on the challenge. + * @param resourceRoles resource role metadata, including read and write flags. + * @param userId current user id from the work app context. + * @returns read/write access flags granted by active challenge resource roles. + * @throws Does not throw. + */ +function resolveChallengeResourceAccess( + resources: Resource[], + resourceRoles: ResourceRole[], + userId: number | string | undefined, +): ChallengeResourceAccessState { + const normalizedUserId = normalizeUserId(userId) + + if (!normalizedUserId) { + return { + canRead: false, + canWrite: false, + } + } + + const userResourceRoleIds = new Set(resources + .filter(resource => normalizeUserId(resource.memberId) === normalizedUserId) + .map(resource => resource.roleId)) + + return resourceRoles.reduce((accessState, resourceRole) => { + if (!userResourceRoleIds.has(resourceRole.id) || resourceRole.isActive === false) { + return accessState + } + + return { + canRead: accessState.canRead || resourceRole.fullReadAccess === true, + canWrite: accessState.canWrite || resourceRole.fullWriteAccess === true, + } + }, { + canRead: false, + canWrite: false, + }) +} + /** * Resolves whether challenge editor content can render for a project-owned challenge. * @@ -1085,21 +1152,7 @@ export const ChallengeEditorPage: FC = () => { const [showLaunchModal, setShowLaunchModal] = useState(false) const workAppContext = useContext(WorkAppContext) const routeProjectResult: UseFetchProjectResult = useFetchProject(routeProjectId) - const canFetchProjectRouteChallenge = !routeProjectId - || ( - !routeProjectResult.isLoading - && !routeProjectResult.error - && checkProjectAccess( - workAppContext.userRoles, - workAppContext.loginUserInfo?.userId, - routeProjectResult.project, - ) - ) - const challengeResult: UseFetchChallengeResult = useFetchChallenge( - canFetchProjectRouteChallenge - ? challengeId - : undefined, - ) + const challengeResult: UseFetchChallengeResult = useFetchChallenge(challengeId) const [ challengeStatus, handleChallengeStatusChange, @@ -1329,7 +1382,7 @@ export const ChallengeEditorPage: FC = () => { const hasChallengeProjectMismatch = !!routeProjectId && !!challengeProjectId && routeProjectId !== challengeProjectId - const projectAccessState = resolveChallengeProjectAccess({ + const baseProjectAccessState = resolveChallengeProjectAccess({ hasChallengeProjectMismatch, isProjectLoading: projectAccessResult.isLoading, project: projectAccessResult.project, @@ -1338,6 +1391,44 @@ export const ChallengeEditorPage: FC = () => { userId: workAppContext.loginUserInfo?.userId, userRoles: workAppContext.userRoles, }) + const shouldResolveChallengeResourceAccess = isExistingChallenge + && baseProjectAccessState.isDenied + && !hasChallengeProjectMismatch + && !!challengeId + const shouldFetchChallengeResources = shouldResolveChallengeResourceAccess + && !!challengeResult.challenge + const resourcesResult = useFetchResources( + shouldFetchChallengeResources + ? challengeId + : undefined, + ) + const resourceRolesResult = useFetchResourceRoles() + const challengeResourceAccess = resolveChallengeResourceAccess( + resourcesResult.resources, + resourceRolesResult.resourceRoles, + workAppContext.loginUserInfo?.userId, + ) + const hasChallengeResourceRouteAccess = isViewMode + ? challengeResourceAccess.canRead + : challengeResourceAccess.canWrite + const isChallengeResourceAccessLoading = shouldResolveChallengeResourceAccess + && ( + challengeResult.isLoading + || ( + !!challengeResult.challenge + && ( + resourcesResult.isLoading + || resourceRolesResult.isLoading + ) + ) + ) + const projectAccessState = { + isDenied: baseProjectAccessState.isDenied + && !hasChallengeResourceRouteAccess + && !isChallengeResourceAccessLoading, + isLoading: baseProjectAccessState.isLoading + || isChallengeResourceAccessLoading, + } const canRenderChallengeDetails = !projectAccessState.isDenied && !projectAccessState.isLoading const pageTitle = getChallengeEditorPageTitle( challengeId, @@ -1355,6 +1446,7 @@ export const ChallengeEditorPage: FC = () => { && canRenderChallengeDetails && isViewMode && !!editChallengePath + && (!baseProjectAccessState.isDenied || challengeResourceAccess.canWrite) && !isChallengeCompletedOrCancelled(effectiveChallengeStatus) const rightHeader = renderHeaderAction({ canCancelChallenge: canRenderChallengeDetails && canCancelChallenge,