Projects
- {checkAdminOrCopilotOrManager(auth.token) && (
+ {checkCanCreateProject(auth.token) && (
diff --git a/src/util/tc.js b/src/util/tc.js
index c813d1d7..6c84aa94 100644
--- a/src/util/tc.js
+++ b/src/util/tc.js
@@ -21,6 +21,64 @@ import { decodeToken } from 'tc-auth-lib'
import { fetchResources, fetchResourceRoles } from '../services/challenges'
import store from '../config/store'
+const TALENT_MANAGER_ROLES = [
+ 'talent manager',
+ 'topcoder talent manager'
+]
+
+const PROJECT_ROLE_PRIVILEGE_LEVELS = {
+ [PROJECT_ROLES.READ]: 0,
+ [PROJECT_ROLES.WRITE]: 1,
+ [PROJECT_ROLES.MANAGER]: 2,
+ [PROJECT_ROLES.COPILOT]: 3
+}
+
+const normalizeUserId = (userId) => {
+ if (_.isNil(userId)) {
+ return null
+ }
+
+ const normalizedUserId = `${userId}`.trim()
+ return normalizedUserId.length > 0 ? normalizedUserId : null
+}
+
+const normalizeEmail = (email) => {
+ if (_.isNil(email)) {
+ return null
+ }
+
+ const normalizedEmail = `${email}`.trim().toLowerCase()
+ return normalizedEmail.length > 0 ? normalizedEmail : null
+}
+
+const getProjectMember = (project, userId) => {
+ const normalizedUserId = normalizeUserId(userId)
+
+ if (
+ !project ||
+ _.isEmpty(project) ||
+ !normalizedUserId ||
+ !Array.isArray(project.members)
+ ) {
+ return null
+ }
+
+ return _.find(
+ project.members,
+ member => normalizeUserId(member.userId) === normalizedUserId
+ ) || null
+}
+
+const canManageProject = (project, userId) => {
+ if (!project || _.isEmpty(project)) {
+ return true
+ }
+
+ return ALLOWED_EDIT_RESOURCE_ROLES.includes(
+ _.get(getProjectMember(project, userId), 'role')
+ )
+}
+
export const RATING_COLORS = [
{
color: '#9D9FA0' /* Grey */,
@@ -213,8 +271,7 @@ export const checkManager = (token) => {
export const checkTalentManager = (token) => {
const tokenData = decodeToken(token)
const roles = _.get(tokenData, 'roles')
- const talentManagerRoles = ['talent manager', 'topcoder talent manager']
- return roles.some(val => talentManagerRoles.indexOf(val.toLowerCase()) > -1)
+ return roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1)
}
export const checkAdminOrTalentManager = (token) => {
@@ -227,13 +284,114 @@ export const checkTaskManager = (token) => {
return roles.some(val => TASK_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1)
}
-const normalizeUserId = (userId) => {
- if (_.isNil(userId)) {
+/**
+ * Checks whether the caller can manage project ownership flows in Work Manager.
+ *
+ * Admins always qualify. Project Managers, Copilots, and Talent Managers
+ * additionally need a management-capable project membership when a project
+ * context is provided.
+ *
+ * @param token
+ * @param project
+ * @returns {boolean} Whether the caller can manage the project in the UI.
+ */
+export const checkCanManageProject = (token, project) => {
+ const tokenData = decodeToken(token)
+ const roles = _.get(tokenData, 'roles')
+ const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1)
+ const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1)
+ const isManager = roles.some(val => MANAGER_ROLES.indexOf(val.toLowerCase()) > -1)
+ const isTalentManager = roles.some(val => TALENT_MANAGER_ROLES.indexOf(val.toLowerCase()) > -1)
+ const hasProjectManagementAccess = canManageProject(project, tokenData.userId)
+
+ return isAdmin || ((isCopilot || isManager || isTalentManager) && hasProjectManagementAccess)
+}
+
+/**
+ * Checks whether the caller may create a project in Work Manager.
+ *
+ * Project creation remains broader than edit permissions. Project Managers
+ * should still be able to create projects even though billing-account edits
+ * are limited to admins and Full Access members.
+ *
+ * @param token
+ * @returns {boolean} Whether the caller can create a project.
+ */
+export const checkCanCreateProject = (token) => {
+ return checkAdmin(token) || checkManager(token) || checkCopilot(token)
+}
+
+/**
+ * Checks whether the caller may edit a project's billing account.
+ *
+ * This is intentionally stricter than general project-management checks:
+ * only admins or project members with Full Access (`manager`) qualify.
+ *
+ * @param token
+ * @param project
+ * @returns {boolean} Whether the caller can edit the project's billing account.
+ */
+export const checkCanManageProjectBillingAccount = (token, project) => {
+ const tokenData = decodeToken(token)
+ const roles = _.get(tokenData, 'roles', [])
+ const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1)
+
+ if (isAdmin) {
+ return true
+ }
+
+ return _.get(getProjectMember(project, tokenData.userId), 'role') === PROJECT_ROLES.MANAGER
+}
+
+export const checkProjectMembership = (project, userId) => {
+ return !!getProjectMember(project, userId)
+}
+
+export const getProjectMemberRole = (project, userId) => {
+ return _.get(getProjectMember(project, userId), 'role', null)
+}
+
+/**
+ * Returns the privilege level for a project role used by Work Manager.
+ *
+ * Higher numbers represent broader project permissions. Unknown roles return
+ * `null` so callers can fail closed when comparing permissions.
+ *
+ * @param {string} role Project role identifier from the API/UI.
+ * @returns {number|null} Numeric privilege level or `null` for unknown roles.
+ */
+export const getProjectRolePrivilegeLevel = (role) => {
+ if (_.isNil(role)) {
return null
}
- const normalizedUserId = `${userId}`.trim()
- return normalizedUserId.length ? normalizedUserId : null
+ const normalizedRole = `${role}`.trim().toLowerCase()
+
+ return Object.prototype.hasOwnProperty.call(PROJECT_ROLE_PRIVILEGE_LEVELS, normalizedRole)
+ ? PROJECT_ROLE_PRIVILEGE_LEVELS[normalizedRole]
+ : null
+}
+
+/**
+ * Checks whether a member may assign themselves `nextRole` without increasing
+ * their project privileges.
+ *
+ * This keeps self-service role edits limited to the same role or a downgrade,
+ * while blocking self-promotion to broader project access.
+ *
+ * @param {string} currentRole Current project role for the member.
+ * @param {string} nextRole Requested replacement project role.
+ * @returns {boolean} `true` when the new role is the same privilege or lower.
+ */
+export const checkCanSelfAssignProjectRole = (currentRole, nextRole) => {
+ const currentRolePrivilegeLevel = getProjectRolePrivilegeLevel(currentRole)
+ const nextRolePrivilegeLevel = getProjectRolePrivilegeLevel(nextRole)
+
+ if (_.isNil(currentRolePrivilegeLevel) || _.isNil(nextRolePrivilegeLevel)) {
+ return false
+ }
+
+ return nextRolePrivilegeLevel <= currentRolePrivilegeLevel
}
/**
@@ -269,8 +427,11 @@ 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 =
- _.get(getProjectMemberByUserId(project, userId), 'role') === PROJECT_ROLES.MANAGER
+ const isProjectManager = project && !_.isEmpty(project) &&
+ project.members && project.members.some(member =>
+ normalizeUserId(member.userId) === normalizeUserId(userId) &&
+ member.role === PROJECT_ROLES.MANAGER
+ )
return isAdmin || isManager || isTaskManager || isProjectManager
}
@@ -282,12 +443,8 @@ export const checkCopilot = (token, project) => {
const tokenData = decodeToken(token)
const roles = _.get(tokenData, 'roles')
const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1)
- const canManageProject = !project || _.isEmpty(project) ||
- ALLOWED_EDIT_RESOURCE_ROLES.includes(
- _.get(getProjectMemberByUserId(project, tokenData.userId), 'role')
- )
- return isCopilot && canManageProject
+ return isCopilot && canManageProject(project, tokenData.userId)
}
/**
@@ -299,12 +456,8 @@ export const checkAdminOrCopilot = (token, project) => {
const roles = _.get(tokenData, 'roles')
const isAdmin = roles.some(val => ADMIN_ROLES.indexOf(val.toLowerCase()) > -1)
const isCopilot = roles.some(val => COPILOT_ROLES.indexOf(val.toLowerCase()) > -1)
- const canManageProject = !project || _.isEmpty(project) ||
- ALLOWED_EDIT_RESOURCE_ROLES.includes(
- _.get(getProjectMemberByUserId(project, tokenData.userId), 'role')
- )
- return isAdmin || (isCopilot && canManageProject)
+ return isAdmin || (isCopilot && canManageProject(project, tokenData.userId))
}
/**
@@ -360,10 +513,16 @@ export const checkIsUserInvitedToProject = (token, project) => {
}
const tokenData = decodeToken(token)
- return project && !_.isEmpty(project) && (_.find(project.invites, d => (
- d.status === PROJECT_MEMBER_INVITE_STATUS_PENDING &&
- (d.userId === tokenData.userId || d.email === tokenData.email)
- )))
+ return project && !_.isEmpty(project) && (_.find(
+ project.invites,
+ d => (
+ d.status === PROJECT_MEMBER_INVITE_STATUS_PENDING &&
+ (
+ normalizeUserId(d.userId) === normalizeUserId(tokenData.userId) ||
+ normalizeEmail(d.email) === normalizeEmail(tokenData.email)
+ )
+ )
+ ))
}
export const getRoleNameForReviewer = (reviewer, challengePhases = []) => {
diff --git a/src/util/tc.test.js b/src/util/tc.test.js
new file mode 100644
index 00000000..ed12f352
--- /dev/null
+++ b/src/util/tc.test.js
@@ -0,0 +1,257 @@
+/* global describe, it, expect, beforeEach, jest */
+
+import { decodeToken } from 'tc-auth-lib'
+import { PROJECT_MEMBER_INVITE_STATUS_PENDING, PROJECT_ROLES } from '../config/constants'
+import {
+ checkCanCreateProject,
+ checkCanManageProject,
+ checkCanManageProjectBillingAccount,
+ checkManager,
+ checkIsUserInvitedToProject,
+ getProjectMemberByUserId
+} from './tc'
+
+jest.mock('tc-auth-lib', () => ({
+ decodeToken: jest.fn()
+}))
+
+jest.mock('../services/challenges', () => ({
+ fetchResources: jest.fn(),
+ fetchResourceRoles: jest.fn()
+}))
+
+jest.mock('../config/store', () => ({
+ getState: jest.fn()
+}))
+
+describe('checkCanManageProjectBillingAccount', () => {
+ beforeEach(() => {
+ decodeToken.mockReset()
+ })
+
+ it('allows administrators to manage project billing accounts', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['administrator']
+ })
+
+ expect(
+ checkCanManageProjectBillingAccount('token', {
+ members: []
+ })
+ ).toBe(true)
+ })
+
+ it('allows full-access project members to manage project billing accounts', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['project manager']
+ })
+
+ expect(
+ checkCanManageProjectBillingAccount('token', {
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.MANAGER
+ }]
+ })
+ ).toBe(true)
+ })
+
+ it('blocks project-manager roles without full-access project membership', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['project manager']
+ })
+
+ expect(
+ checkCanManageProjectBillingAccount('token', {
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.WRITE
+ }]
+ })
+ ).toBe(false)
+ })
+
+ it('blocks talent-manager roles without full-access project membership', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['talent manager']
+ })
+
+ expect(
+ checkCanManageProjectBillingAccount('token', {
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.WRITE
+ }]
+ })
+ ).toBe(false)
+ })
+})
+
+describe('checkCanManageProject', () => {
+ beforeEach(() => {
+ decodeToken.mockReset()
+ })
+
+ it('allows project-manager roles to manage projects when they have full access', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['project manager']
+ })
+
+ expect(
+ checkCanManageProject('token', {
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.MANAGER
+ }]
+ })
+ ).toBe(true)
+ })
+
+ it('blocks project-manager roles from managing projects without full access', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['project manager']
+ })
+
+ expect(
+ checkCanManageProject('token', {
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.WRITE
+ }]
+ })
+ ).toBe(false)
+ })
+
+ it('allows talent-manager roles to manage projects when they have full access', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['talent manager']
+ })
+
+ expect(
+ checkCanManageProject('token', {
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.MANAGER
+ }]
+ })
+ ).toBe(true)
+ })
+})
+
+describe('checkManager', () => {
+ beforeEach(() => {
+ decodeToken.mockReset()
+ })
+
+ it('treats talent-manager tokens as manager-tier access', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['talent manager']
+ })
+
+ expect(checkManager('token')).toBe(true)
+ })
+
+ it('treats topcoder-talent-manager tokens as manager-tier access', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['topcoder talent manager']
+ })
+
+ expect(checkManager('token')).toBe(true)
+ })
+})
+
+describe('checkCanCreateProject', () => {
+ beforeEach(() => {
+ decodeToken.mockReset()
+ })
+
+ it('allows project-manager roles to create projects', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['project manager']
+ })
+
+ expect(checkCanCreateProject('token')).toBe(true)
+ })
+
+ it('allows copilots to create projects', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['copilot']
+ })
+
+ expect(checkCanCreateProject('token')).toBe(true)
+ })
+
+ it('blocks read-only users from creating projects', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ roles: ['topcoder user']
+ })
+
+ expect(checkCanCreateProject('token')).toBe(false)
+ })
+})
+
+describe('getProjectMemberByUserId', () => {
+ it('matches project members even when ids differ by string vs number types', () => {
+ expect(getProjectMemberByUserId({
+ members: [{
+ userId: '1001',
+ role: PROJECT_ROLES.WRITE
+ }]
+ }, 1001)).toEqual({
+ userId: '1001',
+ role: PROJECT_ROLES.WRITE
+ })
+ })
+})
+
+describe('checkIsUserInvitedToProject', () => {
+ beforeEach(() => {
+ decodeToken.mockReset()
+ })
+
+ it('returns the pending invite for the authenticated user', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ email: 'member@test.com'
+ })
+
+ expect(checkIsUserInvitedToProject('token', {
+ invites: [{
+ status: PROJECT_MEMBER_INVITE_STATUS_PENDING,
+ userId: '1001',
+ email: 'member@test.com'
+ }]
+ })).toEqual({
+ status: PROJECT_MEMBER_INVITE_STATUS_PENDING,
+ userId: '1001',
+ email: 'member@test.com'
+ })
+ })
+
+ it('ignores non-pending invites for the authenticated user', () => {
+ decodeToken.mockReturnValue({
+ userId: '1001',
+ email: 'member@test.com'
+ })
+
+ expect(checkIsUserInvitedToProject('token', {
+ invites: [{
+ status: 'declined',
+ userId: '1001',
+ email: 'member@test.com'
+ }]
+ })).toBeUndefined()
+ })
+})