From fe37f50921b148a2190a1777f48582790ec39a77 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 00:56:48 +1100 Subject: [PATCH 1/2] PM-4645: route invited project access to invitation modal What was broken Opening an invited project from the Work Manager projects list always routed users to the project challenges page, so the join/decline invitation modal did not appear. Root cause Project list navigation always used the default challenges path and never checked whether the current user was only a pending invitee for that project. What was changed Added an invite-aware project landing path helper that reuses the existing invite ownership matcher, and updated the projects table/card links to send invited users to /projects/:projectId/invitations instead of /projects/:projectId/challenges. Any added/updated tests Added navigation.utils.spec.ts coverage for invited-user and non-invited-user project landing paths, and kept the related permissions utils tests passing. --- .../components/ProjectCard/ProjectCard.tsx | 22 +++++- .../ProjectsTable/ProjectsTable.tsx | 26 ++++-- .../src/lib/utils/navigation.utils.spec.ts | 79 +++++++++++++++++++ .../work/src/lib/utils/navigation.utils.ts | 21 +++++ 4 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 src/apps/work/src/lib/utils/navigation.utils.spec.ts diff --git a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx index 5d01d8501..4c8d2fd2f 100644 --- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx +++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx @@ -1,9 +1,20 @@ -import { FC } from 'react' +import { + FC, + useContext, +} from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' -import { Project } from '../../models' -import { formatDateTime } from '../../utils' +import { WorkAppContext } from '../../contexts' +import { + Project, + WorkAppContextModel, +} from '../../models' +import { + buildProjectLandingPath, + formatDateTime, + getAuthAccessToken, +} from '../../utils' import { ProjectStatus } from '../ProjectStatus' import styles from './ProjectCard.module.scss' @@ -16,8 +27,11 @@ interface ProjectCardProps { export const ProjectCard: FC = (props: ProjectCardProps) => { const project: Project = props.project + const { + loginUserInfo, + }: WorkAppContextModel = useContext(WorkAppContext) const projectId: string = String(project.id) - const path: string = `/projects/${projectId}/challenges` + const path: string = buildProjectLandingPath(project, getAuthAccessToken(loginUserInfo)) const editPath = `/projects/${projectId}/edit` const lastActivity = formatDateTime( diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx index 7da3eb285..d1adffa12 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx @@ -1,6 +1,7 @@ import { FC, useCallback, + useContext, useMemo, } from 'react' import { Link } from 'react-router-dom' @@ -13,12 +14,18 @@ import { } from '~/libs/ui' import { PROJECT_STATUS } from '../../constants' +import { WorkAppContext } from '../../contexts' import { Project, ProjectStatusValue, + WorkAppContextModel, } from '../../models' import { useFetchBillingAccounts } from '../../hooks' import type { UseFetchBillingAccountsResult } from '../../hooks' +import { + buildProjectLandingPath, + getAuthAccessToken, +} from '../../utils' import { ProjectCard } from '../ProjectCard' import { ProjectStatus } from '../ProjectStatus' @@ -82,10 +89,8 @@ interface ProjectsTableProps { onSort: (fieldName: string) => void } -function getProjectPath(project: Project): string { - const projectId = String(project.id) - - return `/projects/${projectId}/challenges` +function getProjectPath(project: Project, accessToken: string): string { + return buildProjectLandingPath(project, accessToken) } function getBillingAccountDisplay( @@ -116,9 +121,16 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) const onSort: (fieldName: string) => void = props.onSort const sortBy: string = props.sortBy const sortOrder: SortOrder = props.sortOrder + const { + loginUserInfo, + }: WorkAppContextModel = useContext(WorkAppContext) const { billingAccounts, }: UseFetchBillingAccountsResult = useFetchBillingAccounts() + const accessToken = useMemo( + () => getAuthAccessToken(loginUserInfo), + [loginUserInfo], + ) const billingAccountNames = useMemo( () => new Map( billingAccounts.map(account => ([ @@ -135,7 +147,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) label: 'Project Name', propertyName: 'name', renderer: (project: Project) => { - const path = getProjectPath(project) + const path = getProjectPath(project, accessToken) return ( @@ -171,7 +183,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) isSortable: false, label: 'Actions', renderer: (project: Project) => { - const projectPath = getProjectPath(project) + const projectPath = getProjectPath(project, accessToken) const editPath = `/projects/${project.id}/edit` const canEdit = canEditProject(project) @@ -193,7 +205,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) type: 'action', }, ], - [billingAccountNames, canEditProject], + [accessToken, billingAccountNames, canEditProject], ) const forceSort = useMemo( diff --git a/src/apps/work/src/lib/utils/navigation.utils.spec.ts b/src/apps/work/src/lib/utils/navigation.utils.spec.ts new file mode 100644 index 000000000..ad39d5ebe --- /dev/null +++ b/src/apps/work/src/lib/utils/navigation.utils.spec.ts @@ -0,0 +1,79 @@ +import { decodeToken } from 'tc-auth-lib' + +import type { Project } from '../models' + +import { buildProjectLandingPath } from './navigation.utils' + +jest.mock('tc-auth-lib', () => ({ + decodeToken: jest.fn(), +})) + +jest.mock('~/config', () => ({ + AppSubdomain: { + work: 'work', + }, + EnvironmentConfig: new Proxy({}, { + get: (_target, property: string): string => { + if (property === 'SUBDOMAIN') { + return 'work' + } + + return 'https://www.topcoder-dev.com' + }, + }), +}), { virtual: true }) + +jest.mock('../services/resources.service', () => ({ + fetchResourceRoles: jest.fn(), + fetchResources: jest.fn(), +})) + +const mockedDecodeToken = decodeToken as jest.MockedFunction + +describe('buildProjectLandingPath', () => { + const project: Project = { + id: '200', + name: 'Invited project', + status: 'active', + } + + afterEach(() => { + mockedDecodeToken.mockReset() + }) + + it('routes the invited user to the invitation modal path', () => { + mockedDecodeToken.mockReturnValue({ + email: 'invitee@example.com', + userId: '123', + } as ReturnType) + + expect(buildProjectLandingPath({ + ...project, + invites: [ + { + email: 'INVITEE@EXAMPLE.COM', + userId: 123, + }, + ], + }, 'token')) + .toBe('/projects/200/invitations') + }) + + it('keeps the challenges path when the invites belong to another user', () => { + mockedDecodeToken.mockReturnValue({ + email: 'manager@example.com', + userId: '999', + } as ReturnType) + + expect(buildProjectLandingPath({ + ...project, + invites: [ + { + email: 'invitee@example.com', + userId: 123, + }, + ], + }, 'token')) + .toBe('/projects/200/challenges') + }) +}) diff --git a/src/apps/work/src/lib/utils/navigation.utils.ts b/src/apps/work/src/lib/utils/navigation.utils.ts index 438cad676..058179dcd 100644 --- a/src/apps/work/src/lib/utils/navigation.utils.ts +++ b/src/apps/work/src/lib/utils/navigation.utils.ts @@ -3,6 +3,7 @@ import { DIRECT_PROJECT_URL, REVIEW_APP_URL, } from '../constants' +import type { Project } from '../models' import { challengeEditRouteId, projectEditRouteId, @@ -10,6 +11,8 @@ import { taasEditRouteId, } from '../../config/routes.config' +import { checkIsUserInvitedToProject } from './permissions.utils' + interface QueryValueMap { [key: string]: boolean | null | number | string | string[] | undefined } @@ -82,3 +85,21 @@ export function buildCommunityMemberUrl(handle: string): string { export function buildDirectProjectUrl(projectId: string | number): string { return `${DIRECT_PROJECT_URL}/projects/${encodeURIComponent(String(projectId))}` } + +/** + * Resolves the default in-app landing route for a project. + * + * Invited users must land on the invitation route so they can accept or decline + * the project before the app opens a project workspace tab. + * + * @param project The project summary or detail being opened. + * @param accessToken The current user's access token used to match invite ownership. + * @returns The relative route for the project landing page. + */ +export function buildProjectLandingPath(project: Project, accessToken: string = ''): string { + const projectId = encodeURIComponent(String(project.id)) + + return checkIsUserInvitedToProject(accessToken, project) + ? `/projects/${projectId}/invitations` + : `/projects/${projectId}/challenges` +} From 8403938cab3e576dae2db21cdff4c0aa3b683a13 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 2 Apr 2026 07:46:53 +1100 Subject: [PATCH 2/2] Only route pending invites to invitations --- .../src/lib/utils/navigation.utils.spec.ts | 20 +++++++++++++++++++ .../work/src/lib/utils/navigation.utils.ts | 11 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/apps/work/src/lib/utils/navigation.utils.spec.ts b/src/apps/work/src/lib/utils/navigation.utils.spec.ts index ad39d5ebe..24f43edea 100644 --- a/src/apps/work/src/lib/utils/navigation.utils.spec.ts +++ b/src/apps/work/src/lib/utils/navigation.utils.spec.ts @@ -52,6 +52,7 @@ describe('buildProjectLandingPath', () => { invites: [ { email: 'INVITEE@EXAMPLE.COM', + status: 'pending', userId: 123, }, ], @@ -59,6 +60,25 @@ describe('buildProjectLandingPath', () => { .toBe('/projects/200/invitations') }) + it('keeps the challenges path when the matched invite is already accepted', () => { + mockedDecodeToken.mockReturnValue({ + email: 'invitee@example.com', + userId: '123', + } as ReturnType) + + expect(buildProjectLandingPath({ + ...project, + invites: [ + { + email: 'invitee@example.com', + status: 'accepted', + userId: 123, + }, + ], + }, 'token')) + .toBe('/projects/200/challenges') + }) + it('keeps the challenges path when the invites belong to another user', () => { mockedDecodeToken.mockReturnValue({ email: 'manager@example.com', diff --git a/src/apps/work/src/lib/utils/navigation.utils.ts b/src/apps/work/src/lib/utils/navigation.utils.ts index 058179dcd..4e06eb0f2 100644 --- a/src/apps/work/src/lib/utils/navigation.utils.ts +++ b/src/apps/work/src/lib/utils/navigation.utils.ts @@ -4,6 +4,7 @@ import { REVIEW_APP_URL, } from '../constants' import type { Project } from '../models' +import { PROJECT_MEMBER_INVITE_STATUS } from '../constants/project-roles.constants' import { challengeEditRouteId, projectEditRouteId, @@ -89,8 +90,8 @@ export function buildDirectProjectUrl(projectId: string | number): string { /** * Resolves the default in-app landing route for a project. * - * Invited users must land on the invitation route so they can accept or decline - * the project before the app opens a project workspace tab. + * Users with a pending invite must land on the invitation route so they can + * accept or decline the project before the app opens a project workspace tab. * * @param project The project summary or detail being opened. * @param accessToken The current user's access token used to match invite ownership. @@ -98,8 +99,12 @@ export function buildDirectProjectUrl(projectId: string | number): string { */ export function buildProjectLandingPath(project: Project, accessToken: string = ''): string { const projectId = encodeURIComponent(String(project.id)) + const invite = checkIsUserInvitedToProject(accessToken, project) + const normalizedInviteStatus = invite?.status + ?.trim() + .toLowerCase() - return checkIsUserInvitedToProject(accessToken, project) + return normalizedInviteStatus === PROJECT_MEMBER_INVITE_STATUS.PENDING ? `/projects/${projectId}/invitations` : `/projects/${projectId}/challenges` }