diff --git a/config/constants/local.js b/config/constants/local.js index 3048bf97..5e197768 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -48,7 +48,6 @@ module.exports = { // Copilots and other apps remain on dev COPILOTS_URL: 'https://copilots.topcoder-dev.com', - // Projects API v6: keep dev default (switch to LOCAL_PROJECTS_API when needed) PROJECT_API_URL: `${DEV_API_HOSTNAME}/v6/projects`, // Local groups/resources/review services diff --git a/src/components/ChallengesComponent/ChallengeList/index.js b/src/components/ChallengesComponent/ChallengeList/index.js index 8de51f21..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, checkReadOnlyRoles } from '../../../util/tc' +import { checkAdmin, checkCanManageProjectBillingAccount, checkReadOnlyRoles } from '../../../util/tc' require('bootstrap/scss/bootstrap.scss') @@ -405,10 +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 = activeProject && activeProject.members && activeProject.members.some(member => member.userId === loginUserId) + const canManageBillingAccount = checkCanManageProjectBillingAccount( + this.props.auth.token, + activeProject + ) if (warnMessage) { return @@ -495,12 +495,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 dd85ac1c..dc4e4ea9 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,14 +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, - checkAdminOrCopilotOrManager, - checkCanViewProjectAssets, - checkManager, - getProjectMemberByUserId -} from '../../util/tc' +import { checkAdmin, checkCanManageProject, checkCanViewProjectAssets, checkReadOnlyRoles, checkManager, getProjectMemberRole } from '../../util/tc' const ChallengesComponent = ({ challenges, @@ -58,8 +50,8 @@ const ChallengesComponent = ({ }) => { const [loginUserRoleInProject, setLoginUserRoleInProject] = useState('') const isReadOnly = checkReadOnlyRoles(auth.token) || loginUserRoleInProject === PROJECT_ROLES.READ + const canManageProject = checkCanManageProject(auth.token, activeProject) const canViewAssets = checkCanViewProjectAssets(auth.token, activeProject) - const canEditProject = checkAdminOrCopilotOrManager(auth.token, activeProject) const projectStatus = activeProject && activeProject.status ? activeProject.status.toUpperCase() @@ -69,9 +61,9 @@ const ChallengesComponent = ({ useEffect(() => { const loggedInUser = auth.user - const loginUserProjectInfo = getProjectMemberByUserId(activeProject, _.get(loggedInUser, 'userId')) - if (loginUserProjectInfo && loginUserRoleInProject !== loginUserProjectInfo.role) { - setLoginUserRoleInProject(loginUserProjectInfo.role) + const loginUserProjectRole = getProjectMemberRole(activeProject, loggedInUser.userId) + if (loginUserProjectRole && loginUserRoleInProject !== loginUserProjectRole) { + setLoginUserRoleInProject(loginUserProjectRole) } }, [activeProject, auth, loginUserRoleInProject]) @@ -84,7 +76,7 @@ const ChallengesComponent = ({ {activeProject ? activeProject.name : ''} {activeProject && activeProject.status && } - {activeProject && activeProject.id && canEditProject && ( + {activeProject && activeProject.id && canManageProject && ( ( ({ 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/components/UpdateBillingAccount/index.js b/src/components/UpdateBillingAccount/index.js index 71cca494..86a7115e 100644 --- a/src/components/UpdateBillingAccount/index.js +++ b/src/components/UpdateBillingAccount/index.js @@ -14,12 +14,10 @@ const UpdateBillingAccount = ({ billingStartDate, billingEndDate, isBillingAccountExpired, - isAdmin, + canManageBillingAccount, currentBillingAccount, projectId, - updateProject, - isMemberOfActiveProject, - isManager + updateProject }) => { 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/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)} /> -