diff --git a/.circleci/config.yml b/.circleci/config.yml index a456a88e..d348291b 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", "projects-api-v6"] # Production builds are exectuted only on tagged commits to the # master branch. diff --git a/README.md b/README.md index 31d5180c..761507b0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,68 @@ This is the frontend application for creating and managing challenges. Production configuration is in `config/constants/production.js` Development configuration is in `config/constants/development.js` +## Project Invitation Flow + +### Route handled + +`/projects/:projectId/invitation/:action?` + +Handled by `ProjectInvitations` container (`src/containers/ProjectInvitations/index.js`). + +### Email link format + +When `projects-api-v6` sends an invite email to a **known user** (existing Topcoder account), the email contains two action buttons whose links must use this exact format: + +| Button | URL | +| --- | --- | +| Join Project | `{WORK_MANAGER_URL}/projects/{projectId}/invitation/accepted?source=email` | +| Decline Invitation | `{WORK_MANAGER_URL}/projects/{projectId}/invitation/refused?source=email` | + +- `{WORK_MANAGER_URL}` is the `WORK_MANAGER_URL` env var configured in `projects-api-v6`. +- The `?source=email` query parameter is forwarded in the `PATCH /v6/projects/{projectId}/invites/{inviteId}` body as `{ status, source }`. + +### Automatic action behaviour + +When a user clicks either link and lands on the route with `:action` set, `ProjectInvitations` automatically calls `updateProjectMemberInvite` without showing the confirmation modal. After success it redirects to: + +- `accepted` → `/projects/{projectId}/challenges` +- `refused` → `/projects` + +### Manual (modal) flow + +When the route is accessed **without** an `:action` segment (e.g., navigating directly to `/projects/{projectId}/invitation`), the container shows a `ConfirmationModal` with **Join project** / **Decline** buttons. + +### API call made + +Both flows call `PATCH /v6/projects/{projectId}/invites/{inviteId}` via `updateProjectMemberInvite` in `work-manager/src/services/projectMemberInvites.js`, with body `{ status: 'accepted' | 'refused', source?: 'email' }`. + +### Env var cross-reference + +`WORK_MANAGER_URL` is documented in the `projects-api-v6` README under Environment Variables. Ensure it is set to the deployed work-manager origin (no trailing slash), e.g.: + +- Dev: `https://challenges.topcoder-dev.com` +- Prod: `https://work.topcoder.com` + +### Sequence diagram + +```mermaid +sequenceDiagram + participant User + participant Email + participant WorkManager + participant ProjectInvitations + participant ProjectsAPIv6 + + ProjectsAPIv6->>Email: sendInviteEmail (POST /invites) + Email-->>User: Join Project button → WORK_MANAGER_URL/projects/{id}/invitation/accepted?source=email + Email-->>User: Decline button → WORK_MANAGER_URL/projects/{id}/invitation/refused?source=email + User->>WorkManager: GET /projects/{id}/invitation/accepted?source=email + WorkManager->>ProjectInvitations: render (automaticAction = 'accepted') + ProjectInvitations->>ProjectsAPIv6: PATCH /v6/projects/{id}/invites/{inviteId} {status:'accepted', source:'email'} + ProjectsAPIv6-->>ProjectInvitations: 200 OK + ProjectInvitations->>WorkManager: redirect /projects/{id}/challenges +``` + ## Local Deployment Instructions 1. First install dependencies diff --git a/config/constants/development.js b/config/constants/development.js index af3244d6..67b69f47 100644 --- a/config/constants/development.js +++ b/config/constants/development.js @@ -30,7 +30,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..c551b163 100644 --- a/config/constants/local.js +++ b/config/constants/local.js @@ -12,6 +12,7 @@ const LOCAL_MEMBER_API = 'http://localhost:3003/v6' const LOCAL_RESOURCE_API = 'http://localhost:3004/v6' const LOCAL_REVIEW_API = 'http://localhost:3005/v6' const LOCAL_SKILLS_API_V5 = 'http://localhost:3006/v5/standardized-skills' +const LOCAL_PROJECTS_API = 'http://localhost:3008/v6/projects' // Lookups API available on 3007 if needed in future // const LOCAL_LOOKUPS_API = 'http://localhost:3007/v6' @@ -46,8 +47,8 @@ module.exports = { // Copilots and other apps remain on dev COPILOTS_URL: 'https://copilots.topcoder-dev.com', - // Projects API: keep dev unless you run projects locally - PROJECT_API_URL: `${DEV_API_HOSTNAME}/v5/projects`, + // 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 GROUPS_API_URL: `${LOCAL_GROUPS_API}/groups`, diff --git a/config/constants/production.js b/config/constants/production.js index c4f69daf..94c3e95e 100644 --- a/config/constants/production.js +++ b/config/constants/production.js @@ -29,7 +29,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`, diff --git a/src/actions/engagements.js b/src/actions/engagements.js index 34ebbdee..d0a0c55e 100644 --- a/src/actions/engagements.js +++ b/src/actions/engagements.js @@ -7,7 +7,6 @@ import { patchEngagement, deleteEngagement as deleteEngagementAPI } from '../services/engagements' -import { fetchProjectById } from '../services/projects' import { fetchSkillsByIds } from '../services/skills' import { normalizeEngagement, @@ -34,8 +33,6 @@ import { DELETE_ENGAGEMENT_FAILURE } from '../config/constants' -const projectNameCache = {} - const getSkillId = (skill) => { if (!skill) { return null @@ -96,70 +93,6 @@ const withSkillDetails = (engagement, skillsMap) => { } } -const getProjectId = (engagement) => { - if (!engagement || !engagement.projectId) { - return null - } - return String(engagement.projectId) -} - -const getProjectName = (project) => { - if (!project || typeof project !== 'object') { - return null - } - if (typeof project.name === 'string' && project.name.trim()) { - return project.name - } - if (typeof project.projectName === 'string' && project.projectName.trim()) { - return project.projectName - } - return null -} - -const hydrateEngagementProjectNames = async (engagements = []) => { - if (!Array.isArray(engagements) || !engagements.length) { - return [] - } - - const projectIds = Array.from(new Set( - engagements - .map(getProjectId) - .filter(Boolean) - )) - - if (!projectIds.length) { - return engagements - } - - const uncachedProjectIds = projectIds.filter((projectId) => !projectNameCache[projectId]) - if (uncachedProjectIds.length) { - const projectNameEntries = await Promise.all( - uncachedProjectIds.map(async (projectId) => { - try { - const project = await fetchProjectById(projectId) - return [projectId, getProjectName(project)] - } catch (error) { - return [projectId, null] - } - }) - ) - - projectNameEntries.forEach(([projectId, projectName]) => { - if (projectName) { - projectNameCache[projectId] = projectName - } - }) - } - - return engagements.map((engagement) => { - const projectId = getProjectId(engagement) - return { - ...engagement, - projectName: (projectId && projectNameCache[projectId]) || engagement.projectName || null - } - }) -} - const hydrateEngagementSkills = async (engagements = []) => { if (!Array.isArray(engagements) || !engagements.length) { return [] @@ -195,9 +128,19 @@ const hydrateEngagementSkills = async (engagements = []) => { * @param {String} status * @param {String} filterName * @param {Boolean} includePrivate + * @param {Array} projectIds */ -export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false) { +export function loadEngagements (projectId, status = 'all', filterName = '', includePrivate = false, projectIds = []) { + const hasProjectIdsArg = arguments.length >= 5 return async (dispatch) => { + if (hasProjectIdsArg && Array.isArray(projectIds) && !projectIds.length) { + dispatch({ + type: LOAD_ENGAGEMENTS_SUCCESS, + engagements: [] + }) + return + } + dispatch({ type: LOAD_ENGAGEMENTS_PENDING }) @@ -215,6 +158,9 @@ export function loadEngagements (projectId, status = 'all', filterName = '', inc if (includePrivate) { filters.includePrivate = true } + if (projectIds && projectIds.length) { + filters.projectIds = projectIds + } try { const engagements = [] @@ -273,8 +219,7 @@ export function loadEngagements (projectId, status = 'all', filterName = '', inc } while (!totalPages || page <= totalPages) const hydratedEngagements = await hydrateEngagementSkills(engagements) - const engagementsWithProjectNames = await hydrateEngagementProjectNames(hydratedEngagements) - const normalizedEngagements = normalizeEngagements(engagementsWithProjectNames) + const normalizedEngagements = normalizeEngagements(hydratedEngagements) dispatch({ type: LOAD_ENGAGEMENTS_SUCCESS, engagements: normalizedEngagements diff --git a/src/actions/projects.js b/src/actions/projects.js index 89b1f6b5..72ffea42 100644 --- a/src/actions/projects.js +++ b/src/actions/projects.js @@ -14,6 +14,7 @@ import { LOAD_CHALLENGE_MEMBERS, LOAD_PROJECT_TYPES, CREATE_PROJECT, + CLEAR_PROJECT_DETAIL, LOAD_PROJECT_BILLING_ACCOUNTS, UPDATE_PROJECT_PENDING, UPDATE_PROJECT_SUCCESS, @@ -36,6 +37,18 @@ import { } from '../services/projects' import { checkAdmin, checkManager } from '../util/tc' +/** + * Loads projects with optional filters and enforces membership scoping for + * non-admin/non-manager users. + * + * Backend contract: when `memberOnly` is true, the API must apply + * membership/invite visibility constraints so users only receive projects they + * can access. + * + * @param {string} projectNameOrIdFilter Optional id/keyword filter. + * @param {Object} paramFilters Additional query filters. + * @returns {Function} Redux thunk. + */ function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) { return (dispatch, getState) => { dispatch({ @@ -57,6 +70,7 @@ function _loadProjects (projectNameOrIdFilter = '', paramFilters = {}) { } if (!checkAdmin(getState().auth.token) && !checkManager(getState().auth.token)) { + // Non-admin users must always be server-scoped to member-visible projects. filters['memberOnly'] = true } @@ -161,6 +175,20 @@ export function loadProject (projectId, filterMembers = true) { } } +/** + * Clears the currently selected project details from Redux state. + * Use this when entering a create-project flow so stale project data is not reused. + * + * @returns {Function} thunk dispatching the clear action + */ +export function clearProjectDetail () { + return (dispatch) => { + return dispatch({ + type: CLEAR_PROJECT_DETAIL + }) + } +} + /** * Loads project types */ diff --git a/src/components/ApplicationsList/index.js b/src/components/ApplicationsList/index.js index 15ed98c5..2b1c0b98 100644 --- a/src/components/ApplicationsList/index.js +++ b/src/components/ApplicationsList/index.js @@ -13,6 +13,8 @@ import Handle from '../Handle' import styles from './ApplicationsList.module.scss' import { PROFILE_URL } from '../../config/constants' import { serializeTentativeAssignmentDate } from '../../util/assignmentDates' +import { isCapacityLimitError } from '../../util/applicationErrors' +import { getCountableAssignments } from '../../util/engagements' const STATUS_OPTIONS = [ { label: 'All', value: 'all' }, @@ -25,6 +27,7 @@ const STATUS_OPTIONS = [ const STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter(option => option.value !== 'all') const INPUT_DATE_FORMAT = 'MM/dd/yyyy' const INPUT_TIME_FORMAT = 'HH:mm' +const CAPACITY_ERROR_MODAL_MESSAGE = 'The required number of members are already assigned to this engagement. If you\'d like to add another member, change the required number of members on the engagement first.' const ANTICIPATED_START_LABELS = { IMMEDIATE: 'Immediate', @@ -177,6 +180,7 @@ const ApplicationsList = ({ const [selectedApplication, setSelectedApplication] = useState(null) const [acceptApplication, setAcceptApplication] = useState(null) const [acceptSuccess, setAcceptSuccess] = useState(null) + const [capacityError, setCapacityError] = useState(false) const [acceptStartDate, setAcceptStartDate] = useState(null) const [acceptEndDate, setAcceptEndDate] = useState(null) const [acceptRate, setAcceptRate] = useState('') @@ -247,6 +251,37 @@ const ApplicationsList = ({ .filter(Boolean) return new Set(activeAssignmentIds.map((memberId) => String(memberId))) }, [engagement]) + const countableAssignments = useMemo(() => { + const assignments = Array.isArray(engagement && engagement.assignments) + ? engagement.assignments + : [] + return getCountableAssignments(assignments) + }, [engagement]) + const countableAssignmentMemberIds = useMemo(() => { + const memberIds = countableAssignments + .map((assignment) => assignment && assignment.memberId) + .filter(Boolean) + return new Set(memberIds.map((memberId) => String(memberId))) + }, [countableAssignments]) + const assignedMemberCount = useMemo(() => { + if (countableAssignments.length) { + return countableAssignments.length + } + + const assignedMembers = Array.isArray(engagement && engagement.assignedMembers) + ? engagement.assignedMembers + : [] + if (assignedMembers.length) { + return assignedMembers.length + } + + const assignedMemberHandles = Array.isArray(engagement && engagement.assignedMemberHandles) + ? engagement.assignedMemberHandles + : [] + return assignedMemberHandles.length + }, [countableAssignments, engagement]) + const requiredMemberCountValue = Number(engagement && engagement.requiredMemberCount) + const hasRequiredMemberCount = Number.isInteger(requiredMemberCountValue) && requiredMemberCountValue > 0 const filteredApplications = useMemo(() => { let results = applications || [] @@ -288,6 +323,11 @@ const ApplicationsList = ({ setIsAccepting(false) } + /** + * Submits acceptance details for the selected application. + * Propagated API failures are handled locally, and capacity-related failures + * are surfaced with a dedicated modal instead of a generic toast. + */ const handleAcceptSubmit = async () => { if (!acceptApplication || isAccepting) { return @@ -335,11 +375,21 @@ const ApplicationsList = ({ setAcceptSuccess({ memberLabel }) resetAcceptState() } catch (error) { - setIsAccepting(false) + resetAcceptState() + const errorMessage = error && error.response && error.response.data + ? error.response.data.message + : '' + const errorStatus = error && error.response ? error.response.status : null + + if (isCapacityLimitError(errorMessage, errorStatus)) { + setCapacityError(true) + } else { + setIsAccepting(false) + } } } - const handleStatusChange = (application, option) => { + const handleStatusChange = async (application, option) => { if (!option) { return } @@ -347,10 +397,22 @@ const ApplicationsList = ({ if (application.status === 'SELECTED') { return } + const applicationUserId = application.userId || application.user_id || application.memberId || application.member_id + const isExistingAssignedMember = applicationUserId != null && countableAssignmentMemberIds.has(String(applicationUserId)) + const isAtCapacity = hasRequiredMemberCount && assignedMemberCount >= requiredMemberCountValue + + if (isAtCapacity && !isExistingAssignedMember) { + setCapacityError(true) + return + } openAcceptModal(application) return } - onUpdateStatus(application.id, option.value) + try { + await onUpdateStatus(application.id, option.value) + } catch (error) { + // Failures are already surfaced by reducer toasts where appropriate. + } } return ( @@ -480,6 +542,19 @@ const ApplicationsList = ({ )} + {capacityError && ( + setCapacityError(false)}> +
+
Cannot Select Applicant
+
+ {CAPACITY_ERROR_MODAL_MESSAGE} +
+
+ setCapacityError(false)} /> +
+
+
+ )}
@@ -615,6 +690,10 @@ ApplicationsList.propTypes = { assignedMembers: PropTypes.arrayOf( PropTypes.oneOfType([PropTypes.string, PropTypes.number]) ), + assignedMemberHandles: PropTypes.arrayOf( + PropTypes.string + ), + requiredMemberCount: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), assignments: PropTypes.arrayOf(PropTypes.shape({ memberId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), status: PropTypes.string, diff --git a/src/components/AssetsLibrary/ModalAddLink/index.js b/src/components/AssetsLibrary/ModalAddLink/index.js index 420f287a..55906f28 100644 --- a/src/components/AssetsLibrary/ModalAddLink/index.js +++ b/src/components/AssetsLibrary/ModalAddLink/index.js @@ -26,6 +26,7 @@ const ModalAddLink = ({ classsName, theme, onCancel, + onSaved, link, addAttachment, updateAttachment, @@ -70,6 +71,7 @@ const ModalAddLink = ({ toastr.success('Success', 'Added link to the project successfully.') setIsProcessing(false) addAttachment(result) + onSaved() onCancel() }) .catch(e => { @@ -88,6 +90,7 @@ const ModalAddLink = ({ toastr.success('Success', 'Updated link successfully.') setIsProcessing(false) updateAttachment(result) + onSaved() onCancel() }) .catch(e => { @@ -153,13 +156,15 @@ const ModalAddLink = ({ ModalAddLink.defaultProps = { isProcessing: false, projectId: '', - onCancel: () => {} + onCancel: () => {}, + onSaved: () => {} } ModalAddLink.propTypes = { classsName: PropTypes.string, theme: PropTypes.shape(), onCancel: PropTypes.func, + onSaved: PropTypes.func, addAttachment: PropTypes.func.isRequired, updateAttachment: PropTypes.func.isRequired, link: PropTypes.shape(), diff --git a/src/components/AssetsLibrary/ModalAttachmentOptions/index.js b/src/components/AssetsLibrary/ModalAttachmentOptions/index.js index e879746a..d2c326fd 100644 --- a/src/components/AssetsLibrary/ModalAttachmentOptions/index.js +++ b/src/components/AssetsLibrary/ModalAttachmentOptions/index.js @@ -26,12 +26,12 @@ const ModalAttachmentOptions = ({ classsName, theme, onCancel, + onSaved, attachment, members, addAttachment, updateAttachment, projectId, - loggedInUser, newAttachments }) => { const [isProcessing, setIsProcessing] = useState(false) @@ -71,6 +71,7 @@ const ModalAttachmentOptions = ({ toastr.success('Success', 'Updated file successfully.') setIsProcessing(false) updateAttachment(result) + onSaved() onCancel() }) .catch(e => { @@ -90,10 +91,14 @@ const ModalAttachmentOptions = ({ allowedUsers => { let count = newAttachments.length let errorMessage = '' + let hasSuccessfulUpload = false const checkToFinish = () => { count = count - 1 if (count === 0) { setIsProcessing(false) + if (hasSuccessfulUpload) { + onSaved() + } if (errorMessage) { toastr.error('Error', errorMessage) } else { @@ -109,6 +114,7 @@ const ModalAttachmentOptions = ({ allowedUsers }) .then(result => { + hasSuccessfulUpload = true addAttachment(result) checkToFinish() }) @@ -196,7 +202,6 @@ const ModalAttachmentOptions = ({ value={value} onChangeValue={onChange} projectMembers={members} - loggedInUser={loggedInUser} /> )} name='allowedUsers' @@ -253,20 +258,21 @@ const ModalAttachmentOptions = ({ ModalAttachmentOptions.defaultProps = { members: [], newAttachments: [], - projectId: '' + projectId: '', + onSaved: () => {} } ModalAttachmentOptions.propTypes = { classsName: PropTypes.string, theme: PropTypes.shape(), onCancel: PropTypes.func, + onSaved: PropTypes.func, attachment: PropTypes.shape(), newAttachments: PropTypes.arrayOf(PropTypes.shape()), members: PropTypes.arrayOf(PropTypes.shape()), addAttachment: PropTypes.func.isRequired, updateAttachment: PropTypes.func.isRequired, - projectId: PropTypes.string, - loggedInUser: PropTypes.object + projectId: PropTypes.string } const mapStateToProps = () => { diff --git a/src/components/AssetsLibrary/ProjectMembers/index.js b/src/components/AssetsLibrary/ProjectMembers/index.js index ad9eeb6c..a841d64b 100644 --- a/src/components/AssetsLibrary/ProjectMembers/index.js +++ b/src/components/AssetsLibrary/ProjectMembers/index.js @@ -6,12 +6,13 @@ import _ from 'lodash' import styles from './styles.module.scss' import ProjectMember from '../ProjectMember' import cn from 'classnames' +import { getProjectMemberByUserId } from '../../../util/tc' const ProjectMembers = ({ classsName, members, allowedUsers, maxShownNum }) => { const [showAll, setShowAll] = useState(false) const allowedUserInfos = useMemo(() => { const results = _.uniqBy( - _.compact(allowedUsers.map(userId => _.find(members, { userId }))), + _.compact(allowedUsers.map(userId => getProjectMemberByUserId(members, userId))), 'userId' ) let extra = 0 @@ -53,7 +54,9 @@ ProjectMembers.defaultProps = { ProjectMembers.propTypes = { classsName: PropTypes.string, maxShownNum: PropTypes.number, - allowedUsers: PropTypes.arrayOf(PropTypes.number), + allowedUsers: PropTypes.arrayOf( + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + ), members: PropTypes.arrayOf(PropTypes.shape()) } diff --git a/src/components/AssetsLibrary/ProjectMembers/index.test.js b/src/components/AssetsLibrary/ProjectMembers/index.test.js new file mode 100644 index 00000000..88fcecff --- /dev/null +++ b/src/components/AssetsLibrary/ProjectMembers/index.test.js @@ -0,0 +1,60 @@ +/* eslint-disable react/prop-types */ +/* global describe, it, expect, beforeEach, afterEach, jest */ + +import React from 'react' +import ReactDOM from 'react-dom' +import { act } from 'react-dom/test-utils' +import ProjectMembers from './index' + +jest.mock('../../../util/tc', () => ({ + getProjectMemberByUserId: (projectMembers, userId) => ( + (projectMembers || []).find(member => `${member.userId}` === `${userId}`) || null + ) +})) + +jest.mock('../ProjectMember', () => { + const React = require('react') + + return function MockProjectMember ({ memberInfo }) { + return React.createElement( + 'span', + { className: 'project-member' }, + memberInfo.handle || memberInfo.userId + ) + } +}) + +describe('ProjectMembers', () => { + let container + + 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('matches shared users even when allowed user ids are strings', () => { + renderComponent({ + members: [ + { userId: 305384, handle: 'bopowfamo' } + ], + allowedUsers: ['305384'] + }) + + expect(container.textContent).toContain('bopowfamo') + }) +}) diff --git a/src/components/AssetsLibrary/TableAssets/index.js b/src/components/AssetsLibrary/TableAssets/index.js index e5dc40c4..6e9a5c77 100644 --- a/src/components/AssetsLibrary/TableAssets/index.js +++ b/src/components/AssetsLibrary/TableAssets/index.js @@ -2,7 +2,6 @@ import React, { useMemo } from 'react' import PropTypes from 'prop-types' -import _ from 'lodash' import moment from 'moment' import styles from './styles.module.scss' import Table from '../../Table' @@ -17,6 +16,7 @@ import { } from '../../../config/constants' import ProjectMembers from '../ProjectMembers' import ProjectMember from '../ProjectMember' +import { getProjectMemberByUserId } from '../../../util/tc' const TableAssets = ({ classsName, @@ -34,13 +34,23 @@ const TableAssets = ({ () => datas.map(item => { const titles = item.title.split('.') - const owner = _.find(members, { userId: item.createdBy }) + const owner = + getProjectMemberByUserId(members, item.createdBy) || + item.createdByUser || + (`${item.createdBy}` === `${loggedInUser.userId}` ? loggedInUser : null) const canEdit = `${item.createdBy}` === `${loggedInUser.userId}` || isAdmin + const isSharedWithAdmins = + item.allowedUsers === 0 || item.allowedUsers === '0' + const sharedWithUsers = Array.isArray(item.allowedUsers) + ? item.allowedUsers + : [] return { ...item, fileType: titles[titles.length - 1], owner, + isSharedWithAdmins, + sharedWithUsers, updatedAtString: item.updatedAt ? moment(item.updatedAt).format('MM/DD/YYYY h:mm A') : '—', @@ -89,14 +99,14 @@ const TableAssets = ({ )} - {!item.allowedUsers && PROJECT_ASSETS_SHARED_WITH_ALL_MEMBERS} - {item.allowedUsers && - item.allowedUsers === 0 && - PROJECT_ASSETS_SHARED_WITH_ADMIN} - {item.allowedUsers && item.allowedUsers !== 0 && ( + {!item.isSharedWithAdmins && + item.sharedWithUsers.length === 0 && + PROJECT_ASSETS_SHARED_WITH_ALL_MEMBERS} + {item.isSharedWithAdmins && PROJECT_ASSETS_SHARED_WITH_ADMIN} + {item.sharedWithUsers.length > 0 && ( )} diff --git a/src/components/AssetsLibrary/TableAssets/index.test.js b/src/components/AssetsLibrary/TableAssets/index.test.js new file mode 100644 index 00000000..63d175d5 --- /dev/null +++ b/src/components/AssetsLibrary/TableAssets/index.test.js @@ -0,0 +1,129 @@ +/* eslint-disable react/prop-types */ +/* global describe, it, expect, beforeEach, afterEach, jest */ + +import React from 'react' +import ReactDOM from 'react-dom' +import { act } from 'react-dom/test-utils' +import TableAssets from './index' + +jest.mock('../../Icons/IconThreeDot', () => { + const React = require('react') + return function MockIconThreeDot () { + return React.createElement('span', null, 'menu') + } +}) + +jest.mock('../../DropdownMenu', () => { + const React = require('react') + return function MockDropdownMenu ({ children }) { + return React.createElement('div', null, children) + } +}) + +jest.mock('../DownloadFile', () => { + const React = require('react') + return function MockDownloadFile ({ file }) { + return React.createElement('span', null, file.title) + } +}) + +jest.mock('../../Icons/IconFile', () => { + const React = require('react') + return function MockIconFile () { + return React.createElement('span', null, 'file') + } +}) + +jest.mock('../ProjectMembers', () => { + const React = require('react') + return function MockProjectMembers ({ allowedUsers }) { + return React.createElement('span', null, allowedUsers.join(',')) + } +}) + +jest.mock('../ProjectMember', () => { + const React = require('react') + return function MockProjectMember ({ memberInfo }) { + return React.createElement('span', null, memberInfo.handle || memberInfo.userId) + } +}) + +jest.mock('../../../util/tc', () => ({ + getProjectMemberByUserId: (projectMembers, userId) => ( + (projectMembers || []).find(member => `${member.userId}` === `${userId}`) || null + ) +})) + +describe('TableAssets', () => { + let container + + 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 all project members when allowedUsers is an empty array', () => { + renderComponent({ + datas: [ + { + id: 1, + title: 'link', + path: 'https://example.com', + type: 'link', + allowedUsers: [], + createdBy: '123', + updatedAt: '2026-03-09T00:00:00.000Z' + } + ], + members: [ + { userId: '123', handle: 'owner' } + ] + }) + + expect(container.textContent).toContain('All Project Members') + }) + + it('shows the logged-in user as creator when the creator is not in project members', () => { + renderComponent({ + datas: [ + { + id: 2, + title: 'link', + path: 'https://example.com', + type: 'link', + allowedUsers: [], + createdBy: '456', + updatedAt: '2026-03-09T00:00:00.000Z' + } + ], + loggedInUser: { + userId: '456', + handle: 'current-user' + } + }) + + expect(container.textContent).toContain('current-user') + expect(container.textContent).not.toContain('Unknown') + }) +}) diff --git a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js index 0de3fa0b..d22fd537 100644 --- a/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js +++ b/src/components/ChallengeEditor/ChallengeReviewer-Field/index.js @@ -61,6 +61,45 @@ const normalizeTrackForScorecards = (challenge, metadata) => { return null } +const normalizePhaseToken = (value) => (value || '') + .toString() + .toLowerCase() + .trim() + .replace(/\bphase\b$/, '') + .replace(/[-_\s]/g, '') + +const normalizeIdValue = (value) => ( + value === undefined || value === null + ? '' + : value.toString() +) + +const getScorecardsForPhase = (scorecards = [], phases = [], phaseId) => { + const normalizedPhaseId = normalizeIdValue(phaseId) + if (!normalizedPhaseId) { + return [] + } + + const selectedPhase = phases.find(phase => ( + normalizeIdValue(phase.phaseId) === normalizedPhaseId || + normalizeIdValue(phase.id) === normalizedPhaseId + )) + + if (!selectedPhase || !selectedPhase.name) { + return [] + } + + const normalizedPhaseName = normalizePhaseToken(selectedPhase.name) + if (!normalizedPhaseName) { + return [] + } + + return scorecards.filter(scorecard => ( + scorecard && + normalizePhaseToken(scorecard.type) === normalizedPhaseName + )) +} + class ChallengeReviewerField extends Component { constructor (props) { super(props) @@ -602,6 +641,31 @@ class ChallengeReviewerField extends Component { baseCoefficient: defaultReviewer.baseCoefficient, incrementalCoefficient: defaultReviewer.incrementalCoefficient }) + + if (updatedReviewers[index] && (updatedReviewers[index].isMemberReview !== false)) { + const { metadata = {} } = this.props + const scorecardsForPhase = getScorecardsForPhase( + metadata.scorecards || [], + challenge.phases || [], + value + ) + const currentScorecardId = normalizeIdValue(updatedReviewers[index].scorecardId) + const hasCurrentScorecard = scorecardsForPhase.some(scorecard => ( + normalizeIdValue(scorecard.id) === currentScorecardId + )) + + if (!hasCurrentScorecard) { + const defaultScorecardId = normalizeIdValue(defaultReviewer && defaultReviewer.scorecardId) + const hasDefaultScorecard = defaultScorecardId && scorecardsForPhase.some(scorecard => ( + normalizeIdValue(scorecard.id) === defaultScorecardId + )) + const fallbackScorecardId = hasDefaultScorecard + ? defaultScorecardId + : normalizeIdValue(scorecardsForPhase[0] && scorecardsForPhase[0].id) + + fieldUpdate.scorecardId = fallbackScorecardId || '' + } + } } if (field === 'memberReviewerCount') { @@ -661,29 +725,12 @@ class ChallengeReviewerField extends Component { const { challenge, metadata = {}, readOnly = false } = this.props const { scorecards = [], workflows = [] } = metadata const validationErrors = challenge.submitTriggered ? this.validateReviewer(reviewer) : {} - const selectedPhase = challenge.phases.find(p => p.phaseId === reviewer.phaseId) + const filteredScorecards = getScorecardsForPhase( + scorecards, + challenge.phases || [], + reviewer.phaseId + ) const isDesignChallenge = challenge && challenge.trackId === DES_TRACK_ID - const normalize = (value) => (value || '') - .toString() - .toLowerCase() - .trim() - .replace(/\bphase\b$/, '') - .replace(/[-_\s]/g, '') - - const filteredScorecards = scorecards.filter(item => { - if (!selectedPhase || !selectedPhase.name || !item || !item.type) { - return false - } - - const normalizedType = normalize(item.type) - const normalizedPhaseName = normalize(selectedPhase.name) - - if (!normalizedType || !normalizedPhaseName) { - return false - } - - return normalizedType === normalizedPhaseName - }) return (
diff --git a/src/components/ChallengeEditor/ChallengeView/index.js b/src/components/ChallengeEditor/ChallengeView/index.js index ee500aaa..8637bd6a 100644 --- a/src/components/ChallengeEditor/ChallengeView/index.js +++ b/src/components/ChallengeEditor/ChallengeView/index.js @@ -31,6 +31,7 @@ import { import PhaseInput from '../../PhaseInput' import CheckpointPrizesField from '../CheckpointPrizes-Field' import { isBetaMode } from '../../../util/localstorage' +import WiproAllowedField from '../WiproAllowedField' const ChallengeView = ({ projectDetail, @@ -95,6 +96,7 @@ const ChallengeView = ({ if (isLoading || _.isEmpty(metadata.challengePhases) || challenge.id !== challengeId) return const showTimeline = false // disables the timeline for time being https://github.com/topcoder-platform/challenge-engine-ui/issues/706 const isTask = _.get(challenge, 'task.isTask', false) + const isFunChallenge = challenge.funChallenge === true const phases = _.get(challenge, 'phases', []) const showCheckpointPrizes = _.get(challenge, 'timelineTemplateId') === MULTI_ROUND_CHALLENGE_TEMPLATE_ID const useDashboardData = _.find(challenge.metadata, { name: 'show_data_dashboard' }) @@ -195,6 +197,7 @@ const ChallengeView = ({ <> {dashboardToggle} + {}} readOnly />
Groups: {groups} @@ -262,14 +265,24 @@ const ChallengeView = ({ token={token} readOnly />} - - { - showCheckpointPrizes && ( - - ) - } - - + {isFunChallenge ? ( +
+
+ Fun Challenge: True +
+
+ ) : ( + <> + + { + showCheckpointPrizes && ( + + ) + } + + + + )}
diff --git a/src/components/ChallengeEditor/FunChallengeField/FunChallengeField.module.scss b/src/components/ChallengeEditor/FunChallengeField/FunChallengeField.module.scss new file mode 100644 index 00000000..422b7433 --- /dev/null +++ b/src/components/ChallengeEditor/FunChallengeField/FunChallengeField.module.scss @@ -0,0 +1,76 @@ +@use '../../../styles/includes' as *; + +.row { + box-sizing: border-box; + display: flex; + flex-direction: row; + margin: 30px 30px 0 30px; + align-content: space-between; + justify-content: flex-start; + + .tcCheckbox { + @include tc-checkbox; + + height: 18px; + width: 210px; + margin: 0; + padding: 0; + vertical-align: bottom; + position: relative; + display: inline-block; + + input[type='checkbox'] { + display: none; + } + + label { + @include roboto-light(); + + line-height: 17px; + font-weight: 300; + cursor: pointer; + position: absolute; + display: inline-block; + width: 14px; + height: 14px; + top: 0; + left: 0; + border: none; + box-shadow: none; + background: $tc-gray-30; + transition: all 0.15s ease-in-out; + + &::after { + opacity: 0; + content: ''; + position: absolute; + width: 9px; + height: 5px; + background: transparent; + top: 2px; + left: 2px; + border-top: none; + border-right: none; + transform: rotate(-45deg); + transition: all 0.15s ease-in-out; + } + + &:hover::after { + opacity: 0.3; + } + + div { + margin-left: 24px; + width: 300px; + } + } + + input[type='checkbox']:checked ~ label { + background: $tc-blue-20; + } + + input[type='checkbox']:checked + label::after { + border-color: $white; + } + } +} diff --git a/src/components/ChallengeEditor/FunChallengeField/index.js b/src/components/ChallengeEditor/FunChallengeField/index.js new file mode 100644 index 00000000..d826b916 --- /dev/null +++ b/src/components/ChallengeEditor/FunChallengeField/index.js @@ -0,0 +1,47 @@ +import React from 'react' +import PropTypes from 'prop-types' +import styles from './FunChallengeField.module.scss' + +/** + * Renders a checkbox to toggle the `funChallenge` flag for Marathon Match challenges. + * + * @param {Object} props component props + * @param {Object} props.challenge challenge data object that may include `funChallenge` + * @param {Function} props.onUpdateOthers callback used to update top-level challenge fields + * @param {boolean} props.readOnly when true, renders the control as read-only + * @returns {import('react').ReactNode} rendered fun challenge checkbox field + */ +const FunChallengeField = ({ challenge, onUpdateOthers, readOnly }) => { + const isFunChallenge = challenge.funChallenge === true + + return ( +
+
+ onUpdateOthers({ field: 'funChallenge', value: !isFunChallenge })} + /> + +
+
+ ) +} + +FunChallengeField.defaultProps = { + readOnly: false +} + +FunChallengeField.propTypes = { + challenge: PropTypes.shape().isRequired, + onUpdateOthers: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} + +export default FunChallengeField diff --git a/src/components/ChallengeEditor/Resources/index.js b/src/components/ChallengeEditor/Resources/index.js index 8e1f44f9..fef48d22 100644 --- a/src/components/ChallengeEditor/Resources/index.js +++ b/src/components/ChallengeEditor/Resources/index.js @@ -352,34 +352,33 @@ export default class Resources extends React.Component { > - {!isDesign && ( - - - - )} + +
+ + +
+ {!isNew && ( +
+
+ +
+
+ {canEdit && canEditParentProject ? ( + { onChangeValue((values || []).map(value => value.value)) }} - options={(projectMembers || []) - .filter(member => member.userId !== loggedInUser.userId) - .map(member => ({ value: member.userId, label: member.handle }))} + options={memberOptions} /> ) } @@ -44,18 +85,16 @@ FieldUserAutoComplete.defaultProps = { onChangeValue: () => {}, id: 'user-select', value: [], - projectMembers: [], - loggedInUser: {} + projectMembers: [] } FieldUserAutoComplete.propTypes = { value: PropTypes.arrayOf( - PropTypes.oneOfType(PropTypes.string, PropTypes.number) + PropTypes.oneOfType([PropTypes.string, PropTypes.number]) ), id: PropTypes.string, onChangeValue: PropTypes.func, - projectMembers: PropTypes.arrayOf(PropTypes.object), - loggedInUser: PropTypes.object + projectMembers: PropTypes.arrayOf(PropTypes.object) } export default FieldUserAutoComplete diff --git a/src/components/FieldUserAutoComplete/index.test.js b/src/components/FieldUserAutoComplete/index.test.js new file mode 100644 index 00000000..a1ddc85b --- /dev/null +++ b/src/components/FieldUserAutoComplete/index.test.js @@ -0,0 +1,94 @@ +/* eslint-disable react/prop-types */ +/* global describe, it, expect, beforeEach, afterEach, jest */ + +import React from 'react' +import ReactDOM from 'react-dom' +import { act } from 'react-dom/test-utils' +import FieldUserAutoComplete from './index' + +jest.mock('../../util/tc', () => ({ + getProjectMemberByUserId: (projectMembers, userId) => ( + (projectMembers || []).find(member => `${member.userId}` === `${userId}`) || null + ) +})) + +jest.mock('../Select', () => { + const React = require('react') + + return function MockSelect ({ options, value }) { + return React.createElement( + 'div', + null, + React.createElement( + 'div', + { className: 'selected-values' }, + (value || []).map(item => `${item.value}:${item.label}`).join('|') + ), + React.createElement( + 'div', + { className: 'option-values' }, + (options || []).map(item => `${item.value}:${item.label}`).join('|') + ) + ) + } +}) + +describe('FieldUserAutoComplete', () => { + let container + + const defaultProps = { + value: [], + onChangeValue: () => {}, + id: 'user-select', + projectMembers: [] + } + + 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('includes all project members using handle, email, or userId labels', () => { + renderComponent({ + projectMembers: [ + { userId: 305384, handle: 'bopowfamo' }, + { userId: 101, email: 'tetal003@example.com' }, + { userId: '202' } + ] + }) + + const optionValues = container.querySelector('.option-values').textContent + + expect(optionValues).toContain('305384:bopowfamo') + expect(optionValues).toContain('101:tetal003@example.com') + expect(optionValues).toContain('202:202') + }) + + it('resolves selected member labels when selected ids and member ids use different types', () => { + renderComponent({ + value: ['305384'], + projectMembers: [ + { userId: 305384, handle: 'bopowfamo' } + ] + }) + + const selectedValues = container.querySelector('.selected-values').textContent + + expect(selectedValues).toContain('305384:bopowfamo') + }) +}) diff --git a/src/components/ProjectCard/index.js b/src/components/ProjectCard/index.js index 97376dc4..e387775d 100644 --- a/src/components/ProjectCard/index.js +++ b/src/components/ProjectCard/index.js @@ -8,11 +8,11 @@ import { PROJECT_STATUSES } from '../../config/constants' import styles from './ProjectCard.module.scss' -const ProjectCard = ({ projectName, projectStatus, projectId, selected, isInvited }) => { +const ProjectCard = ({ projectName, projectStatus, projectId, selected }) => { return (
@@ -28,7 +28,6 @@ ProjectCard.propTypes = { projectStatus: PT.string.isRequired, projectId: PT.number.isRequired, projectName: PT.string.isRequired, - isInvited: PT.bool.isRequired, selected: PT.bool } diff --git a/src/components/Tab/index.js b/src/components/Tab/index.js index f5c94cd2..0b8b3734 100644 --- a/src/components/Tab/index.js +++ b/src/components/Tab/index.js @@ -9,7 +9,6 @@ const Tab = ({ projectId, canViewAssets, canViewEngagements, // Admin or TM - isAdmin, // Only admin onBack }) => { const projectTabs = [ @@ -22,7 +21,7 @@ const Tab = ({ : [ { id: 1, label: 'All Work' }, { id: 2, label: 'Projects' }, - ...(isAdmin ? [{ id: 3, label: 'Engagements' }] : []), + ...(canViewEngagements ? [{ id: 3, label: 'Engagements' }] : []), { id: 4, label: 'Users' }, { id: 5, label: 'Self-Service' }, { id: 6, label: 'TaaS' }, @@ -89,7 +88,6 @@ Tab.defaultProps = { projectId: null, canViewAssets: true, canViewEngagements: false, - isAdmin: false, onBack: () => {} } @@ -99,7 +97,6 @@ Tab.propTypes = { projectId: PT.oneOfType([PT.string, PT.number]), canViewAssets: PT.bool, canViewEngagements: PT.bool, - isAdmin: PT.bool, onBack: PT.func } diff --git a/src/components/UserCard/index.js b/src/components/UserCard/index.js index 58b30ab7..03970d87 100644 --- a/src/components/UserCard/index.js +++ b/src/components/UserCard/index.js @@ -13,6 +13,22 @@ const theme = { container: styles.modalContainer } +function normalizeDisplayValue (value) { + if (_.isNil(value)) { + return null + } + + const normalizedValue = String(value).trim() + + return normalizedValue || 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. + */ class UserCard extends Component { constructor (props) { super(props) @@ -61,19 +77,26 @@ class UserCard extends Component { render () { const { isInvite, user, onRemoveClick, isEditable } = this.props const showRadioButtons = _.includes(_.values(PROJECT_ROLES), user.role) + const userDisplayName = normalizeDisplayValue(user.handle) || + normalizeDisplayValue(user.userId) || + '(unknown user)' + const inviteDisplayName = normalizeDisplayValue(user.email) || + normalizeDisplayValue(user.handle) || + normalizeDisplayValue(user.userId) || + '(unknown user)' return (
{ this.state.isUpdatingPermission && ( ) } {this.state.showWarningModal && (
- {isInvite ? (user.email || user.handle) : user.handle} + {isInvite ? inviteDisplayName : userDisplayName}
{!isInvite && ( <> diff --git a/src/components/Users/index.js b/src/components/Users/index.js index 90bd4401..38fbf07b 100644 --- a/src/components/Users/index.js +++ b/src/components/Users/index.js @@ -19,6 +19,18 @@ const theme = { container: styles.modalContainer } +function getUserDisplayName (user) { + if (!user) { + return '(unknown user)' + } + + const displayName = [user.handle, user.email, user.userId] + .map(value => typeof value === 'undefined' || value === null ? '' : String(value).trim()) + .find(Boolean) + + return displayName || '(unknown user)' +} + class Users extends Component { constructor (props) { super(props) @@ -263,7 +275,7 @@ class Users extends Component { this.state.showRemoveConfirmationModal && ( {(dashboard || activeProjectId !== -1 || selfService) && ( ({ id: null, + projectId: null, title: '', description: '', durationWeeks: '', @@ -128,9 +130,10 @@ class EngagementEditorContainer extends Component { this.onDelete = this.onDelete.bind(this) this.onToggleDelete = this.onToggleDelete.bind(this) this.resolveMemberIds = this.resolveMemberIds.bind(this) + this.loadParentProjectOptions = this.loadParentProjectOptions.bind(this) } - componentDidMount () { + async componentDidMount () { const { match, loadEngagementDetails, loadProject } = this.props const projectId = this.getProjectId(match) const engagementId = _.get(match.params, 'engagementId', null) @@ -138,7 +141,41 @@ class EngagementEditorContainer extends Component { loadProject(projectId) } if (engagementId) { - loadEngagementDetails(projectId, engagementId) + await loadEngagementDetails(projectId, engagementId) + } + } + + /** + * Loads parent project autocomplete options for the engagement editor. + * + * Performs a name-filtered lookup and returns a small option set so the + * editor does not fetch every project before opening the dropdown. + * + * @param {string} inputValue User-entered search text. + * @returns {Promise>} Select options. + */ + async loadParentProjectOptions (inputValue) { + const query = typeof inputValue === 'string' ? inputValue.trim() : '' + if (query.length < 2) { + return [] + } + + try { + const response = await fetchMemberProjects({ + name: query, + page: 1, + perPage: 20, + sort: 'name asc' + }) + const projects = _.get(response, 'projects', []) + return _.uniqBy(projects, 'id') + .filter((project) => project && project.id != null) + .map((project) => ({ + label: project.name || `Project ${project.id}`, + value: String(project.id) + })) + } catch (error) { + return [] } } @@ -245,6 +282,7 @@ class EngagementEditorContainer extends Component { return { ...getEmptyEngagement(), ...normalized, + projectId: normalized.projectId || null, durationWeeks, role: fromEngagementRoleApi(normalized.role), workload: fromEngagementWorkloadApi(normalized.workload), @@ -387,6 +425,7 @@ class EngagementEditorContainer extends Component { } buildPayload (engagement, isDraft) { + const currentProjectId = this.getProjectId(this.props.match) const status = engagement.status || (isDraft ? 'Open' : '') const requiredSkills = (engagement.skills || []) .map((skill) => { @@ -523,6 +562,13 @@ class EngagementEditorContainer extends Component { } } + if ( + engagement.projectId && + `${engagement.projectId}` !== `${currentProjectId}` + ) { + payload.projectId = String(engagement.projectId) + } + return payload } @@ -656,6 +702,16 @@ class EngagementEditorContainer extends Component { return isAdmin || isManager || isTaskManager || isProjectManager } + /** + * Indicates whether current user can change the engagement Parent Project. + * + * @returns {boolean} + */ + canEditParentProject () { + const { auth } = this.props + return checkAdmin(auth.token) || checkTalentManager(auth.token) + } + render () { const { match, isLoading } = this.props const engagementId = _.get(match.params, 'engagementId', null) @@ -670,6 +726,9 @@ class EngagementEditorContainer extends Component { isLoading={isLoading} isSaving={this.state.isSaving} canEdit={this.canEdit()} + currentProjectName={_.get(this.props.projectDetail, 'name', null)} + loadParentProjectOptions={this.loadParentProjectOptions} + canEditParentProject={this.canEditParentProject()} submitTriggered={this.state.submitTriggered} validationErrors={this.state.validationErrors} showDeleteModal={this.state.showDeleteModal} @@ -705,6 +764,10 @@ class EngagementEditorContainer extends Component { } } +/** + * Engagement editor container props. + * `engagementDetails.projectId` is optional and is used to preselect Parent Project. + */ EngagementEditorContainer.propTypes = { match: PropTypes.shape({ params: PropTypes.shape({ @@ -727,7 +790,10 @@ EngagementEditorContainer.propTypes = { role: PropTypes.string })) }), - engagementDetails: PropTypes.shape(), + engagementDetails: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + }), isLoading: PropTypes.bool, loadEngagementDetails: PropTypes.func.isRequired, createEngagement: PropTypes.func.isRequired, diff --git a/src/containers/EngagementsList/index.js b/src/containers/EngagementsList/index.js index a1b5f5b8..15ed6c04 100644 --- a/src/containers/EngagementsList/index.js +++ b/src/containers/EngagementsList/index.js @@ -4,10 +4,16 @@ import PropTypes from 'prop-types' import { connect } from 'react-redux' import { withRouter } from 'react-router-dom' import EngagementsList from '../../components/EngagementsList' -import { loadEngagements } from '../../actions/engagements' +import { loadEngagements, deleteEngagement } from '../../actions/engagements' import { loadProject } from '../../actions/projects' -import { checkAdminOrPmOrTaskManager } from '../../util/tc' +import { fetchMemberProjects } from '../../services/projects' +import { checkAdmin, checkAdminOrPmOrTaskManager, checkTalentManager } from '../../util/tc' +/** + * Loads and wires engagement list data for the current project context. + * Computes permission flags (`canManage`, `isAdmin`) and injects dispatch props, + * including `deleteEngagement` for admin-only delete flows in the list UI. + */ class EngagementsListContainer extends Component { componentDidMount () { this.loadData() @@ -32,7 +38,7 @@ class EngagementsListContainer extends Component { return projectId ? parseInt(projectId, 10) : null } - loadData () { + async loadData () { const projectId = this.getProjectId() const { loadProject, loadEngagements, allEngagements } = this.props if (projectId) { @@ -41,6 +47,13 @@ class EngagementsListContainer extends Component { if (!projectId && !allEngagements) { return } + + if (!projectId && allEngagements && this.isTalentManagerOnly()) { + const tmProjectIds = await this.loadTmProjectIds() + loadEngagements(null, 'all', '', true, tmProjectIds) + return + } + loadEngagements(projectId, 'all', '', this.canIncludePrivate()) } @@ -57,6 +70,63 @@ class EngagementsListContainer extends Component { return checkAdminOrPmOrTaskManager(auth.token, null) } + isAdmin () { + const { auth } = this.props + if (!auth || !auth.token) { + return false + } + return checkAdmin(auth.token) + } + + /** + * Checks whether the current user is a Talent Manager without Admin role. + * + * @returns {Boolean} true when user is TM-only. + */ + isTalentManagerOnly () { + const { auth } = this.props + if (!auth || !auth.token) { + return false + } + return checkTalentManager(auth.token) && !checkAdmin(auth.token) + } + + /** + * Loads all member projects for the current TM user and returns unique IDs. + * + * @returns {Promise>} Unique project ids as strings. + */ + async loadTmProjectIds () { + try { + const perPage = 100 + let page = 1 + let hasMore = true + let projects = [] + + while (hasMore) { + const response = await fetchMemberProjects({ memberOnly: true, page, perPage }) + const pageProjects = _.get(response, 'projects', []) + const totalPages = _.get(response, 'pagination.xTotalPages', null) + projects = projects.concat(pageProjects) + if (totalPages) { + hasMore = page < totalPages + } else { + hasMore = pageProjects.length === perPage + } + page += 1 + } + + return _.uniq( + projects + .map(project => _.get(project, 'id', null)) + .filter(Boolean) + .map(projectId => `${projectId}`) + ) + } catch (error) { + return [] + } + } + render () { const projectId = this.getProjectId() return ( @@ -67,6 +137,8 @@ class EngagementsListContainer extends Component { allEngagements={this.props.allEngagements} isLoading={this.props.isLoading} canManage={this.canManage()} + isAdmin={this.isAdmin()} + deleteEngagement={this.props.deleteEngagement} currentUser={this.props.auth.user} /> ) @@ -94,7 +166,8 @@ EngagementsListContainer.propTypes = { }) }).isRequired, loadEngagements: PropTypes.func.isRequired, - loadProject: PropTypes.func.isRequired + loadProject: PropTypes.func.isRequired, + deleteEngagement: PropTypes.func.isRequired } EngagementsListContainer.defaultProps = { @@ -110,7 +183,8 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = { loadEngagements, - loadProject + loadProject, + deleteEngagement } export default withRouter( diff --git a/src/containers/ProjectAssets/index.jsx b/src/containers/ProjectAssets/index.jsx index 002ce66b..c8f54898 100644 --- a/src/containers/ProjectAssets/index.jsx +++ b/src/containers/ProjectAssets/index.jsx @@ -46,6 +46,7 @@ const ProjectAssets = ({ loggedInUser, token }) => { + const projectDetailId = _.get(projectDetail, 'id') const [isProcessing, setIsProcessing] = useState(false) const [selectedTab, setSelectedTab] = useState(0) const [showDeleteFile, setShowDeleteFile] = useState(null) @@ -158,10 +159,10 @@ const ProjectAssets = ({ }, [files, links, selectedTab]) useEffect(() => { - if (projectId) { + if (projectId && `${projectDetailId || ''}` !== `${projectId}`) { loadOnlyProjectInfo(projectId) } - }, [projectId]) + }, [loadOnlyProjectInfo, projectId, projectDetailId]) if (isLoading) { return @@ -243,12 +244,12 @@ const ProjectAssets = ({ setShowAttachmentOptions(false)} + onSaved={() => loadOnlyProjectInfo(projectId)} attachment={ showAttachmentOptions === true ? null : showAttachmentOptions } members={projectDetail.members} projectId={projectId} - loggedInUser={loggedInUser} newAttachments={pendingUploadFiles} /> )} @@ -256,6 +257,7 @@ const ProjectAssets = ({ setShowAddLink(false)} + onSaved={() => loadOnlyProjectInfo(projectId)} projectId={projectId} link={showAddLink === true ? null : showAddLink} /> diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js index 2627e99e..e4cf8694 100644 --- a/src/containers/ProjectEditor/index.js +++ b/src/containers/ProjectEditor/index.js @@ -12,10 +12,11 @@ import { loadProjectTypes, loadProject, createProject, - updateProject + updateProject, + clearProjectDetail } from '../../actions/projects' import { setActiveProject } from '../../actions/sidebar' -import { checkAdminOrCopilot, checkAdmin, checkIsUserInvitedToProject } from '../../util/tc' +import { checkAdminOrCopilotOrManager, checkAdmin, checkIsUserInvitedToProject, checkManager } from '../../util/tc' import { PROJECT_ROLES } from '../../config/constants' import Loader from '../../components/Loader' @@ -28,22 +29,29 @@ class ProjectEditor extends Component { } // load the project types componentDidMount () { - const { match, isEdit, loadProjectTypes } = this.props + const { match, isEdit, loadProjectTypes, clearProjectDetail } = this.props loadProjectTypes() if (isEdit) { this.fetchProjectDetails(match) + } else { + clearProjectDetail() } } componentDidUpdate () { - const { auth } = this.props + const { auth, history, isEdit, projectDetail } = this.props - if (checkIsUserInvitedToProject(auth.token, this.props.projectDetail)) { - this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`) + if (checkIsUserInvitedToProject(auth.token, projectDetail)) { + history.push(`/projects/${projectDetail.id}/invitation`) } - if (!checkAdminOrCopilot(auth.token, this.props.projectDetail)) { - this.props.history.push('/projects') + // For create flow there is no project context yet, so only evaluate global JWT roles. + const canManageProject = isEdit + ? checkAdminOrCopilotOrManager(auth.token, projectDetail) + : checkAdminOrCopilotOrManager(auth.token) + + if (!canManageProject) { + history.push('/projects') } } @@ -96,8 +104,9 @@ class ProjectEditor extends Component { if (isProjectTypesLoading || (isEdit && isProjectLoading)) return const isAdmin = checkAdmin(this.props.auth.token) + const isManager = checkManager(this.props.auth.token) const isCopilotOrManager = this.checkIsCopilotOrManager(_.get(projectDetail, 'members', []), _.get(this.props.auth, 'user.userId', null)) - const canManage = isAdmin || isCopilotOrManager + const canManage = isAdmin || isManager || isCopilotOrManager const projectId = this.getProjectId(match) return ( @@ -153,6 +162,7 @@ ProjectEditor.propTypes = { auth: PropTypes.object, history: PropTypes.object, setActiveProject: PropTypes.func.isRequired, + clearProjectDetail: PropTypes.func.isRequired, isEdit: PropTypes.bool, loadProject: PropTypes.func, isProjectLoading: PropTypes.bool, @@ -172,6 +182,7 @@ const mapDispatchToProps = { loadProject, createProject, updateProject, + clearProjectDetail, setActiveProject } diff --git a/src/containers/ProjectEntry/index.js b/src/containers/ProjectEntry/index.js new file mode 100644 index 00000000..1215b497 --- /dev/null +++ b/src/containers/ProjectEntry/index.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import _ from 'lodash' + +import Loader from '../../components/Loader' +import { loadOnlyProjectInfo } from '../../actions/projects' +import { checkIsUserInvitedToProject } from '../../util/tc' + +/** + * Resolves the correct project landing route for `/projects/:projectId`. + * + * It loads lightweight project details first so invited users can be sent to + * the invitation modal before challenge-specific requests run. + */ +const ProjectEntry = ({ + history, + isProjectLoading, + loadOnlyProjectInfo, + match, + projectDetail, + token +}) => { + const projectId = _.get(match, 'params.projectId') + const [resolvedProjectId, setResolvedProjectId] = useState(null) + + useEffect(() => { + let isActive = true + + if (!projectId) { + history.replace('/projects') + return undefined + } + + setResolvedProjectId(null) + loadOnlyProjectInfo(projectId) + .then(() => { + if (isActive) { + setResolvedProjectId(projectId) + } + }) + .catch(() => { + if (isActive) { + history.replace('/projects') + } + }) + + return () => { + isActive = false + } + }, [history, loadOnlyProjectInfo, projectId]) + + useEffect(() => { + if ( + !resolvedProjectId || + isProjectLoading || + `${_.get(projectDetail, 'id', '')}` !== `${resolvedProjectId}` + ) { + return + } + + const destination = checkIsUserInvitedToProject(token, projectDetail) + ? `/projects/${resolvedProjectId}/invitation` + : `/projects/${resolvedProjectId}/challenges` + + history.replace(destination) + }, [history, isProjectLoading, projectDetail, resolvedProjectId, token]) + + return +} + +ProjectEntry.propTypes = { + history: PropTypes.shape({ + replace: PropTypes.func.isRequired + }).isRequired, + isProjectLoading: PropTypes.bool, + loadOnlyProjectInfo: PropTypes.func.isRequired, + match: PropTypes.shape({ + params: PropTypes.shape({ + projectId: PropTypes.string + }) + }).isRequired, + projectDetail: PropTypes.object, + token: PropTypes.string +} + +const mapStateToProps = ({ auth, projects }) => ({ + isProjectLoading: projects.isLoading, + projectDetail: projects.projectDetail, + token: auth.token +}) + +const mapDispatchToProps = { + loadOnlyProjectInfo +} + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(ProjectEntry) +) diff --git a/src/containers/Projects/index.js b/src/containers/Projects/index.js index 893f3f78..97fcc577 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 { checkAdminOrCopilotOrManager, 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) && ( + {checkAdminOrCopilotOrManager(auth.token) && ( @@ -112,7 +112,6 @@ const Projects = ({ projects, auth, isLoading, projectsCount, loadProjects, load {projects.map(p => (
  • ) @@ -264,12 +269,14 @@ TabContainer.propTypes = { backPath: PropTypes.string, resetSidebarActiveParams: PropTypes.func, selfService: PropTypes.bool, - token: PropTypes.string + token: PropTypes.string, + projectDetail: PropTypes.object } -const mapStateToProps = ({ sidebar, auth }) => ({ +const mapStateToProps = ({ sidebar, auth, projects }) => ({ ...sidebar, - token: auth.token + token: auth.token, + projectDetail: projects.projectDetail }) const mapDispatchToProps = { diff --git a/src/containers/Users/index.js b/src/containers/Users/index.js index 6f1cef67..91faaef4 100644 --- a/src/containers/Users/index.js +++ b/src/containers/Users/index.js @@ -5,7 +5,8 @@ import PT from 'prop-types' import { withRouter } from 'react-router-dom' import UsersComponent from '../../components/Users' import { PROJECT_ROLES } from '../../config/constants' -import { fetchInviteMembers, fetchProjectById } from '../../services/projects' +import { fetchProjectById, fetchProjectMembers } from '../../services/projects' +import { getProjectMemberInvites } from '../../services/projectMemberInvites' import { checkAdmin, checkManager } from '../../util/tc' import { @@ -89,24 +90,42 @@ class Users extends Component { } } + /** + * Loads project details plus project-scoped members/invites into local state. + * + * Members and invites are loaded from their dedicated project endpoints so + * handle resolution follows project permissions instead of a separate member + * directory lookup. + * + * @param {string|number} projectId Project id to load. + * @returns {void} + */ loadProject (projectId) { this.setState({ isLoadingProject: true }) - fetchProjectById(projectId).then(async (project) => { - const projectMembers = _.get(project, 'members') - const invitedMembers = _.get(project, 'invites') || [] - const invitedUserIds = _.filter(_.map(invitedMembers, 'userId')) - const invitedUsers = await fetchInviteMembers(invitedUserIds) + Promise.all([ + fetchProjectById(projectId), + fetchProjectMembers(projectId), + getProjectMemberInvites(projectId) + ]).then(([project, projectMembers, invitedMembers]) => { + const normalizedProjectMembers = projectMembers || [] + const normalizedInvitedMembers = invitedMembers || [] + let resolvedProject = this.state.project + + if (!resolvedProject && project && project.id && project.name) { + resolvedProject = { + id: project.id, + name: project.name + } + } this.setState({ - projectMembers, - invitedMembers: invitedMembers.map(m => ({ - ...m, - email: m.email || invitedUsers[m.userId].handle - })), + projectMembers: normalizedProjectMembers, + invitedMembers: normalizedInvitedMembers, + project: resolvedProject, isLoadingProject: false }) const { loggedInUser } = this.props - this.updateLoginUserRoleInProject(projectMembers, loggedInUser) + this.updateLoginUserRoleInProject(normalizedProjectMembers, loggedInUser) }) } diff --git a/src/reducers/applications.js b/src/reducers/applications.js index cfd8befd..3480988d 100644 --- a/src/reducers/applications.js +++ b/src/reducers/applications.js @@ -3,6 +3,7 @@ */ import _ from 'lodash' import { toastSuccess, toastFailure } from '../util/toaster' +import { isCapacityLimitError } from '../util/applicationErrors' import { LOAD_APPLICATIONS_PENDING, LOAD_APPLICATIONS_SUCCESS, @@ -114,7 +115,11 @@ export default function (state = initialState, action) { } case UPDATE_APPLICATION_STATUS_FAILURE: { const errorMessage = getErrorMessage(action, 'Failed to update application status') - toastFailure('Error', errorMessage) + const errorStatus = _.get(action, 'error.response.status') + const isCapacityError = isCapacityLimitError(errorMessage, errorStatus) + if (!isCapacityError) { + toastFailure('Error', errorMessage) + } return { ...state, isLoading: false, diff --git a/src/reducers/projects.js b/src/reducers/projects.js index f81713c6..6f1109a4 100644 --- a/src/reducers/projects.js +++ b/src/reducers/projects.js @@ -16,6 +16,7 @@ import { LOAD_PROJECT_DETAILS_FAILURE, LOAD_PROJECT_DETAILS_PENDING, LOAD_PROJECT_DETAILS_SUCCESS, + CLEAR_PROJECT_DETAIL, LOAD_PROJECT_PHASES_FAILURE, LOAD_PROJECT_PHASES_PENDING, LOAD_PROJECT_PHASES_SUCCESS, @@ -76,6 +77,7 @@ const initialState = { isLoading: false, isUpdating: false, projectDetail: {}, + hasProjectAccess: false, isBillingAccountsLoading: false, billingAccounts: [], isBillingAccountExpired: false, @@ -145,6 +147,12 @@ export default function (state = initialState, action) { hasProjectAccess: true, isLoading: false } + case CLEAR_PROJECT_DETAIL: + return { + ...state, + projectDetail: {}, + hasProjectAccess: initialState.hasProjectAccess + } case LOAD_PROJECT_BILLING_ACCOUNTS_PENDING: return { ...state, diff --git a/src/routes.js b/src/routes.js index f4b66bb1..3a160dd7 100644 --- a/src/routes.js +++ b/src/routes.js @@ -25,6 +25,7 @@ import EngagementExperience from './containers/EngagementExperience' import { getFreshToken, decodeToken } from 'tc-auth-lib' import { saveToken } from './actions/auth' import { loadChallengeDetails } from './actions/challenges' +import { loadOnlyProjectInfo } from './actions/projects' import { connect } from 'react-redux' import { checkAllowedRoles, @@ -33,13 +34,15 @@ import { checkAdmin, checkCopilot, checkManager, - checkAdminOrTalentManager + checkAdminOrTalentManager, + checkIsProjectMember } from './util/tc' import Users from './containers/Users' import Groups from './containers/Groups' import { isBetaMode, removeFromLocalStorage, saveToLocalStorage } from './util/localstorage' import ProjectEditor from './containers/ProjectEditor' import ProjectInvitations from './containers/ProjectInvitations' +import ProjectEntry from './containers/ProjectEntry' const { ACCOUNTS_APP_LOGIN_URL } = process.env @@ -86,10 +89,22 @@ RedirectToChallenge.propTypes = { const ConnectRedirectToChallenge = connect(mapStateToProps, mapDispatchToProps)(RedirectToChallenge) class Routes extends React.Component { + constructor (props) { + super(props) + + this.state = { + assetsAccessStatusByProjectId: {} + } + } + componentWillMount () { this.checkAuth() } + componentDidMount () { + this.resolveAssetsRouteAccess(this.props) + } + checkAuth () { // try to get a token and redirect to login page if it fails getFreshToken().then((token) => { @@ -102,7 +117,99 @@ class Routes extends React.Component { }) } - componentDidUpdate () { + /** + * Parses the pathname and returns the project id for assets routes. + * + * @param {String} pathname current location pathname + * @returns {String|null} assets route project id + */ + getAssetsProjectIdFromPath (pathname) { + const match = (pathname || '').match(/^\/projects\/([^/]+)\/assets\/?$/) + return _.get(match, '[1]', null) + } + + /** + * Stores per-project access resolution status for assets routing. + * + * @param {String} projectId route project id + * @param {String} status resolution status (`loading` or `denied`) + */ + setAssetsAccessStatus (projectId, status) { + const normalizedProjectId = `${projectId}` + this.setState(prevState => ({ + assetsAccessStatusByProjectId: { + ...prevState.assetsAccessStatusByProjectId, + [normalizedProjectId]: status + } + })) + } + + /** + * Clears a stored assets access status for the provided project id. + * + * @param {String} projectId route project id + */ + clearAssetsAccessStatus (projectId) { + const normalizedProjectId = `${projectId}` + this.setState(prevState => { + if (!_.has(prevState.assetsAccessStatusByProjectId, normalizedProjectId)) { + return null + } + + return { + assetsAccessStatusByProjectId: _.omit(prevState.assetsAccessStatusByProjectId, normalizedProjectId) + } + }) + } + + /** + * Resolves assets access for direct `/projects/:projectId/assets` navigation. + * This keeps authorization scoped to the requested route project id. + * + * @param {Object} props current component props + * @param {Object} prevProps previous component props + */ + resolveAssetsRouteAccess (props, prevProps = {}) { + const projectId = this.getAssetsProjectIdFromPath(_.get(props, 'location.pathname')) + if (!projectId || !props.isLoggedIn || !props.token) { + return + } + + if (checkAdmin(props.token) || checkCopilot(props.token)) { + return + } + + const isProjectDetailForRequestedProject = `${_.get(props, 'projectDetail.id', '')}` === `${projectId}` + if (isProjectDetailForRequestedProject) { + this.clearAssetsAccessStatus(projectId) + return + } + + const currentPath = _.get(props, 'location.pathname') + const previousPath = _.get(prevProps, 'location.pathname') + const isNewAssetsNavigation = currentPath !== previousPath + const accessStatus = _.get(this.state.assetsAccessStatusByProjectId, `${projectId}`) + if (accessStatus === 'loading' || (accessStatus === 'denied' && !isNewAssetsNavigation)) { + return + } + + this.setAssetsAccessStatus(projectId, 'loading') + this.props.loadOnlyProjectInfo(projectId) + .then(() => { + this.clearAssetsAccessStatus(projectId) + }) + .catch((error) => { + const responseStatus = _.get(error, 'payload.response.status', _.get(error, 'response.status')) + if (responseStatus === 403) { + this.setAssetsAccessStatus(projectId, 'denied') + return + } + + this.clearAssetsAccessStatus(projectId) + }) + } + + componentDidUpdate (prevProps) { const { search } = this.props.location const params = new URLSearchParams(search) if (!_.isEmpty(params.get('beta'))) { @@ -113,6 +220,8 @@ class Routes extends React.Component { } this.props.history.push(this.props.location.pathname) } + + this.resolveAssetsRouteAccess(this.props, prevProps) } render () { @@ -120,11 +229,12 @@ class Routes extends React.Component { return null } - const isAllowed = checkAllowedRoles(_.get(decodeToken(this.props.token), 'roles')) - const isReadOnly = checkReadOnlyRoles(this.props.token) - const isCopilot = checkCopilot(this.props.token) - const isAdmin = checkAdmin(this.props.token) - const canAccessEngagements = checkAdminOrTalentManager(this.props.token) + const { token, projectDetail, hasProjectAccess } = this.props + const isAllowed = checkAllowedRoles(_.get(decodeToken(token), 'roles')) + const isReadOnly = checkReadOnlyRoles(token) + const isCopilot = checkCopilot(token) + const isAdmin = checkAdmin(token) + const canAccessEngagements = checkAdminOrTalentManager(token) return ( @@ -156,7 +266,7 @@ class Routes extends React.Component { )()} /> - {isAdmin && ( + {canAccessEngagements && ( renderApp( , @@ -166,12 +276,12 @@ class Routes extends React.Component { )()} /> )} - {!isAdmin && ( + {!canAccessEngagements && ( renderApp( , , , @@ -203,20 +313,41 @@ class Routes extends React.Component { )()} /> - {(isCopilot || isAdmin) && ( - - renderApp( - , + { + const routeProjectId = _.get(match.params, 'projectId') + const isProjectDetailForRequestedProject = `${_.get(projectDetail, 'id', '')}` === `${routeProjectId}` + const hasScopedProjectAccess = isProjectDetailForRequestedProject && hasProjectAccess + const isProjectMemberForRequestedProject = isProjectDetailForRequestedProject && checkIsProjectMember(token, projectDetail) + const canViewRequestedProjectAssets = isCopilot || isAdmin || hasScopedProjectAccess || isProjectMemberForRequestedProject + const assetsAccessStatus = _.get(this.state.assetsAccessStatusByProjectId, `${routeProjectId}`) + const canResolveRequestedProjectAccess = !isCopilot && + !isAdmin && + !isProjectDetailForRequestedProject && + assetsAccessStatus !== 'denied' + + if (!canViewRequestedProjectAssets && !canResolveRequestedProjectAccess) { + return renderApp( + , , - , + , )() } - /> - )} + + return renderApp( + , + , + , + + )() + }} + /> { !isReadOnly && ( , )()} /> + renderApp( + , + , + , + + )()} + /> renderApp( , @@ -420,20 +559,26 @@ class Routes extends React.Component { } } -mapStateToProps = ({ auth }) => ({ - ...auth +mapStateToProps = ({ auth, projects }) => ({ + ...auth, + projectDetail: projects.projectDetail, + hasProjectAccess: projects.hasProjectAccess }) mapDispatchToProps = { - saveToken + saveToken, + loadOnlyProjectInfo } Routes.propTypes = { saveToken: PropTypes.func, + loadOnlyProjectInfo: PropTypes.func, location: PropTypes.object, isLoggedIn: PropTypes.bool, token: PropTypes.string, - history: PropTypes.object + history: PropTypes.object, + projectDetail: PropTypes.object, + hasProjectAccess: PropTypes.bool } export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Routes)) diff --git a/src/services/challenges.js b/src/services/challenges.js index 1119fe77..6e99dc48 100644 --- a/src/services/challenges.js +++ b/src/services/challenges.js @@ -272,10 +272,12 @@ export async function fetchResources (challengeId) { /** * Api request for fetching submissions * @param challengeId Challenge id +* @param {Object} pageObj pagination options * @returns {Promise<*>} */ -export async function fetchSubmissions (challengeId, pageObj) { - const { page, perPage } = pageObj +export async function fetchSubmissions (challengeId, pageObj = {}) { + const page = _.get(pageObj, 'page', 1) + const perPage = _.get(pageObj, 'perPage', 10) const response = await axiosInstance.get(`${SUBMISSIONS_API_URL}?challengeId=${challengeId}&perPage=${perPage}&page=${page}`) const responseData = _.get(response, 'data', {}) const meta = _.get(responseData, 'meta', {}) diff --git a/src/services/engagements.js b/src/services/engagements.js index 6ebf9eb0..c396b112 100644 --- a/src/services/engagements.js +++ b/src/services/engagements.js @@ -14,7 +14,9 @@ export function fetchEngagements (filters = {}, params = {}) { ...filters, ...params } - const queryString = qs.stringify(query, { encode: false }) + // Engagements API expects repeated query params for arrays (e.g. `projectIds=1&projectIds=2`). + // Bracket-indexed arrays (`projectIds[0]=1`) are ignored by the backend and can leak unscoped results. + const queryString = qs.stringify(query, { encode: false, arrayFormat: 'repeat' }) const querySuffix = queryString ? `?${queryString}` : '' return axiosInstance.get(`${ENGAGEMENTS_API_URL}${querySuffix}`) } diff --git a/src/services/projects.js b/src/services/projects.js index 1a03221c..4fe68fd4 100644 --- a/src/services/projects.js +++ b/src/services/projects.js @@ -60,13 +60,94 @@ export function fetchMemberProjects (filters) { } /** - * Api request for fetching project by id - * @param id Project id - * @returns {Promise<*>} + * Api request for fetching a project by id with best-effort user enrichment. + * + * After loading the project, this resolves any members with missing `handle` + * values through `MEMBERS_API_URL`, and also resolves non-member attachment + * creators so the assets library can render `Created By` consistently. + * + * @param {string|number} id Project id. + * @returns {Promise} Project payload with `members` and attachment + * creator details enriched when possible. */ export async function fetchProjectById (id) { const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}`) - return _.get(response, 'data') + const project = _.get(response, 'data') + const members = _.get(project, 'members', []) + const attachments = _.get(project, 'attachments', []) + const membersWithoutHandle = members.filter(member => !member.handle && member.userId) + const memberUserIds = members.reduce((acc, member) => { + if (member.userId) { + acc[`${member.userId}`] = true + } + return acc + }, {}) + const attachmentCreatorIds = _.uniq( + attachments + .map(attachment => `${_.get(attachment, 'createdBy', '')}`.trim()) + .filter(createdBy => createdBy && /^\d+$/.test(createdBy) && !memberUserIds[createdBy]) + ) + const missingUserIds = _.uniq([ + ...membersWithoutHandle.map(member => member.userId), + ...attachmentCreatorIds + ]) + + if (!missingUserIds.length) { + return project + } + + try { + const membersByUserId = await fetchInviteMembers(missingUserIds) + const enrichedMembers = members.map(member => ({ + ...member, + handle: member.handle || _.get(membersByUserId, [member.userId, 'handle'], null) + })) + const enrichedMembersByUserId = enrichedMembers.reduce((acc, member) => { + if (member.userId) { + acc[`${member.userId}`] = member + } + return acc + }, {}) + + return { + ...project, + members: enrichedMembers, + attachments: attachments.map(attachment => { + const creatorUserId = `${_.get(attachment, 'createdBy', '')}`.trim() + const createdByUser = + enrichedMembersByUserId[creatorUserId] || + _.get(membersByUserId, [creatorUserId], null) + + return createdByUser + ? { + ...attachment, + createdByUser + } + : attachment + }) + } + } catch (error) { + return project + } +} + +/** + * Fetch project members with handle enrichment supplied by the Projects API. + * + * This avoids depending on a separate member-directory lookup in callers that + * only need project membership data for display/edit flows. + * + * @param {string|number} projectId Project id. + * @returns {Promise} Project members. + */ +export async function fetchProjectMembers (projectId) { + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/members`, { + params: { + fields: 'handle' + } + }) + + return _.get(response, 'data', []) } /** diff --git a/src/util/applicationErrors.js b/src/util/applicationErrors.js new file mode 100644 index 00000000..a53513aa --- /dev/null +++ b/src/util/applicationErrors.js @@ -0,0 +1,66 @@ +export const CAPACITY_LIMIT_ERROR_MESSAGE = 'Maximum number of members already assigned' +const CAPACITY_LIMIT_ERROR_MESSAGE_PATTERNS = [ + CAPACITY_LIMIT_ERROR_MESSAGE, + 'Maximum number of members already assigned to this engagement', + 'Assigned member count exceeds required member count.' +] + +/** + * Normalizes API error message payloads into a single string so capacity-limit + * checks can support both string and string-array responses. + * + * @param {string|Array} message API error message payload. + * @returns {string} Normalized message text. + */ +const normalizeCapacityErrorMessage = (message) => { + if (typeof message === 'string') { + return message + } + + if (Array.isArray(message)) { + return message + .map((entry) => (entry == null ? '' : String(entry))) + .filter(Boolean) + .join(' ') + } + + return '' +} + +/** + * Checks whether an API error matches the known assignment-capacity limit case. + * + * @param {string|Array} message API error message. + * @param {number|string} status HTTP status code when available. + * @returns {boolean} True when the error is the capacity-limit message. + */ +export const isCapacityLimitError = (message, status) => { + if (status !== undefined && status !== null && Number(status) !== 400) { + return false + } + + const rawMessage = normalizeCapacityErrorMessage(message) + if (!rawMessage) { + return false + } + + const normalizedMessage = rawMessage.trim().replace(/\s+/g, ' ').toLowerCase() + if (!normalizedMessage) { + return false + } + + const normalizedPatterns = CAPACITY_LIMIT_ERROR_MESSAGE_PATTERNS + .map(pattern => pattern.toLowerCase()) + + if (normalizedPatterns.some(pattern => normalizedMessage === pattern)) { + return true + } + + if (normalizedMessage.includes('maximum number of members already assigned')) { + return true + } + + return normalizedMessage.includes('required members') && + normalizedMessage.includes('select') && + normalizedMessage.includes('member') +} diff --git a/src/util/engagements.js b/src/util/engagements.js index b776328c..70b610e5 100644 --- a/src/util/engagements.js +++ b/src/util/engagements.js @@ -2,6 +2,7 @@ const STATUS_LABELS = { OPEN: 'Open', PENDING_ASSIGNMENT: 'Pending Assignment', ACTIVE: 'Active', + ON_HOLD: 'On Hold', CANCELLED: 'Cancelled', CLOSED: 'Closed' } @@ -10,6 +11,7 @@ const STATUS_TO_API = { Open: 'OPEN', 'Pending Assignment': 'PENDING_ASSIGNMENT', Active: 'ACTIVE', + 'On Hold': 'ON_HOLD', Cancelled: 'CANCELLED', Closed: 'CLOSED' } diff --git a/src/util/engagements.test.js b/src/util/engagements.test.js new file mode 100644 index 00000000..13557af6 --- /dev/null +++ b/src/util/engagements.test.js @@ -0,0 +1,18 @@ +/* global describe, it, expect */ + +import { + fromEngagementStatusApi, + toEngagementStatusApi +} from './engagements' + +describe('engagement status normalization', () => { + it('converts ON_HOLD API values to On Hold labels', () => { + expect(fromEngagementStatusApi('ON_HOLD')).toBe('On Hold') + expect(fromEngagementStatusApi(' on_hold ')).toBe('On Hold') + }) + + it('converts On Hold labels to ON_HOLD API values', () => { + expect(toEngagementStatusApi('On Hold')).toBe('ON_HOLD') + expect(toEngagementStatusApi('on hold')).toBe('ON_HOLD') + }) +}) diff --git a/src/util/tc.js b/src/util/tc.js index 80c0350b..60a814af 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -13,7 +13,8 @@ import { ALLOWED_EDIT_RESOURCE_ROLES, MANAGER_ROLES, PROJECT_ROLES, - TASK_MANAGER_ROLES + TASK_MANAGER_ROLES, + PROJECT_MEMBER_INVITE_STATUS_PENDING } from '../config/constants' import _ from 'lodash' import { decodeToken } from 'tc-auth-lib' @@ -226,6 +227,39 @@ export const checkTaskManager = (token) => { return roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) } +const normalizeUserId = (userId) => { + if (_.isNil(userId)) { + return null + } + + const normalizedUserId = `${userId}`.trim() + return normalizedUserId.length ? normalizedUserId : 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') @@ -235,10 +269,8 @@ export const checkAdminOrPmOrTaskManager = (token, project) => { const isManager = roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) const isTaskManager = roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1) - const isProjectManager = project && !_.isEmpty(project) && - project.members && project.members.some(member => - member.userId === userId && member.role === PROJECT_ROLES.MANAGER - ) + const isProjectManager = + _.get(getProjectMemberByUserId(project, userId), 'role') === PROJECT_ROLES.MANAGER return isAdmin || isManager || isTaskManager || isProjectManager } @@ -250,7 +282,10 @@ 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')) + const canManageProject = !project || _.isEmpty(project) || + ALLOWED_EDIT_RESOURCE_ROLES.includes( + _.get(getProjectMemberByUserId(project, tokenData.userId), 'role') + ) return isCopilot && canManageProject } @@ -264,18 +299,71 @@ 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')) + const canManageProject = !project || _.isEmpty(project) || + ALLOWED_EDIT_RESOURCE_ROLES.includes( + _.get(getProjectMemberByUserId(project, tokenData.userId), 'role') + ) return isAdmin || (isCopilot && canManageProject) } +/** + * Checks whether the authenticated user is a member of the specified project. + * This project-level check grants access regardless of the user's global JWT roles. + * + * @param {String} token JWT token for the authenticated user. + * @param {Object} projectDetail Project detail payload that includes `members`. + * @returns {Boolean} `true` when `projectDetail.members` contains the token's `userId`. + */ +export const checkIsProjectMember = (token, projectDetail) => { + const tokenData = decodeToken(token) + return !!getProjectMemberByUserId(projectDetail, _.get(tokenData, 'userId')) +} + +/** + * Checks whether the authenticated user can view the project assets library. + * + * Asset Library access is granted to admins, global copilots, and any member + * of the project regardless of project role. + * + * @param {String} token JWT token for the authenticated user. + * @param {Object} projectDetail Project detail payload that includes `members`. + * @returns {Boolean} `true` when the user can view the project assets library. + */ +export const checkCanViewProjectAssets = (token, projectDetail) => { + if (!token) { + return false + } + + return checkAdmin(token) || checkCopilot(token) || checkIsProjectMember(token, projectDetail) +} + +/** + * Checks if token has any of the admin, copilot, or manager roles + * When `project` is omitted or empty, the check is based solely on the user's global JWT roles. + * @param token + * @param project + */ +export const checkAdminOrCopilotOrManager = (token, project) => { + return checkManager(token) || checkAdminOrCopilot(token, project) +} + +/** + * Returns the authenticated user's pending invite for a project, if one exists. + * + * Accepted or declined historical invites are intentionally ignored so callers + * only trigger the invitation flow for actionable invitations. + */ export const checkIsUserInvitedToProject = (token, project) => { if (!token) { return } 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 => ( + d.status === PROJECT_MEMBER_INVITE_STATUS_PENDING && + (d.userId === tokenData.userId || d.email === tokenData.email) + ))) } /**