diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx new file mode 100644 index 000000000..15862142d --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.spec.tsx @@ -0,0 +1,219 @@ +/* eslint-disable no-var, global-require, @typescript-eslint/no-var-requires */ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ +import type { + Context, + PropsWithChildren, +} from 'react' +import { + render, + screen, +} from '@testing-library/react' +import { + MemoryRouter, + Route, + Routes, +} from 'react-router-dom' + +import { WorkAppContextModel } from '../../models' +import { useFetchProject } from '../../hooks' +import { checkProjectAccess } from '../../utils' + +import { + PROJECT_ACCESS_DENIED_MESSAGE, + ProjectRouteAccessGuard, +} from './ProjectRouteAccessGuard' + +var mockWorkAppContext: Context + +jest.mock('~/apps/review/src/lib', () => ({ + PageWrapper: ( + props: PropsWithChildren<{ + pageTitle?: string + }>, + ) => ( +
+

{props.pageTitle}

+
{props.children}
+
+ ), +}), { + virtual: true, +}) +jest.mock('~/libs/ui', () => ({ + Button: (props: { label: string }) => ( + + ), + LoadingSpinner: () =>
Loading Spinner
, +}), { + virtual: true, +}) +jest.mock('../../contexts', () => { + const React = require('react') as typeof import('react') + + mockWorkAppContext = React.createContext({ + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: false, + isReadOnly: false, + loginUserInfo: undefined, + userRoles: [], + }) + + return { + WorkAppContext: mockWorkAppContext, + } +}) +jest.mock('../../hooks', () => ({ + useFetchProject: jest.fn(), +})) +jest.mock('../../utils', () => ({ + checkProjectAccess: jest.fn(), +})) + +const mockedUseFetchProject = useFetchProject as jest.Mock +const mockedCheckProjectAccess = checkProjectAccess as jest.Mock + +const defaultContextValue: WorkAppContextModel = { + isAdmin: false, + isAnonymous: false, + isCopilot: false, + isManager: true, + isReadOnly: false, + loginUserInfo: { + email: 'manager@example.com', + exp: 0, + handle: 'manager-user', + iat: 0, + roles: ['Project Manager'], + userId: 12345, + } as WorkAppContextModel['loginUserInfo'], + userRoles: ['Project Manager'], +} + +function renderGuard( + route: string, + contextValue: WorkAppContextModel = defaultContextValue, +): void { + const MockWorkAppContext = mockWorkAppContext + + render( + + + + +
Protected Project Users
+ + )} + /> +
+
+
, + ) +} + +describe('ProjectRouteAccessGuard', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: false, + mutate: jest.fn(), + project: { + id: 200, + members: [{ + userId: 12345, + }], + }, + }) + mockedCheckProjectAccess.mockReturnValue(true) + }) + + it('renders the protected route when project access is allowed', () => { + renderGuard('/projects/200/users') + + expect(mockedCheckProjectAccess) + .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 })) + expect(screen.getByText('Protected Project Users')) + .toBeTruthy() + }) + + it('renders the protected route when cached project access survives a revalidation error', () => { + mockedUseFetchProject.mockReturnValue({ + error: new Error('Network unavailable'), + isLoading: false, + mutate: jest.fn(), + project: { + id: 200, + members: [{ + userId: 12345, + }], + }, + }) + mockedCheckProjectAccess.mockReturnValue(true) + + renderGuard('/projects/200/users') + + expect(mockedCheckProjectAccess) + .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, expect.objectContaining({ id: 200 })) + expect(screen.getByText('Protected Project Users')) + .toBeTruthy() + expect(screen.queryByText(PROJECT_ACCESS_DENIED_MESSAGE)) + .toBeNull() + }) + + it('shows loading while project access is resolving', () => { + mockedUseFetchProject.mockReturnValue({ + error: undefined, + isLoading: true, + mutate: jest.fn(), + project: undefined, + }) + + renderGuard('/projects/200/users') + + expect(screen.getByRole('heading', { level: 1, name: 'Users' })) + .toBeTruthy() + expect(screen.getByText('Loading Spinner')) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + expect(mockedCheckProjectAccess) + .not.toHaveBeenCalled() + }) + + it('shows the project access denial message when project access is rejected', () => { + mockedCheckProjectAccess.mockReturnValue(false) + + renderGuard('/projects/200/users') + + expect(screen.getByRole('heading', { level: 1, name: 'Users' })) + .toBeTruthy() + expect(screen.getByText(PROJECT_ACCESS_DENIED_MESSAGE)) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + }) + + it('shows the project access denial message when the project fetch fails', () => { + mockedUseFetchProject.mockReturnValue({ + error: new Error('Forbidden'), + isLoading: false, + mutate: jest.fn(), + project: undefined, + }) + mockedCheckProjectAccess.mockReturnValue(false) + + renderGuard('/projects/200/users') + + expect(mockedCheckProjectAccess) + .toHaveBeenCalledWith(defaultContextValue.userRoles, 12345, undefined) + expect(screen.getByText(PROJECT_ACCESS_DENIED_MESSAGE)) + .toBeTruthy() + expect(screen.queryByText('Protected Project Users')) + .toBeNull() + }) +}) diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx new file mode 100644 index 000000000..21dc756f4 --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/ProjectRouteAccessGuard.tsx @@ -0,0 +1,77 @@ +import { + FC, + PropsWithChildren, + useContext, +} from 'react' +import { useParams } from 'react-router-dom' + +import { PageWrapper } from '~/apps/review/src/lib' + +import { WorkAppContext } from '../../contexts' +import { useFetchProject } from '../../hooks' +import { WorkAppContextModel } from '../../models' +import { checkProjectAccess } from '../../utils' +import { ErrorMessage } from '../ErrorMessage' +import { LoadingSpinner } from '../LoadingSpinner' + +export const PROJECT_ACCESS_DENIED_MESSAGE + = 'You don’t have access to this project. Please contact support@topcoder.com.' + +interface ProjectRouteAccessGuardProps extends PropsWithChildren { + pageTitle: string +} + +/** + * 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. + * @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. + */ +export const ProjectRouteAccessGuard: FC = ( + props: ProjectRouteAccessGuardProps, +) => { + const params: Readonly<{ projectId?: string }> = useParams<'projectId'>() + const projectId = params.projectId?.trim() + + const workAppContext = useContext(WorkAppContext) as WorkAppContextModel + const projectResult = useFetchProject(projectId || undefined) + + if (!projectId) { + return <>{props.children} + } + + if (projectResult.isLoading) { + return ( + + + + ) + } + + const hasProjectAccess = checkProjectAccess( + workAppContext.userRoles, + workAppContext.loginUserInfo?.userId, + projectResult.project, + ) + + if (!hasProjectAccess) { + return ( + + + + ) + } + + return <>{props.children} +} + +export default ProjectRouteAccessGuard diff --git a/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts new file mode 100644 index 000000000..c9831bfb9 --- /dev/null +++ b/src/apps/work/src/lib/components/ProjectRouteAccessGuard/index.ts @@ -0,0 +1 @@ +export * from './ProjectRouteAccessGuard' diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index ee606c570..29122df08 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -22,6 +22,7 @@ export * from './Pagination' export * from './ProjectCard' export * from './ProjectBillingAccountExpiredNotice' export * from './ProjectListTabs' +export * from './ProjectRouteAccessGuard' export * from './ProjectStatus' export * from './PaymentFormModal' export * from './PaymentHistoryModal' diff --git a/src/apps/work/src/work-app.routes.tsx b/src/apps/work/src/work-app.routes.tsx index 68758d959..250452f52 100644 --- a/src/apps/work/src/work-app.routes.tsx +++ b/src/apps/work/src/work-app.routes.tsx @@ -39,7 +39,10 @@ import { usersRouteId, } from './config/routes.config' import { WORK_MANAGER_ALLOWED_ROLES } from './config/access.config' -import { ErrorMessage } from './lib/components' +import { + ErrorMessage, + ProjectRouteAccessGuard, +} from './lib/components' import { WorkAppContext } from './lib/contexts' import { WorkAppContextModel } from './lib/models' import { canViewAllEngagements } from './lib/utils' @@ -222,7 +225,11 @@ export const workRoutes: ReadonlyArray = [ }, { authRequired: true, - element: , + element: ( + + + + ), id: projectAssetsRouteId, route: '/projects/:projectId/assets', title: 'Project Assets', @@ -261,48 +268,76 @@ export const workRoutes: ReadonlyArray = [ }, { authRequired: true, - element: , + element: ( + + + + ), route: '/projects/:projectId/engagements', title: 'Engagements', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementCreateRouteId, route: '/projects/:projectId/engagements/new', title: 'Create Engagement', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementEditRouteId, route: '/projects/:projectId/engagements/:engagementId', title: 'Edit Engagement', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementApplicationsRouteId, route: '/projects/:projectId/engagements/:engagementId/applications', title: 'Applications', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementAssignmentsRouteId, route: '/projects/:projectId/engagements/:engagementId/assignments', title: 'Assignments', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementFeedbackRouteId, route: '/projects/:projectId/engagements/:engagementId/assignments/:assignmentId/feedback', title: 'Feedback', }, { authRequired: true, - element: , + element: ( + + + + ), id: engagementExperienceRouteId, route: '/projects/:projectId/engagements/:engagementId/assignments/:assignmentId/experience', title: 'Experience', @@ -349,7 +384,11 @@ export const workRoutes: ReadonlyArray = [ }, { authRequired: true, - element: , + element: ( + + + + ), id: usersRouteId, route: '/projects/:projectId/users', title: 'Users',