From 7beacfaf62ec8115a8ed9e062f95fc81f71a7758 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 07:30:41 +1100 Subject: [PATCH 01/10] Talent manager permissions for BAs (https://topcoder.atlassian.net/browse/PM-4376) --- .../ChallengeList/index.js | 9 +- src/components/ChallengesComponent/index.js | 17 ++- src/components/UpdateBillingAccount/index.js | 16 +-- .../UpdateBillingAccount/index.test.js | 90 +++++++++++++++ src/containers/ProjectEditor/index.js | 12 +- src/containers/Projects/index.js | 4 +- src/util/tc.js | 103 +++++++++++++++++- 7 files changed, 210 insertions(+), 41 deletions(-) create mode 100644 src/components/UpdateBillingAccount/index.test.js diff --git a/src/components/ChallengesComponent/ChallengeList/index.js b/src/components/ChallengesComponent/ChallengeList/index.js index 8de51f21..8398fd4b 100644 --- a/src/components/ChallengesComponent/ChallengeList/index.js +++ b/src/components/ChallengesComponent/ChallengeList/index.js @@ -25,7 +25,7 @@ import Loader from '../../Loader' import UpdateBillingAccount from '../../UpdateBillingAccount' import { CHALLENGE_STATUS, PAGE_SIZE, PAGINATION_PER_PAGE_OPTIONS, PROJECT_ROLES } from '../../../config/constants' -import { checkAdmin, checkManager, checkReadOnlyRoles } from '../../../util/tc' +import { checkAdmin, checkManager, checkProjectMembership, checkReadOnlyRoles } from '../../../util/tc' require('bootstrap/scss/bootstrap.scss') @@ -408,7 +408,8 @@ class ChallengeList extends Component { const isAdmin = checkAdmin(this.props.auth.token) const isManager = checkManager(this.props.auth.token) const loginUserId = this.props.auth.user.userId - const isMemberOfActiveProject = activeProject && activeProject.members && activeProject.members.some(member => member.userId === loginUserId) + const isMemberOfActiveProject = checkProjectMembership(activeProject, loginUserId) + const canManageBillingAccount = isAdmin || (isManager && isMemberOfActiveProject) if (warnMessage) { return @@ -495,12 +496,10 @@ class ChallengeList extends Component { billingStartDate={billingStartDate} billingEndDate={billingEndDate} isBillingAccountExpired={isBillingAccountExpired} - isAdmin={isAdmin} + canManageBillingAccount={canManageBillingAccount} currentBillingAccount={currentBillingAccount} updateProject={updateProject} projectId={activeProject.id} - isMemberOfActiveProject={isMemberOfActiveProject} - isManager={isManager} /> ) : ( diff --git a/src/components/ChallengesComponent/index.js b/src/components/ChallengesComponent/index.js index 4fbe77b6..158025da 100644 --- a/src/components/ChallengesComponent/index.js +++ b/src/components/ChallengesComponent/index.js @@ -2,7 +2,6 @@ * Component to render Challenges page */ import React, { useState, useEffect } from 'react' -import _ from 'lodash' import PropTypes from 'prop-types' import { Helmet } from 'react-helmet' import { Link } from 'react-router-dom' @@ -11,7 +10,7 @@ import { PROJECT_ROLES, PROJECT_STATUS, COPILOTS_URL, CHALLENGE_STATUS } from '. import { PrimaryButton, OutlineButton } from '../Buttons' import ChallengeList from './ChallengeList' import styles from './ChallengesComponent.module.scss' -import { checkAdmin, checkReadOnlyRoles, checkAdminOrCopilot, checkManager } from '../../util/tc' +import { checkAdmin, checkCanManageProject, checkReadOnlyRoles, checkAdminOrCopilot, checkManager, getProjectMemberRole } from '../../util/tc' const ChallengesComponent = ({ challenges, @@ -51,7 +50,8 @@ const ChallengesComponent = ({ }) => { const [loginUserRoleInProject, setLoginUserRoleInProject] = useState('') const isReadOnly = checkReadOnlyRoles(auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ - const isAdminOrCopilot = checkAdminOrCopilot(auth.token, activeProject) + const canManageProject = checkCanManageProject(auth.token, activeProject) + const canAccessProjectAssets = checkAdminOrCopilot(auth.token, activeProject) const projectStatus = activeProject && activeProject.status ? activeProject.status.toUpperCase() @@ -61,10 +61,9 @@ const ChallengesComponent = ({ useEffect(() => { const loggedInUser = auth.user - const projectMembers = activeProject.members - const loginUserProjectInfo = _.find(projectMembers, { userId: loggedInUser.userId }) - if (loginUserProjectInfo && loginUserRoleInProject !== loginUserProjectInfo.role) { - setLoginUserRoleInProject(loginUserProjectInfo.role) + const loginUserProjectRole = getProjectMemberRole(activeProject, loggedInUser.userId) + if (loginUserProjectRole && loginUserRoleInProject !== loginUserProjectRole) { + setLoginUserRoleInProject(loginUserProjectRole) } }, [activeProject, auth]) @@ -77,7 +76,7 @@ const ChallengesComponent = ({ {activeProject ? activeProject.name : ''} {activeProject && activeProject.status && } - {activeProject && activeProject.id && isAdminOrCopilot && ( + {activeProject && activeProject.id && canManageProject && ( ( - {isAdminOrCopilot && ( + {canAccessProjectAssets && ( { const [isEditing, setIsEditing] = useState(false) const [selectedBillingAccount, setSelectedBillingAccount] = useState(null) @@ -131,7 +129,7 @@ const UpdateBillingAccount = ({ !currentBillingAccount && ( No Billing Account set - {(isAdmin || (isManager && isMemberOfActiveProject)) && ( + {canManageBillingAccount && ( {' '} ({' '} @@ -155,7 +153,7 @@ const UpdateBillingAccount = ({ > {isBillingAccountExpired ? 'INACTIVE' : 'ACTIVE'} {' '} - {(isAdmin || (isManager && isMemberOfActiveProject)) && ( + {canManageBillingAccount && ( {' '} ({' '} @@ -187,11 +185,9 @@ UpdateBillingAccount.propTypes = { billingEndDate: PropTypes.string, currentBillingAccount: PropTypes.number, isBillingAccountExpired: PropTypes.bool, - isAdmin: PropTypes.bool, + canManageBillingAccount: PropTypes.bool, projectId: PropTypes.number, - updateProject: PropTypes.func.isRequired, - isMemberOfActiveProject: PropTypes.bool.isRequired, - isManager: PropTypes.bool.isRequired + updateProject: PropTypes.func.isRequired } export default UpdateBillingAccount diff --git a/src/components/UpdateBillingAccount/index.test.js b/src/components/UpdateBillingAccount/index.test.js new file mode 100644 index 00000000..02b9145d --- /dev/null +++ b/src/components/UpdateBillingAccount/index.test.js @@ -0,0 +1,90 @@ +/* global describe, it, expect, beforeEach, afterEach, jest */ + +import React from 'react' +import ReactDOM from 'react-dom' +import { act } from 'react-dom/test-utils' +import UpdateBillingAccount from './index' + +jest.mock('../Select', () => () => null) +jest.mock('../Buttons', () => { + const React = require('react') + + const renderButton = (text) => React.createElement( + 'button', + { type: 'button' }, + text + ) + + return { + PrimaryButton: ({ text }) => renderButton(text), + OutlineButton: ({ text }) => renderButton(text) + } +}) + +describe('UpdateBillingAccount', () => { + let container + + const defaultProps = { + billingAccounts: [], + isBillingAccountsLoading: false, + isBillingAccountLoading: false, + isBillingAccountLoadingFailed: false, + billingStartDate: 'Jan 01, 2026', + billingEndDate: 'Dec 31, 2026', + isBillingAccountExpired: false, + canManageBillingAccount: false, + currentBillingAccount: null, + projectId: 1001, + updateProject: () => {} + } + + const renderComponent = (props = {}) => { + act(() => { + ReactDOM.render( + , + container + ) + }) + } + + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container) + container.remove() + container = null + }) + + it('shows the select action when the user can manage billing accounts and none is assigned', () => { + renderComponent({ + canManageBillingAccount: true, + isBillingAccountLoadingFailed: true + }) + + expect(container.textContent).toContain('No Billing Account set') + expect(container.textContent).toContain('Select Billing Account') + }) + + it('shows the edit action when the user can manage an assigned billing account', () => { + renderComponent({ + canManageBillingAccount: true, + currentBillingAccount: 12345 + }) + + expect(container.textContent).toContain('Billing Account:') + expect(container.textContent).toContain('Edit Billing Account') + }) + + it('hides management actions when the user cannot manage billing accounts', () => { + renderComponent({ + currentBillingAccount: 12345 + }) + + expect(container.textContent).toContain('Billing Account:') + expect(container.textContent).not.toContain('Edit Billing Account') + expect(container.textContent).not.toContain('Select Billing Account') + }) +}) diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js index 2627e99e..43fb7b54 100644 --- a/src/containers/ProjectEditor/index.js +++ b/src/containers/ProjectEditor/index.js @@ -15,7 +15,7 @@ import { updateProject } from '../../actions/projects' import { setActiveProject } from '../../actions/sidebar' -import { checkAdminOrCopilot, checkAdmin, checkIsUserInvitedToProject } from '../../util/tc' +import { checkAdmin, checkCanManageProject, checkIsUserInvitedToProject, getProjectMemberRole } from '../../util/tc' import { PROJECT_ROLES } from '../../config/constants' import Loader from '../../components/Loader' @@ -42,7 +42,7 @@ class ProjectEditor extends Component { this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`) } - if (!checkAdminOrCopilot(auth.token, this.props.projectDetail)) { + if (!checkCanManageProject(auth.token, this.props.projectDetail)) { this.props.history.push('/projects') } } @@ -61,13 +61,7 @@ class ProjectEditor extends Component { } getMemberRole (members, userId) { - if (!userId) { return null } - - const found = _.find(members, (m) => { - return m.userId === userId - }) - - return _.get(found, 'role') + return getProjectMemberRole({ members }, userId) } checkIsCopilotOrManager (projectMembers, userId) { diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js index 893f3f78..36ba899a 100644 --- a/src/containers/Projects/index.js +++ b/src/containers/Projects/index.js @@ -5,7 +5,7 @@ import { withRouter, Link } from 'react-router-dom' import { connect } from 'react-redux' import PropTypes from 'prop-types' import Loader from '../../components/Loader' -import { checkAdminOrCopilot, checkIsUserInvitedToProject, checkManager } from '../../util/tc' +import { checkCanManageProject, checkIsUserInvitedToProject, checkManager } from '../../util/tc' import { PrimaryButton } from '../../components/Buttons' import Select from '../../components/Select' import ProjectCard from '../../components/ProjectCard' @@ -49,7 +49,7 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load

Projects

- {checkAdminOrCopilot(auth.token) && ( + {checkCanManageProject(auth.token) && ( diff --git a/src/util/tc.js b/src/util/tc.js index 248fddf6..d010b3f4 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -20,6 +20,57 @@ import { decodeToken } from 'tc-auth-lib' import { fetchResources, fetchResourceRoles } from '../services/challenges' import store from '../config/store' +const TALENT_MANAGER_ROLES = [ + 'talent manager', + 'topcoder talent manager' +] + +const normalizeUserId = (userId) => { + if (_.isNil(userId)) { + return null + } + + const normalizedUserId = `${userId}`.trim() + return normalizedUserId.length > 0 ? normalizedUserId : null +} + +const normalizeEmail = (email) => { + if (_.isNil(email)) { + return null + } + + const normalizedEmail = `${email}`.trim().toLowerCase() + return normalizedEmail.length > 0 ? normalizedEmail : null +} + +const getProjectMember = (project, userId) => { + const normalizedUserId = normalizeUserId(userId) + + if ( + !project || + _.isEmpty(project) || + !normalizedUserId || + !Array.isArray(project.members) + ) { + return null + } + + return _.find( + project.members, + member => normalizeUserId(member.userId) === normalizedUserId + ) || null +} + +const canManageProject = (project, userId) => { + if (!project || _.isEmpty(project)) { + return true + } + + return ALLOWED_EDIT_RESOURCE_ROLES.includes( + _.get(getProjectMember(project, userId), 'role') + ) +} + export const RATING_COLORS = [ { color: '#9D9FA0' /* Grey */, @@ -215,6 +266,41 @@ export const checkTaskManager = (token) => { return roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) } +export const checkTalentManager = (token) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + return roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) +} + +/** + * Checks whether the caller can manage project ownership flows in Work Manager. + * + * Admins always qualify. Copilots and Talent Managers additionally need a + * management-capable project membership when a project context is provided. + * + * @param token + * @param project + * @returns {boolean} Whether the caller can manage the project in the UI. + */ +export const checkCanManageProject = (token, project) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles') + const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) + const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) + const isTalentManager = roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) + const hasProjectManagementAccess = canManageProject(project, tokenData.userId) + + return isAdmin || ((isCopilot || isTalentManager) && hasProjectManagementAccess) +} + +export const checkProjectMembership = (project, userId) => { + return !!getProjectMember(project, userId) +} + +export const getProjectMemberRole = (project, userId) => { + return _.get(getProjectMember(project, userId), 'role', null) +} + export const checkAdminOrPmOrTaskManager = (token, project) => { const tokenData = decodeToken(token) const roles = _.get(tokenData, 'roles') @@ -226,7 +312,8 @@ export const checkAdminOrPmOrTaskManager = (token, project) => { const isProjectManager = project && !_.isEmpty(project) && project.members && project.members.some(member => - member.userId === userId && member.role === PROJECT_ROLES.MANAGER + normalizeUserId(member.userId) === normalizeUserId(userId) && + member.role === PROJECT_ROLES.MANAGER ) return isAdmin || isManager || isTaskManager || isProjectManager @@ -239,9 +326,8 @@ export const checkCopilot = (token, project) => { const tokenData = decodeToken(token) const roles = _.get(tokenData, 'roles') const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) - const canManageProject = !project || _.isEmpty(project) || ALLOWED_EDIT_RESOURCE_ROLES.includes(_.get(_.find(project.members, { userId: tokenData.userId }), 'role')) - return isCopilot && canManageProject + return isCopilot && canManageProject(project, tokenData.userId) } /** @@ -253,9 +339,8 @@ export const checkAdminOrCopilot = (token, project) => { const roles = _.get(tokenData, 'roles') const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) - const canManageProject = !project || _.isEmpty(project) || ALLOWED_EDIT_RESOURCE_ROLES.includes(_.get(_.find(project.members, { userId: tokenData.userId }), 'role')) - return isAdmin || (isCopilot && canManageProject) + return isAdmin || (isCopilot && canManageProject(project, tokenData.userId)) } export const checkIsUserInvitedToProject = (token, project) => { @@ -264,7 +349,13 @@ export const checkIsUserInvitedToProject = (token, project) => { } const tokenData = decodeToken(token) - return project && !_.isEmpty(project) && (_.find(project.invites, d => d.userId === tokenData.userId || d.email === tokenData.email)) + return project && !_.isEmpty(project) && (_.find( + project.invites, + d => ( + normalizeUserId(d.userId) === normalizeUserId(tokenData.userId) || + normalizeEmail(d.email) === normalizeEmail(tokenData.email) + ) + )) } /** From 5b77f4690ee3de56957752acd497579fc9e74d25 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 09:03:53 +1100 Subject: [PATCH 02/10] Deploy hotfix branch for validation --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c2a8f9b8..b0fa9c02 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -160,7 +160,7 @@ workflows: context: org-global filters: &filters-dev branches: - only: ["develop", "pm-2917", "points", "pm-3270", "engagements"] + only: ["develop", "pm-2917", "points", "pm-3270", "permissions-hotfix"] # Production builds are exectuted only on tagged commits to the # master branch. From cc452542466a6b1093e8807a957cfc3b89b8ddcd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 09:06:44 +1100 Subject: [PATCH 03/10] Build fix --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b0fa9c02..91b2bb49 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -9,7 +9,7 @@ parameters: defaults: &defaults docker: - - image: cimg/python:3.11.11-browsers + - image: cimg/python:3.12-browsers test_defaults: &test_defaults docker: From b6707c43eb86526597d8895df2d8099cfe9847f7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 09:32:50 +1100 Subject: [PATCH 04/10] Projects path updates --- config/constants/development.js | 2 +- config/constants/local.js | 2 +- config/constants/production.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants/development.js b/config/constants/development.js index 487ef24b..1ca384e3 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -28,7 +28,7 @@ module.exports = { CHALLENGE_PHASES_URL: `${DEV_API_HOSTNAME}/v6/challenge-phases`, CHALLENGE_TIMELINES_URL: `${DEV_API_HOSTNAME}/v6/challenge-timelines`, COPILOTS_URL: 'https://copilots.topcoder-dev.com', - PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`, + PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`, GROUPS_API_URL: `${DEV_API_HOSTNAME}/v6/groups`, TERMS_API_URL: `${DEV_API_HOSTNAME}/v5/terms`, RESOURCES_API_URL: `${DEV_API_HOSTNAME}/v6/resources`, diff --git a/config/constants/local.js b/config/constants/local.js index ad2ae1da..1f03e28f 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -47,7 +47,7 @@ module.exports = { COPILOTS_URL: 'https://copilots.topcoder-dev.com', // Projects API: keep dev unless you run projects locally - PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`, + PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`, // Local groups/resources/review services GROUPS_API_URL: `${LOCAL_GROUPS_API}/groups`, diff --git a/config/constants/production.js b/config/constants/production.js index 425d9b46..f4a75156 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -27,7 +27,7 @@ module.exports = { CHALLENGE_PHASES_URL: `${PROD_API_HOSTNAME}/v6/challenge-phases`, CHALLENGE_TIMELINES_URL: `${PROD_API_HOSTNAME}/v6/challenge-timelines`, COPILOTS_URL: `https://copilots.${DOMAIN}`, - PROJECT_API_URL: `${PROD_API_HOSTNAME}/v5/projects`, + PROJECT_API_URL: `${PROD_API_HOSTNAME}/v6/projects`, GROUPS_API_URL: `${PROD_API_HOSTNAME}/v6/groups`, TERMS_API_URL: `${PROD_API_HOSTNAME}/v5/terms`, MEMBERS_API_URL: `${PROD_API_HOSTNAME}/v5/members`, From 610dbbc5fd896f0bc642e65d802123c6925b3741 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 15:08:05 +1100 Subject: [PATCH 05/10] Fix for missing project name --- src/containers/Challenges/helper.js | 26 ++++++++++++++++++++++++ src/containers/Challenges/helper.test.js | 23 +++++++++++++++++++++ src/containers/Challenges/index.js | 14 ++++++++----- 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/containers/Challenges/helper.js create mode 100644 src/containers/Challenges/helper.test.js diff --git a/src/containers/Challenges/helper.js b/src/containers/Challenges/helper.js new file mode 100644 index 00000000..79f2efd8 --- /dev/null +++ b/src/containers/Challenges/helper.js @@ -0,0 +1,26 @@ +/** + * Returns project details only when they match the current project context. + * + * This prevents stale project data from rendering on another project page while + * still handling APIs that may return ids as strings. + * + * @param {Object} projectDetail Loaded project details from redux. + * @param {string|number} projectId Project id from the current route. + * @param {number} activeProjectId Active project id stored in the sidebar state. + * @returns {Object} The matching project detail object, or an empty object. + */ +export function getActiveProject (projectDetail, projectId, activeProjectId) { + if (!projectDetail) { + return {} + } + + const scopedProjectId = projectId != null + ? `${projectId}` + : (activeProjectId != null && activeProjectId !== -1 ? `${activeProjectId}` : '') + + if (!scopedProjectId || `${projectDetail.id}` !== scopedProjectId) { + return {} + } + + return projectDetail +} diff --git a/src/containers/Challenges/helper.test.js b/src/containers/Challenges/helper.test.js new file mode 100644 index 00000000..12e36dc3 --- /dev/null +++ b/src/containers/Challenges/helper.test.js @@ -0,0 +1,23 @@ +/* global describe, it, expect */ + +import { getActiveProject } from './helper' + +describe('getActiveProject', () => { + it('returns the project detail when the route project id matches a string project id', () => { + const projectDetail = { + id: '100566', + name: 'Project Phoenix' + } + + expect(getActiveProject(projectDetail, 100566, 100566)).toEqual(projectDetail) + }) + + it('returns an empty object when the loaded project does not match the current project context', () => { + const projectDetail = { + id: '100566', + name: 'Project Phoenix' + } + + expect(getActiveProject(projectDetail, 100567, 100567)).toEqual({}) + }) +}) diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js index f3edb55a..e413cd79 100644 --- a/src/containers/Challenges/index.js +++ b/src/containers/Challenges/index.js @@ -22,6 +22,7 @@ import { } from '../../actions/sidebar' import { checkAdmin, checkIsUserInvitedToProject } from '../../util/tc' import { withRouter } from 'react-router-dom' +import { getActiveProject } from './helper' class Challenges extends Component { constructor (props) { @@ -134,6 +135,7 @@ class Challenges extends Component { setActiveProject, partiallyUpdateChallengeDetails, deleteChallenge, + projectId, isBillingAccountExpired, billingStartDate, billingEndDate, @@ -150,15 +152,17 @@ class Challenges extends Component { fetchNextProjects } = this.props const { challengeTypes = [] } = metadata + const activeProject = getActiveProject( + reduxProjectInfo, + projectId, + activeProjectId + ) + return ( {(dashboard || activeProjectId !== -1 || selfService) && ( Date: Wed, 18 Mar 2026 15:53:19 +1100 Subject: [PATCH 06/10] Narrower permissions on managing BAs --- .../ChallengeList/index.js | 11 ++- src/util/tc.js | 22 +++++ src/util/tc.test.js | 85 +++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 src/util/tc.test.js diff --git a/src/components/ChallengesComponent/ChallengeList/index.js b/src/components/ChallengesComponent/ChallengeList/index.js index 8398fd4b..5866bcf4 100644 --- a/src/components/ChallengesComponent/ChallengeList/index.js +++ b/src/components/ChallengesComponent/ChallengeList/index.js @@ -25,7 +25,7 @@ import Loader from '../../Loader' import UpdateBillingAccount from '../../UpdateBillingAccount' import { CHALLENGE_STATUS, PAGE_SIZE, PAGINATION_PER_PAGE_OPTIONS, PROJECT_ROLES } from '../../../config/constants' -import { checkAdmin, checkManager, checkProjectMembership, checkReadOnlyRoles } from '../../../util/tc' +import { checkAdmin, checkCanManageProjectBillingAccount, checkReadOnlyRoles } from '../../../util/tc' require('bootstrap/scss/bootstrap.scss') @@ -405,11 +405,10 @@ class ChallengeList extends Component { fetchNextProjects } = this.props const isReadOnly = checkReadOnlyRoles(this.props.auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ - const isAdmin = checkAdmin(this.props.auth.token) - const isManager = checkManager(this.props.auth.token) - const loginUserId = this.props.auth.user.userId - const isMemberOfActiveProject = checkProjectMembership(activeProject, loginUserId) - const canManageBillingAccount = isAdmin || (isManager && isMemberOfActiveProject) + const canManageBillingAccount = checkCanManageProjectBillingAccount( + this.props.auth.token, + activeProject + ) if (warnMessage) { return diff --git a/src/util/tc.js b/src/util/tc.js index d010b3f4..b41e2cb7 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -293,6 +293,28 @@ export const checkCanManageProject = (token, project) => { return isAdmin || ((isCopilot || isTalentManager) && hasProjectManagementAccess) } +/** + * Checks whether the caller may edit a project's billing account. + * + * This is intentionally stricter than general project-management checks: + * only admins or project members with Full Access (`manager`) qualify. + * + * @param token + * @param project + * @returns {boolean} Whether the caller can edit the project's billing account. + */ +export const checkCanManageProjectBillingAccount = (token, project) => { + const tokenData = decodeToken(token) + const roles = _.get(tokenData, 'roles', []) + const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) + + if (isAdmin) { + return true + } + + return _.get(getProjectMember(project, tokenData.userId), 'role') === PROJECT_ROLES.MANAGER +} + export const checkProjectMembership = (project, userId) => { return !!getProjectMember(project, userId) } diff --git a/src/util/tc.test.js b/src/util/tc.test.js new file mode 100644 index 00000000..6cc769d3 --- /dev/null +++ b/src/util/tc.test.js @@ -0,0 +1,85 @@ +/* global describe, it, expect, beforeEach, jest */ + +import { decodeToken } from 'tc-auth-lib' +import { PROJECT_ROLES } from '../config/constants' +import { checkCanManageProjectBillingAccount } from './tc' + +jest.mock('tc-auth-lib', () => ({ + decodeToken: jest.fn() +})) + +jest.mock('../services/challenges', () => ({ + fetchResources: jest.fn(), + fetchResourceRoles: jest.fn() +})) + +jest.mock('../config/store', () => ({ + getState: jest.fn() +})) + +describe('checkCanManageProjectBillingAccount', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('allows administrators to manage project billing accounts', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['administrator'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [] + }) + ).toBe(true) + }) + + it('allows full-access project members to manage project billing accounts', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.MANAGER + }] + }) + ).toBe(true) + }) + + it('blocks project-manager roles without full-access project membership', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }) + ).toBe(false) + }) + + it('blocks talent-manager roles without full-access project membership', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['talent manager'] + }) + + expect( + checkCanManageProjectBillingAccount('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }) + ).toBe(false) + }) +}) From 31ec80cdeb85d7f1913c244b70bef0c860f4f7b7 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 16:12:57 +1100 Subject: [PATCH 07/10] Ensure Project Manager role can create projects --- src/containers/ProjectEditor/index.js | 10 +++++--- src/containers/Projects/index.js | 4 +-- src/util/tc.js | 14 +++++++++++ src/util/tc.test.js | 35 ++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js index 43fb7b54..b89c7017 100644 --- a/src/containers/ProjectEditor/index.js +++ b/src/containers/ProjectEditor/index.js @@ -15,7 +15,7 @@ import { updateProject } from '../../actions/projects' import { setActiveProject } from '../../actions/sidebar' -import { checkAdmin, checkCanManageProject, checkIsUserInvitedToProject, getProjectMemberRole } from '../../util/tc' +import { checkAdmin, checkCanCreateProject, checkCanManageProject, checkIsUserInvitedToProject, getProjectMemberRole } from '../../util/tc' import { PROJECT_ROLES } from '../../config/constants' import Loader from '../../components/Loader' @@ -36,13 +36,17 @@ class ProjectEditor extends Component { } componentDidUpdate () { - const { auth } = this.props + const { auth, isEdit } = this.props if (checkIsUserInvitedToProject(auth.token, this.props.projectDetail)) { this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`) } - if (!checkCanManageProject(auth.token, this.props.projectDetail)) { + if (isEdit && !checkCanManageProject(auth.token, this.props.projectDetail)) { + this.props.history.push('/projects') + } + + if (!isEdit && !checkCanCreateProject(auth.token)) { this.props.history.push('/projects') } } diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js index 36ba899a..80575cab 100644 --- a/src/containers/Projects/index.js +++ b/src/containers/Projects/index.js @@ -5,7 +5,7 @@ import { withRouter, Link } from 'react-router-dom' import { connect } from 'react-redux' import PropTypes from 'prop-types' import Loader from '../../components/Loader' -import { checkCanManageProject, checkIsUserInvitedToProject, checkManager } from '../../util/tc' +import { checkCanCreateProject, checkIsUserInvitedToProject, checkManager } from '../../util/tc' import { PrimaryButton } from '../../components/Buttons' import Select from '../../components/Select' import ProjectCard from '../../components/ProjectCard' @@ -49,7 +49,7 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load

Projects

- {checkCanManageProject(auth.token) && ( + {checkCanCreateProject(auth.token) && ( diff --git a/src/util/tc.js b/src/util/tc.js index b41e2cb7..f3eb5b44 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -293,6 +293,20 @@ export const checkCanManageProject = (token, project) => { return isAdmin || ((isCopilot || isTalentManager) && hasProjectManagementAccess) } +/** + * Checks whether the caller may create a project in Work Manager. + * + * Project creation remains broader than edit permissions. Project Managers + * should still be able to create projects even though billing-account edits + * are limited to admins and Full Access members. + * + * @param token + * @returns {boolean} Whether the caller can create a project. + */ +export const checkCanCreateProject = (token) => { + return checkAdmin(token) || checkManager(token) || checkCopilot(token) +} + /** * Checks whether the caller may edit a project's billing account. * diff --git a/src/util/tc.test.js b/src/util/tc.test.js index 6cc769d3..4a361945 100644 --- a/src/util/tc.test.js +++ b/src/util/tc.test.js @@ -2,7 +2,7 @@ import { decodeToken } from 'tc-auth-lib' import { PROJECT_ROLES } from '../config/constants' -import { checkCanManageProjectBillingAccount } from './tc' +import { checkCanCreateProject, checkCanManageProjectBillingAccount } from './tc' jest.mock('tc-auth-lib', () => ({ decodeToken: jest.fn() @@ -83,3 +83,36 @@ describe('checkCanManageProjectBillingAccount', () => { ).toBe(false) }) }) + +describe('checkCanCreateProject', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('allows project-manager roles to create projects', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect(checkCanCreateProject('token')).toBe(true) + }) + + it('allows copilots to create projects', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['copilot'] + }) + + expect(checkCanCreateProject('token')).toBe(true) + }) + + it('blocks read-only users from creating projects', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['topcoder user'] + }) + + expect(checkCanCreateProject('token')).toBe(false) + }) +}) From 0fc1caf0d8e94f14e7cf818f78f2c07a7bf61288 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 16:30:03 +1100 Subject: [PATCH 08/10] Additional fix for editing project with access --- src/util/tc.js | 8 +++++--- src/util/tc.test.js | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/util/tc.js b/src/util/tc.js index f3eb5b44..4f52b830 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -275,8 +275,9 @@ export const checkTalentManager = (token) => { /** * Checks whether the caller can manage project ownership flows in Work Manager. * - * Admins always qualify. Copilots and Talent Managers additionally need a - * management-capable project membership when a project context is provided. + * Admins always qualify. Project Managers, Copilots, and Talent Managers + * additionally need a management-capable project membership when a project + * context is provided. * * @param token * @param project @@ -287,10 +288,11 @@ export const checkCanManageProject = (token, project) => { const roles = _.get(tokenData, 'roles') const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1) const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1) + const isManager = roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) const isTalentManager = roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) const hasProjectManagementAccess = canManageProject(project, tokenData.userId) - return isAdmin || ((isCopilot || isTalentManager) && hasProjectManagementAccess) + return isAdmin || ((isCopilot || isManager || isTalentManager) && hasProjectManagementAccess) } /** diff --git a/src/util/tc.test.js b/src/util/tc.test.js index 4a361945..7a85d8c3 100644 --- a/src/util/tc.test.js +++ b/src/util/tc.test.js @@ -2,7 +2,7 @@ import { decodeToken } from 'tc-auth-lib' import { PROJECT_ROLES } from '../config/constants' -import { checkCanCreateProject, checkCanManageProjectBillingAccount } from './tc' +import { checkCanCreateProject, checkCanManageProject, checkCanManageProjectBillingAccount } from './tc' jest.mock('tc-auth-lib', () => ({ decodeToken: jest.fn() @@ -84,6 +84,44 @@ describe('checkCanManageProjectBillingAccount', () => { }) }) +describe('checkCanManageProject', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('allows project-manager roles to manage projects when they have full access', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProject('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.MANAGER + }] + }) + ).toBe(true) + }) + + it('blocks project-manager roles from managing projects without full access', () => { + decodeToken.mockReturnValue({ + userId: '1001', + roles: ['project manager'] + }) + + expect( + checkCanManageProject('token', { + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }) + ).toBe(false) + }) +}) + describe('checkCanCreateProject', () => { beforeEach(() => { decodeToken.mockReset() From 8cb07c3661c528ca52a9d486a40eed668bcf4d9f Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Wed, 18 Mar 2026 16:57:31 +1100 Subject: [PATCH 09/10] Merge issues --- src/components/ChallengesComponent/index.js | 28 ++++---- .../ChallengesComponent/index.test.js | 9 +-- src/containers/ProjectEditor/index.js | 10 +-- src/containers/Projects/index.js | 2 +- src/util/tc.js | 40 +++++++++--- src/util/tc.test.js | 64 ++++++++++++++++++- 6 files changed, 118 insertions(+), 35 deletions(-) diff --git a/src/components/ChallengesComponent/index.js b/src/components/ChallengesComponent/index.js index b7c62450..dc4e4ea9 100644 --- a/src/components/ChallengesComponent/index.js +++ b/src/components/ChallengesComponent/index.js @@ -10,7 +10,7 @@ import { PROJECT_ROLES, PROJECT_STATUS, COPILOTS_URL, CHALLENGE_STATUS } from '. import { PrimaryButton, OutlineButton } from '../Buttons' import ChallengeList from './ChallengeList' import styles from './ChallengesComponent.module.scss' -import { checkAdmin, checkCanManageProject, checkReadOnlyRoles, checkAdminOrCopilot, checkManager, getProjectMemberRole } from '../../util/tc' +import { checkAdmin, checkCanManageProject, checkCanViewProjectAssets, checkReadOnlyRoles, checkManager, getProjectMemberRole } from '../../util/tc' const ChallengesComponent = ({ challenges, @@ -51,7 +51,7 @@ const ChallengesComponent = ({ const [loginUserRoleInProject, setLoginUserRoleInProject] = useState('') const isReadOnly = checkReadOnlyRoles(auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ const canManageProject = checkCanManageProject(auth.token, activeProject) - const canAccessProjectAssets = checkAdminOrCopilot(auth.token, activeProject) + const canViewAssets = checkCanViewProjectAssets(auth.token, activeProject) const projectStatus = activeProject && activeProject.status ? activeProject.status.toUpperCase() @@ -90,17 +90,19 @@ const ChallengesComponent = ({
{activeProject && activeProject.id && (!isReadOnly || canViewAssets) ? (
- - {canAccessProjectAssets && ( + {!isReadOnly && ( + + )} + {canViewAssets && ( ({ jest.mock('../../util/tc', () => ({ checkAdmin: jest.fn(), checkReadOnlyRoles: jest.fn(), - checkAdminOrCopilotOrManager: jest.fn(), + checkCanManageProject: jest.fn(), checkCanViewProjectAssets: jest.fn(), checkManager: jest.fn(), - getProjectMemberByUserId: jest.fn((project, userId) => { + getProjectMemberRole: jest.fn((project, userId) => { const members = (project && project.members) || [] - return members.find(member => `${member.userId}` === `${userId}`) || null + const member = members.find(candidate => `${candidate.userId}` === `${userId}`) || null + return member ? member.role : null }) })) @@ -111,7 +112,7 @@ describe('ChallengesComponent', () => { tcUtils.checkAdmin.mockReturnValue(false) tcUtils.checkReadOnlyRoles.mockReturnValue(false) - tcUtils.checkAdminOrCopilotOrManager.mockReturnValue(false) + tcUtils.checkCanManageProject.mockReturnValue(false) tcUtils.checkCanViewProjectAssets.mockReturnValue(true) tcUtils.checkManager.mockReturnValue(false) }) diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js index 1b8388b6..ce4b174b 100644 --- a/src/containers/ProjectEditor/index.js +++ b/src/containers/ProjectEditor/index.js @@ -16,7 +16,7 @@ import { clearProjectDetail } from '../../actions/projects' import { setActiveProject } from '../../actions/sidebar' -import { checkAdmin, checkCanCreateProject, checkCanManageProject, checkIsUserInvitedToProject, getProjectMemberRole } from '../../util/tc' +import { checkAdmin, checkCanCreateProject, checkCanManageProject, checkIsUserInvitedToProject, checkManager, getProjectMemberRole } from '../../util/tc' import { PROJECT_ROLES } from '../../config/constants' import Loader from '../../components/Loader' @@ -39,18 +39,18 @@ class ProjectEditor extends Component { } componentDidUpdate () { - const { auth, isEdit } = this.props + const { auth, history, isEdit, projectDetail } = this.props if (checkIsUserInvitedToProject(auth.token, projectDetail)) { history.push(`/projects/${projectDetail.id}/invitation`) } - if (isEdit && !checkCanManageProject(auth.token, this.props.projectDetail)) { - this.props.history.push('/projects') + if (isEdit && !checkCanManageProject(auth.token, projectDetail)) { + history.push('/projects') } if (!isEdit && !checkCanCreateProject(auth.token)) { - this.props.history.push('/projects') + history.push('/projects') } } diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js index d15355a6..44968f5d 100644 --- a/src/containers/Projects/index.js +++ b/src/containers/Projects/index.js @@ -5,7 +5,7 @@ import { withRouter, Link } from 'react-router-dom' import { connect } from 'react-redux' import PropTypes from 'prop-types' import Loader from '../../components/Loader' -import { checkCanCreateProject, checkIsUserInvitedToProject, checkManager } from '../../util/tc' +import { checkCanCreateProject, checkManager } from '../../util/tc' import { PrimaryButton } from '../../components/Buttons' import Select from '../../components/Select' import ProjectCard from '../../components/ProjectCard' diff --git a/src/util/tc.js b/src/util/tc.js index 5f2a85f6..f306dfb1 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -264,8 +264,7 @@ export const checkManager = (token) => { export const checkTalentManager = (token) => { const tokenData = decodeToken(token) const roles = _.get(tokenData, 'roles') - const talentManagerRoles = ['talent manager', 'topcoder talent manager'] - return roles.some(val => talentManagerRoles.indexOf(val.toLowerCase()) > -1) + return roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) } export const checkAdminOrTalentManager = (token) => { @@ -278,12 +277,6 @@ export const checkTaskManager = (token) => { return roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) } -export const checkTalentManager = (token) => { - const tokenData = decodeToken(token) - const roles = _.get(tokenData, 'roles') - return roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) -} - /** * Checks whether the caller can manage project ownership flows in Work Manager. * @@ -351,6 +344,30 @@ export const getProjectMemberRole = (project, userId) => { return _.get(getProjectMember(project, userId), 'role', null) } +/** + * Returns the matching project member for the provided user id, if present. + * + * @param {Object|Object[]} projectDetail Project detail payload with `members`, + * or a raw members array. + * @param {String|Number} userId Authenticated user id to match. + * @returns {Object|null} Matching member record or `null`. + */ +export const getProjectMemberByUserId = (projectDetail, userId) => { + const normalizedUserId = normalizeUserId(userId) + const members = Array.isArray(projectDetail) + ? projectDetail + : _.get(projectDetail, 'members', []) + + if (!normalizedUserId || !Array.isArray(members)) { + return null + } + + return _.find( + members, + member => normalizeUserId(member.userId) === normalizedUserId + ) || null +} + export const checkAdminOrPmOrTaskManager = (token, project) => { const tokenData = decodeToken(token) const roles = _.get(tokenData, 'roles') @@ -449,8 +466,11 @@ export const checkIsUserInvitedToProject = (token, project) => { return project && !_.isEmpty(project) && (_.find( project.invites, d => ( - normalizeUserId(d.userId) === normalizeUserId(tokenData.userId) || - normalizeEmail(d.email) === normalizeEmail(tokenData.email) + d.status === PROJECT_MEMBER_INVITE_STATUS_PENDING && + ( + normalizeUserId(d.userId) === normalizeUserId(tokenData.userId) || + normalizeEmail(d.email) === normalizeEmail(tokenData.email) + ) ) )) } diff --git a/src/util/tc.test.js b/src/util/tc.test.js index 7a85d8c3..a8c9c240 100644 --- a/src/util/tc.test.js +++ b/src/util/tc.test.js @@ -1,8 +1,14 @@ /* global describe, it, expect, beforeEach, jest */ import { decodeToken } from 'tc-auth-lib' -import { PROJECT_ROLES } from '../config/constants' -import { checkCanCreateProject, checkCanManageProject, checkCanManageProjectBillingAccount } from './tc' +import { PROJECT_MEMBER_INVITE_STATUS_PENDING, PROJECT_ROLES } from '../config/constants' +import { + checkCanCreateProject, + checkCanManageProject, + checkCanManageProjectBillingAccount, + checkIsUserInvitedToProject, + getProjectMemberByUserId +} from './tc' jest.mock('tc-auth-lib', () => ({ decodeToken: jest.fn() @@ -154,3 +160,57 @@ describe('checkCanCreateProject', () => { expect(checkCanCreateProject('token')).toBe(false) }) }) + +describe('getProjectMemberByUserId', () => { + it('matches project members even when ids differ by string vs number types', () => { + expect(getProjectMemberByUserId({ + members: [{ + userId: '1001', + role: PROJECT_ROLES.WRITE + }] + }, 1001)).toEqual({ + userId: '1001', + role: PROJECT_ROLES.WRITE + }) + }) +}) + +describe('checkIsUserInvitedToProject', () => { + beforeEach(() => { + decodeToken.mockReset() + }) + + it('returns the pending invite for the authenticated user', () => { + decodeToken.mockReturnValue({ + userId: '1001', + email: 'member@test.com' + }) + + expect(checkIsUserInvitedToProject('token', { + invites: [{ + status: PROJECT_MEMBER_INVITE_STATUS_PENDING, + userId: '1001', + email: 'member@test.com' + }] + })).toEqual({ + status: PROJECT_MEMBER_INVITE_STATUS_PENDING, + userId: '1001', + email: 'member@test.com' + }) + }) + + it('ignores non-pending invites for the authenticated user', () => { + decodeToken.mockReturnValue({ + userId: '1001', + email: 'member@test.com' + }) + + expect(checkIsUserInvitedToProject('token', { + invites: [{ + status: 'declined', + userId: '1001', + email: 'member@test.com' + }] + })).toBeUndefined() + }) +}) From a5a1ef9ac9b392b6bec47306264d6f5a13cadc66 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Thu, 19 Mar 2026 14:52:57 +1100 Subject: [PATCH 10/10] Fixes for PM vs TM handling --- src/components/UserCard/index.js | 46 ++++++++-- src/components/UserCard/index.test.js | 118 ++++++++++++++++++++++++++ src/components/Users/index.js | 2 + src/util/tc.js | 50 +++++++++++ src/util/tc.test.js | 41 +++++++++ 5 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 src/components/UserCard/index.test.js diff --git a/src/components/UserCard/index.js b/src/components/UserCard/index.js index 03970d87..2c1102bb 100644 --- a/src/components/UserCard/index.js +++ b/src/components/UserCard/index.js @@ -8,6 +8,7 @@ import { PROJECT_ROLES } from '../../config/constants' import PrimaryButton from '../Buttons/PrimaryButton' import AlertModal from '../Modal/AlertModal' import { updateProjectMemberRole } from '../../services/projects' +import { checkCanSelfAssignProjectRole } from '../../util/tc' const theme = { container: styles.modalContainer @@ -23,11 +24,23 @@ function normalizeDisplayValue (value) { return normalizedValue || null } +function normalizeUserId (userId) { + if (_.isNil(userId)) { + return null + } + + const normalizedUserId = String(userId).trim() + + return normalizedUserId || null +} + /** * Renders one project member or invite card with role controls. * * `user.handle` may be null/empty for some members; this component falls back * to `user.userId` and then `"(unknown user)"` when rendering labels/messages. + * Members may lower their own role, but this control blocks self-promotion to + * higher project privileges. */ class UserCard extends Component { constructor (props) { @@ -54,12 +67,21 @@ class UserCard extends Component { async updatePermission (newRole) { if (this.state.isUpdatingPermission) { return } + const { user, updateProjectMember, currentUserId } = this.props + const isCurrentUser = normalizeUserId(user.userId) === normalizeUserId(currentUserId) + + if (isCurrentUser && !checkCanSelfAssignProjectRole(user.role, newRole)) { + this.setState({ + showWarningModal: true, + permissionUpdateError: 'You cannot give yourself higher privileges in this project.' + }) + return + } + this.setState({ isUpdatingPermission: true }) - const { user, updateProjectMember } = this.props - try { const newUserInfoRole = await updateProjectMemberRole(user.projectId, user.id, newRole) updateProjectMember(newUserInfoRole) @@ -75,8 +97,13 @@ class UserCard extends Component { } render () { - const { isInvite, user, onRemoveClick, isEditable } = this.props + const { isInvite, user, onRemoveClick, isEditable, currentUserId } = this.props const showRadioButtons = _.includes(_.values(PROJECT_ROLES), user.role) + const isCurrentUser = normalizeUserId(user.userId) === normalizeUserId(currentUserId) + const canAssignRole = role => ( + isEditable && + (!isCurrentUser || checkCanSelfAssignProjectRole(user.role, role)) + ) const userDisplayName = normalizeDisplayValue(user.handle) || normalizeDisplayValue(user.userId) || '(unknown user)' @@ -125,9 +152,10 @@ class UserCard extends Component { type='radio' id={`read-${user.id}`} checked={user.role === PROJECT_ROLES.READ} + disabled={!canAssignRole(PROJECT_ROLES.READ)} onChange={(e) => e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} /> -