From 4fd1543201557053d2d7626b371acc0561a57368 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 28 Mar 2025 15:01:12 +0200 Subject: [PATCH 1/4] PM-973 - move add user to own component --- src/components/Users/Users.module.scss | 7 +- src/components/Users/index.js | 222 ++++--------------------- src/components/Users/user-add.modal.js | 175 +++++++++++++++++++ 3 files changed, 210 insertions(+), 194 deletions(-) create mode 100644 src/components/Users/user-add.modal.js diff --git a/src/components/Users/Users.module.scss b/src/components/Users/Users.module.scss index d86de92d..6a0a49c8 100644 --- a/src/components/Users/Users.module.scss +++ b/src/components/Users/Users.module.scss @@ -395,10 +395,15 @@ } .addButtonContainer { - width: 110px; + display: flex; + justify-content: flex-start; height: 30px; margin-top: 20px; margin-bottom: 20px; + gap: 8px; + > * { + width: 125px; + } } .addUserContentContainer { diff --git a/src/components/Users/index.js b/src/components/Users/index.js index 07d862dc..c5f03dd3 100644 --- a/src/components/Users/index.js +++ b/src/components/Users/index.js @@ -6,12 +6,12 @@ import styles from './Users.module.scss' import Select from '../Select' import UserCard from '../UserCard' import PrimaryButton from '../Buttons/PrimaryButton' -import Modal from '../Modal' -import SelectUserAutocomplete from '../SelectUserAutocomplete' import { PROJECT_ROLES, AUTOCOMPLETE_DEBOUNCE_TIME_MS } from '../../config/constants' import { checkAdmin } from '../../util/tc' -import { addUserToProject, removeUserFromProject } from '../../services/projects' +import { removeUserFromProject } from '../../services/projects' import ConfirmationModal from '../Modal/ConfirmationModal' +import UserAddModalContent from './user-add.modal' +import InviteUserModalContent from './invite-user.modal' // Import the new component const theme = { container: styles.modalContainer @@ -23,11 +23,7 @@ class Users extends Component { this.state = { projectOption: null, showAddUserModal: false, - userToAdd: null, - userPermissionToAdd: PROJECT_ROLES.READ, - showSelectUserError: false, - isAdding: false, - addUserError: false, + showInviteUserModal: false, // Add state for invite user modal isRemoving: false, removeError: null, showRemoveConfirmationModal: false, @@ -36,10 +32,9 @@ class Users extends Component { } this.setProjectOption = this.setProjectOption.bind(this) this.onAddUserClick = this.onAddUserClick.bind(this) + this.onInviteUserClick = this.onInviteUserClick.bind(this) // Bind the new method this.resetAddUserState = this.resetAddUserState.bind(this) - this.onUpdateUserToAdd = this.onUpdateUserToAdd.bind(this) - this.onAddUserConfirmClick = this.onAddUserConfirmClick.bind(this) - this.updatePermission = this.updatePermission.bind(this) + this.resetInviteUserState = this.resetInviteUserState.bind(this) // Bind reset method this.onRemoveClick = this.onRemoveClick.bind(this) this.resetRemoveUserState = this.resetRemoveUserState.bind(this) this.onRemoveConfirmClick = this.onRemoveConfirmClick.bind(this) @@ -54,78 +49,24 @@ class Users extends Component { loadProject(projectOption.value, false) } - updatePermission (newRole) { - this.setState({ - userPermissionToAdd: newRole - }) - } - onAddUserClick () { this.setState({ showAddUserModal: true }) } - resetAddUserState () { + onInviteUserClick () { this.setState({ - userToAdd: null, - showSelectUserError: false, - isAdding: false, - showAddUserModal: false, - userPermissionToAdd: PROJECT_ROLES.READ, - addUserError: null + showInviteUserModal: true }) } - onUpdateUserToAdd (option) { - let userToAdd = null - if (option && option.value) { - userToAdd = { - handle: option.label, - userId: parseInt(option.value, 10) - } - } - - this.setState({ - userToAdd, - showSelectUserError: !userToAdd - }) + resetAddUserState () { + this.setState({ showAddUserModal: false }) } - async onAddUserConfirmClick () { - const { addNewProjectMember } = this.props - if (this.state.isAdding) { return } - - this.setState({ - showSelectUserError: false, - addUserError: null - }) - - if (!this.state.userToAdd) { - this.setState({ - showSelectUserError: true - }) - return - } - - this.setState({ - isAdding: true - }) - - try { - const newUserInfo = await addUserToProject(this.state.projectOption.value, this.state.userToAdd.userId, this.state.userPermissionToAdd) - newUserInfo.handle = this.state.userToAdd.handle - // wait for a second so that project's members are updated - addNewProjectMember(newUserInfo) - this.resetAddUserState() - } catch (e) { - const error = _.get( - e, - 'response.data.message', - `Unable to add user` - ) - this.setState({ isAdding: false, addUserError: error }) - } + resetInviteUserState () { + this.setState({ showInviteUserModal: false }) } getHandle () { @@ -260,133 +201,28 @@ class Users extends Component { text={'Add User'} type={'info'} onClick={() => this.onAddUserClick()} /> + this.onInviteUserClick()} /> ) } { this.state.showAddUserModal && ( - this.resetAddUserState()}> -
-
Add User
-
-
-
- Member* : -
-
- -
-
- { - this.state.showSelectUserError && ( -
-
Please select a member.
-
- ) - } -
-
- -
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} - /> - -
-
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)} - /> - -
-
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)} - /> - -
-
-
-
- e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)} - /> - -
-
-
- { - this.state.addUserError && ( -
- {this.state.addUserError} -
- ) - } -
- -
-
- this.resetAddUserState()} - /> -
-
- this.onAddUserConfirmClick()} - /> -
-
-
-
+ + ) + } + { + this.state.showInviteUserModal && ( + ) } { diff --git a/src/components/Users/user-add.modal.js b/src/components/Users/user-add.modal.js new file mode 100644 index 00000000..79f08f93 --- /dev/null +++ b/src/components/Users/user-add.modal.js @@ -0,0 +1,175 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { get } from 'lodash' +import Modal from '../Modal' +import SelectUserAutocomplete from '../SelectUserAutocomplete' +import { PROJECT_ROLES } from '../../config/constants' +import PrimaryButton from '../Buttons/PrimaryButton' +import { addUserToProject } from '../../services/projects' + +import styles from './Users.module.scss' + +const theme = { + container: styles.modalContainer +} + +const UserAddModalContent = ({ projectId, addNewProjectMember, onClose }) => { + const [userToAdd, setUserToAdd] = useState(null) + const [userPermissionToAdd, setUserPermissionToAdd] = useState(PROJECT_ROLES.READ) + const [showSelectUserError, setShowSelectUserError] = useState(false) + const [addUserError, setAddUserError] = useState(null) + const [isAdding, setIsAdding] = useState(false) + + const onUpdateUserToAdd = (option) => { + if (option && option.value) { + setUserToAdd({ + handle: option.label, + userId: parseInt(option.value, 10) + }) + setShowSelectUserError(false) + } else { + setUserToAdd(null) + } + } + + const onAddUserConfirmClick = async () => { + if (isAdding) return + + if (!userToAdd) { + setShowSelectUserError(true) + return + } + + setIsAdding(true) + setAddUserError(null) + + try { + const newUserInfo = await addUserToProject(projectId, userToAdd.userId, userPermissionToAdd) + newUserInfo.handle = userToAdd.handle + addNewProjectMember(newUserInfo) + onClose() + } catch (e) { + const error = get(e, 'response.data.message', 'Unable to add user') + setAddUserError(error) + setIsAdding(false) + } + } + + return ( + +
+
Add User
+
+
+
+ Member* : +
+
+ +
+
+ {showSelectUserError && ( +
+
Please select a member.
+
+ )} +
+
+ +
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.READ)} + /> + +
+
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.WRITE)} + /> + +
+
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.MANAGER)} + /> + +
+
+
+
+ setUserPermissionToAdd(PROJECT_ROLES.COPILOT)} + /> + +
+
+
+ {addUserError && ( +
{addUserError}
+ )} +
+
+
+ +
+
+ +
+
+
+
+ ) +} +UserAddModalContent.propTypes = { + projectId: PropTypes.number.isRequired, + addNewProjectMember: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired +} + +export default UserAddModalContent From 81a11735b02fd40178ded8d75a0d085e76562456 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 28 Mar 2025 15:01:26 +0200 Subject: [PATCH 2/4] PM-973 - invite user modal --- src/components/Users/Users.module.scss | 6 ++ src/components/Users/invite-user.modal.js | 113 ++++++++++++++++++++++ src/config/constants.js | 3 + src/services/projectMemberInvites.js | 66 +++++++++++++ src/services/projects.js | 54 +++++++---- 5 files changed, 222 insertions(+), 20 deletions(-) create mode 100644 src/components/Users/invite-user.modal.js create mode 100644 src/services/projectMemberInvites.js diff --git a/src/components/Users/Users.module.scss b/src/components/Users/Users.module.scss index 6a0a49c8..7451b0c6 100644 --- a/src/components/Users/Users.module.scss +++ b/src/components/Users/Users.module.scss @@ -118,6 +118,12 @@ text-decoration: none; font-size: 12px; } + + &.inviteEmailInput { + input { + width: 250px; + } + } } } diff --git a/src/components/Users/invite-user.modal.js b/src/components/Users/invite-user.modal.js new file mode 100644 index 00000000..69f3cbe1 --- /dev/null +++ b/src/components/Users/invite-user.modal.js @@ -0,0 +1,113 @@ +/* eslint-disable no-unused-vars */ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import cn from 'classnames' +import { get } from 'lodash' +import Modal from '../Modal' +import PrimaryButton from '../Buttons/PrimaryButton' +import { inviteUserToProject } from '../../services/projects' +import { PROJECT_ROLES } from '../../config/constants' + +import styles from './Users.module.scss' + +const theme = { + container: styles.modalContainer +} + +const validateEmail = (email) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +const InviteUserModalContent = ({ projectId, onClose }) => { + const [emailToInvite, setEmailToInvite] = useState('') + const [showEmailError, setShowEmailError] = useState(false) + const [inviteUserError, setInviteUserError] = useState(null) + const [isInviting, setIsInviting] = useState(false) + + const handleEmailBlur = () => { + if (!validateEmail(emailToInvite)) { + setShowEmailError(true) + } + } + + const onInviteUserConfirmClick = async () => { + if (isInviting) return + + if (!emailToInvite || !validateEmail(emailToInvite)) { + setShowEmailError(true) + return + } + + setIsInviting(true) + setInviteUserError(null) + + try { + // api restriction: ONLY "customer" role can be invited via email + await inviteUserToProject(projectId, emailToInvite, PROJECT_ROLES.CUSTOMER) + onClose() + } catch (e) { + const error = get(e, 'response.data.message', 'Unable to invite user') + setInviteUserError(error) + setIsInviting(false) + } + } + + return ( + +
+
Invite User
+
+
+
+ Email* : +
+
+ { + setEmailToInvite(e.target.value) + setShowEmailError(false) + }} + onBlur={handleEmailBlur} + /> +
+
+ {showEmailError && ( +
+
Please enter a valid email address.
+
+ )} + {inviteUserError && ( +
{inviteUserError}
+ )} +
+
+
+ +
+
+ +
+
+
+
+ ) +} + +InviteUserModalContent.propTypes = { + projectId: PropTypes.number.isRequired, + onClose: PropTypes.func.isRequired +} + +export default InviteUserModalContent diff --git a/src/config/constants.js b/src/config/constants.js index 6aa99441..96a88579 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -33,7 +33,9 @@ export const { TYPEFORM_URL, PROFILE_URL } = process.env + export const CREATE_FORUM_TYPE_IDS = typeof process.env.CREATE_FORUM_TYPE_IDS === 'string' ? process.env.CREATE_FORUM_TYPE_IDS.split(',') : process.env.CREATE_FORUM_TYPE_IDS +export const PROJECTS_API_URL = process.env.PROJECTS_API_URL || process.env.PROJECT_API_URL /** * Filepicker config @@ -242,6 +244,7 @@ export const MARATHON_MATCH_SUBTRACKS = [ export const PROJECT_ROLES = { READ: 'observer', + CUSTOMER: 'customer', WRITE: 'customer', MANAGER: 'manager', COPILOT: 'copilot' diff --git a/src/services/projectMemberInvites.js b/src/services/projectMemberInvites.js new file mode 100644 index 00000000..6fa99c55 --- /dev/null +++ b/src/services/projectMemberInvites.js @@ -0,0 +1,66 @@ +import { axiosInstance as axios } from './axiosWithAuth' +import { PROJECTS_API_URL } from '../config/constants' + +/** + * Update project member invite based on project's id & given member + * @param {integer} projectId unique identifier of the project + * @param {integer} inviteId unique identifier of the invite + * @param {string} status the new status for invitation + * @return {object} project member invite returned by api + */ +export function updateProjectMemberInvite (projectId, inviteId, status) { + const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/${inviteId}` + return axios.patch(url, { status }) + .then(resp => resp.data) +} + +/** + * Delete project member invite based on project's id & given invite's id + * @param {integer} projectId unique identifier of the project + * @param {integer} inviteId unique identifier of the invite + * @return {object} project member invite returned by api + */ +export function deleteProjectMemberInvite (projectId, inviteId) { + const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/${inviteId}` + return axios.delete(url) +} + +/** + * Create a project member invite based on project's id & given member + * @param {integer} projectId unique identifier of the project + * @param {object} member invite + * @return {object} project member invite returned by api + */ +export function createProjectMemberInvite (projectId, member) { + const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle' + const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` + encodeURIComponent(fields) + return axios({ + method: 'post', + url, + data: member, + validateStatus (status) { + return (status >= 200 && status < 300) || status === 403 + } + }) + .then(resp => resp.data) +} + +export function getProjectMemberInvites (projectId) { + const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle' + const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/?fields=` + + encodeURIComponent(fields) + return axios.get(url) + .then(resp => { + return resp.data + }) +} + +/** + * Get a project member invite based on project's id + * @param {integer} projectId unique identifier of the project + * @return {object} project member invite returned by api + */ +export function getProjectInviteById (projectId) { + return axios.get(`${PROJECTS_API_URL}/v5/projects/${projectId}/invites`) + .then(resp => resp.data) +} diff --git a/src/services/projects.js b/src/services/projects.js index e749ea11..e61a0f70 100644 --- a/src/services/projects.js +++ b/src/services/projects.js @@ -7,11 +7,11 @@ import { GENERIC_PROJECT_MILESTONE_PRODUCT_NAME, GENERIC_PROJECT_MILESTONE_PRODUCT_TYPE, PHASE_PRODUCT_CHALLENGE_ID_FIELD, - PHASE_PRODUCT_TEMPLATE_ID + PHASE_PRODUCT_TEMPLATE_ID, + PROJECTS_API_URL } from '../config/constants' import { paginationHeaders } from '../util/pagination' - -const { PROJECT_API_URL } = process.env +import { createProjectMemberInvite } from './projectMemberInvites' /** * Get billing accounts based on project id @@ -21,7 +21,7 @@ const { PROJECT_API_URL } = process.env * @returns {Promise} Billing accounts data */ export async function fetchBillingAccounts (projectId) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccounts`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccounts`) return _.get(response, 'data') } @@ -33,7 +33,7 @@ export async function fetchBillingAccounts (projectId) { * @returns {Promise} Billing account data */ export async function fetchBillingAccount (projectId) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${projectId}/billingAccount`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${projectId}/billingAccount`) return _.get(response, 'data') } @@ -53,7 +53,7 @@ export function fetchMemberProjects (filters) { } } - return axiosInstance.get(`${PROJECT_API_URL}?${queryString.stringify(params)}`).then(response => { + return axiosInstance.get(`${PROJECTS_API_URL}?${queryString.stringify(params)}`).then(response => { return { projects: _.get(response, 'data'), pagination: paginationHeaders(response) } }) } @@ -64,7 +64,7 @@ export function fetchMemberProjects (filters) { * @returns {Promise<*>} */ export async function fetchProjectById (id) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}`) return _.get(response, 'data') } @@ -74,7 +74,7 @@ export async function fetchProjectById (id) { * @returns {Promise<*>} */ export async function fetchProjectPhases (id) { - const response = await axiosInstance.get(`${PROJECT_API_URL}/${id}/phases`, { + const response = await axiosInstance.get(`${PROJECTS_API_URL}/${id}/phases`, { params: { fields: 'id,name,products,status' } @@ -90,7 +90,7 @@ export async function fetchProjectPhases (id) { * @returns {Promise<*>} */ export async function updateProjectMemberRole (projectId, memberRecordId, newRole) { - const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`, { + const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`, { role: newRole }) return _.get(response, 'data') @@ -104,13 +104,27 @@ export async function updateProjectMemberRole (projectId, memberRecordId, newRol * @returns {Promise<*>} */ export async function addUserToProject (projectId, userId, role) { - const response = await axiosInstance.post(`${PROJECT_API_URL}/${projectId}/members`, { + const response = await axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/members`, { userId, role }) return _.get(response, 'data') } +/** + * adds the given user to the given project with the specified role + * @param projectId project id + * @param userId user id + * @param role + * @returns {Promise<*>} + */ +export async function inviteUserToProject (projectId, email, role) { + return createProjectMemberInvite(projectId, { + emails: [email], + role: role + }) +} + /** * removes the given member record from the project * @param projectId project id @@ -118,7 +132,7 @@ export async function addUserToProject (projectId, userId, role) { * @returns {Promise<*>} */ export async function removeUserFromProject (projectId, memberRecordId) { - const response = await axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/members/${memberRecordId}`) + const response = await axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/members/${memberRecordId}`) return response } @@ -145,7 +159,7 @@ export async function saveChallengeAsPhaseProduct (projectId, phaseId, challenge estimatedPrice: 1 } - return axiosInstance.post(`${PROJECT_API_URL}/${projectId}/phases/${phaseId}/products`, + return axiosInstance.post(`${PROJECTS_API_URL}/${projectId}/phases/${phaseId}/products`, _.set(payload, PHASE_PRODUCT_CHALLENGE_ID_FIELD, challengeId) ) } @@ -176,7 +190,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) { if (selectedMilestoneProduct) { // If its the only challenge in product and product doesn't contain any other detail just delete it - return axiosInstance.delete(`${PROJECT_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`) + return axiosInstance.delete(`${PROJECTS_API_URL}/${projectId}/phases/${selectedMilestoneProduct.phaseId}/products/${selectedMilestoneProduct.productId}`) } } @@ -186,7 +200,7 @@ export async function removeChallengeFromPhaseProduct (projectId, challengeId) { * @returns {Promise<*>} */ export async function createProjectApi (project) { - const response = await axiosInstance.post(`${PROJECT_API_URL}`, project) + const response = await axiosInstance.post(`${PROJECTS_API_URL}`, project) return _.get(response, 'data') } @@ -197,7 +211,7 @@ export async function createProjectApi (project) { * @returns {Promise<*>} */ export async function updateProjectApi (projectId, project) { - const response = await axiosInstance.patch(`${PROJECT_API_URL}/${projectId}`, project) + const response = await axiosInstance.patch(`${PROJECTS_API_URL}/${projectId}`, project) return _.get(response, 'data') } @@ -206,7 +220,7 @@ export async function updateProjectApi (projectId, project) { * @returns {Promise<*>} */ export async function getProjectTypes () { - const response = await axiosInstance.get(`${PROJECT_API_URL}/metadata/projectTypes`) + const response = await axiosInstance.get(`${PROJECTS_API_URL}/metadata/projectTypes`) return _.get(response, 'data') } @@ -218,7 +232,7 @@ export async function getProjectTypes () { */ export async function getProjectAttachment (projectId, attachmentId) { const response = await axiosInstance.get( - `${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}` + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}` ) return _.get(response, 'data') } @@ -241,7 +255,7 @@ export async function addProjectAttachmentApi (projectId, data) { } const response = await axiosInstance.post( - `${PROJECT_API_URL}/${projectId}/attachments`, + `${PROJECTS_API_URL}/${projectId}/attachments`, data ) return _.get(response, 'data') @@ -272,7 +286,7 @@ export async function updateProjectAttachmentApi ( } const response = await axiosInstance.patch( - `${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}`, + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}`, data ) return _.get(response, 'data') @@ -285,6 +299,6 @@ export async function updateProjectAttachmentApi ( */ export async function removeProjectAttachmentApi (projectId, attachmentId) { await axiosInstance.delete( - `${PROJECT_API_URL}/${projectId}/attachments/${attachmentId}` + `${PROJECTS_API_URL}/${projectId}/attachments/${attachmentId}` ) } From 06ab559e74ee6e9408f625c317041a7e1434ab1f Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Sun, 30 Mar 2025 10:57:04 +0300 Subject: [PATCH 3/4] PM-973 - invite by email --- src/components/UserCard/index.js | 158 ++++++++++++---------- src/components/Users/index.js | 32 ++++- src/components/Users/invite-user.modal.js | 34 +++-- src/containers/Users/index.js | 13 +- src/services/projectMemberInvites.js | 8 +- 5 files changed, 154 insertions(+), 91 deletions(-) diff --git a/src/components/UserCard/index.js b/src/components/UserCard/index.js index 87387714..67f1cc08 100644 --- a/src/components/UserCard/index.js +++ b/src/components/UserCard/index.js @@ -1,3 +1,5 @@ +import _ from 'lodash' +import moment from 'moment' import React, { Component } from 'react' import PropTypes from 'prop-types' import cn from 'classnames' @@ -6,7 +8,6 @@ import { PROJECT_ROLES } from '../../config/constants' import PrimaryButton from '../Buttons/PrimaryButton' import AlertModal from '../Modal/AlertModal' import { updateProjectMemberRole } from '../../services/projects' -import _ from 'lodash' const theme = { container: styles.modalContainer @@ -58,7 +59,7 @@ class UserCard extends Component { } render () { - const { user, onRemoveClick, isEditable } = this.props + const { isInvite, user, onRemoveClick, isEditable } = this.props const showRadioButtons = _.includes(_.values(PROJECT_ROLES), user.role) return (
@@ -90,76 +91,90 @@ class UserCard extends Component { )}
- {user.handle} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} - /> - -
)} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)} - /> - -
)} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)} - /> - -
)} -
-
- {showRadioButtons && (
- e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)} - /> - -
)} + {isInvite ? user.email : user.handle}
+ {!isInvite && ( + <> +
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.READ)} + /> + +
)} +
+
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.WRITE)} + /> + +
)} +
+
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.MANAGER)} + /> + +
)} +
+
+ {showRadioButtons && (
+ e.target.checked && this.updatePermission(PROJECT_ROLES.COPILOT)} + /> + +
)} +
+ + )} + {isInvite && ( + <> +
+
+ Invited {moment(user.createdAt).format('MMM D, YY')} +
+
+
+ + )} {isEditable ? (
0 + const membersExist = (projectMembers && projectMembers.length > 0) || (invitedMembers && invitedMembers.length > 0) const isCopilotOrManager = this.checkIsCopilotOrManager(projectMembers, loggedInHandle) const isAdmin = checkAdmin(this.props.auth.token) const showAddUser = isEditable && this.state.projectOption && (isCopilotOrManager || isAdmin) @@ -221,6 +226,8 @@ class Users extends Component { this.state.showInviteUserModal && ( ) @@ -229,7 +236,7 @@ class Users extends Component { this.state.showRemoveConfirmationModal && ( +
    + { + _.map(invitedMembers, (member) => { + return ( +
  • + +
  • + ) + }) + } +
) } @@ -292,6 +315,7 @@ Users.propTypes = { isSearchingUserProjects: PropTypes.bool, projects: PropTypes.arrayOf(PropTypes.object), projectMembers: PropTypes.arrayOf(PropTypes.object), + invitedMembers: PropTypes.arrayOf(PropTypes.object), searchUserProjects: PropTypes.func.isRequired, resultSearchUserProjects: PropTypes.arrayOf(PropTypes.object) } diff --git a/src/components/Users/invite-user.modal.js b/src/components/Users/invite-user.modal.js index 69f3cbe1..60f1330f 100644 --- a/src/components/Users/invite-user.modal.js +++ b/src/components/Users/invite-user.modal.js @@ -1,8 +1,7 @@ -/* eslint-disable no-unused-vars */ import React, { useState } from 'react' import PropTypes from 'prop-types' import cn from 'classnames' -import { get } from 'lodash' +import { find, get } from 'lodash' import Modal from '../Modal' import PrimaryButton from '../Buttons/PrimaryButton' import { inviteUserToProject } from '../../services/projects' @@ -19,23 +18,35 @@ const validateEmail = (email) => { return emailRegex.test(email) } -const InviteUserModalContent = ({ projectId, onClose }) => { +const InviteUserModalContent = ({ projectId, onClose, projectMembers, invitedMembers }) => { const [emailToInvite, setEmailToInvite] = useState('') const [showEmailError, setShowEmailError] = useState(false) const [inviteUserError, setInviteUserError] = useState(null) const [isInviting, setIsInviting] = useState(false) - const handleEmailBlur = () => { + const checkEmail = () => { if (!validateEmail(emailToInvite)) { setShowEmailError(true) + return false } + + if (find(invitedMembers, { email: emailToInvite })) { + setInviteUserError('Email is already invited!') + return false + } + + if (find(projectMembers, { email: emailToInvite })) { + setInviteUserError('Member already part of the project!') + return false + } + + return true } const onInviteUserConfirmClick = async () => { if (isInviting) return - if (!emailToInvite || !validateEmail(emailToInvite)) { - setShowEmailError(true) + if (!checkEmail()) { return } @@ -70,8 +81,9 @@ const InviteUserModalContent = ({ projectId, onClose }) => { onChange={(e) => { setEmailToInvite(e.target.value) setShowEmailError(false) + setInviteUserError(null) }} - onBlur={handleEmailBlur} + onBlur={checkEmail} />
@@ -81,7 +93,9 @@ const InviteUserModalContent = ({ projectId, onClose }) => {
)} {inviteUserError && ( -
{inviteUserError}
+
+
{inviteUserError}
+
)}
@@ -107,7 +121,9 @@ const InviteUserModalContent = ({ projectId, onClose }) => { InviteUserModalContent.propTypes = { projectId: PropTypes.number.isRequired, - onClose: PropTypes.func.isRequired + onClose: PropTypes.func.isRequired, + projectMembers: PropTypes.arrayOf(PropTypes.object), + invitedMembers: PropTypes.arrayOf(PropTypes.object) } export default InviteUserModalContent diff --git a/src/containers/Users/index.js b/src/containers/Users/index.js index 7a1ecf3f..d90ea7f9 100644 --- a/src/containers/Users/index.js +++ b/src/containers/Users/index.js @@ -19,6 +19,7 @@ class Users extends Component { this.state = { loginUserRoleInProject: '', projectMembers: null, + invitedMembers: null, isAdmin: false } this.loadProject = this.loadProject.bind(this) @@ -66,8 +67,10 @@ class Users extends Component { loadProject (projectId) { fetchProjectById(projectId).then((project) => { const projectMembers = _.get(project, 'members') + const invitedMembers = _.get(project, 'invites') this.setState({ - projectMembers + projectMembers, + invitedMembers }) const { loggedInUser } = this.props this.updateLoginUserRoleInProject(projectMembers, loggedInUser) @@ -88,11 +91,13 @@ class Users extends Component { } removeProjectNember (projectMember) { - const { projectMembers } = this.state + const { projectMembers, invitedMembers } = this.state const newProjectMembers = _.filter(projectMembers, pm => pm.id !== projectMember.id) + const newInvitedMembers = _.filter(invitedMembers, pm => pm.id !== projectMember.id) const { loggedInUser } = this.props this.setState({ - projectMembers: newProjectMembers + projectMembers: newProjectMembers, + invitedMembers: newInvitedMembers }) this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser) } @@ -120,6 +125,7 @@ class Users extends Component { } = this.props const { projectMembers, + invitedMembers, isAdmin } = this.state return ( @@ -130,6 +136,7 @@ class Users extends Component { removeProjectNember={this.removeProjectNember} addNewProjectMember={this.addNewProjectMember} projectMembers={projectMembers} + invitedMembers={invitedMembers} auth={auth} isAdmin={isAdmin} isEditable={this.isEditable()} diff --git a/src/services/projectMemberInvites.js b/src/services/projectMemberInvites.js index 6fa99c55..783155d5 100644 --- a/src/services/projectMemberInvites.js +++ b/src/services/projectMemberInvites.js @@ -9,7 +9,7 @@ import { PROJECTS_API_URL } from '../config/constants' * @return {object} project member invite returned by api */ export function updateProjectMemberInvite (projectId, inviteId, status) { - const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/${inviteId}` + const url = `${PROJECTS_API_URL}/${projectId}/invites/${inviteId}` return axios.patch(url, { status }) .then(resp => resp.data) } @@ -21,7 +21,7 @@ export function updateProjectMemberInvite (projectId, inviteId, status) { * @return {object} project member invite returned by api */ export function deleteProjectMemberInvite (projectId, inviteId) { - const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/${inviteId}` + const url = `${PROJECTS_API_URL}/${projectId}/invites/${inviteId}` return axios.delete(url) } @@ -47,7 +47,7 @@ export function createProjectMemberInvite (projectId, member) { export function getProjectMemberInvites (projectId) { const fields = 'id,projectId,userId,email,role,status,createdAt,updatedAt,createdBy,updatedBy,handle' - const url = `${PROJECTS_API_URL}/v5/projects/${projectId}/invites/?fields=` + + const url = `${PROJECTS_API_URL}/${projectId}/invites/?fields=` + encodeURIComponent(fields) return axios.get(url) .then(resp => { @@ -61,6 +61,6 @@ export function getProjectMemberInvites (projectId) { * @return {object} project member invite returned by api */ export function getProjectInviteById (projectId) { - return axios.get(`${PROJECTS_API_URL}/v5/projects/${projectId}/invites`) + return axios.get(`${PROJECTS_API_URL}/${projectId}/invites`) .then(resp => resp.data) } From 592b787912874b13f0c49a8b71d9f3438b19a59a Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 7 Apr 2025 16:17:22 +0300 Subject: [PATCH 4/4] PM-973 - add invitation dialog after user accepts invitation through email --- src/components/Users/index.js | 2 + src/components/Users/invite-user.modal.js | 15 ++- src/config/constants.js | 8 ++ src/containers/Challenges/index.js | 16 ++- src/containers/ProjectEditor/index.js | 7 +- .../ProjectInvitations.module.scss | 120 ++++++++++++++++++ src/containers/ProjectInvitations/index.js | 119 +++++++++++++++++ src/containers/Users/index.js | 11 ++ src/routes.js | 9 ++ src/util/tc.js | 9 ++ 10 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 src/containers/ProjectInvitations/ProjectInvitations.module.scss create mode 100644 src/containers/ProjectInvitations/index.js diff --git a/src/components/Users/index.js b/src/components/Users/index.js index 1fb02283..f14e6892 100644 --- a/src/components/Users/index.js +++ b/src/components/Users/index.js @@ -228,6 +228,7 @@ class Users extends Component { projectId={this.state.projectOption.value} projectMembers={projectMembers} invitedMembers={invitedMembers} + onMemberInvited={this.props.addNewProjectInvite} onClose={this.resetInviteUserState} /> ) @@ -309,6 +310,7 @@ Users.propTypes = { loadProject: PropTypes.func.isRequired, updateProjectNember: PropTypes.func.isRequired, removeProjectNember: PropTypes.func.isRequired, + addNewProjectInvite: PropTypes.func.isRequired, addNewProjectMember: PropTypes.func.isRequired, auth: PropTypes.object, isEditable: PropTypes.bool, diff --git a/src/components/Users/invite-user.modal.js b/src/components/Users/invite-user.modal.js index 60f1330f..41b1dff4 100644 --- a/src/components/Users/invite-user.modal.js +++ b/src/components/Users/invite-user.modal.js @@ -18,7 +18,7 @@ const validateEmail = (email) => { return emailRegex.test(email) } -const InviteUserModalContent = ({ projectId, onClose, projectMembers, invitedMembers }) => { +const InviteUserModalContent = ({ projectId, onClose, onMemberInvited, projectMembers, invitedMembers }) => { const [emailToInvite, setEmailToInvite] = useState('') const [showEmailError, setShowEmailError] = useState(false) const [inviteUserError, setInviteUserError] = useState(null) @@ -55,8 +55,16 @@ const InviteUserModalContent = ({ projectId, onClose, projectMembers, invitedMem try { // api restriction: ONLY "customer" role can be invited via email - await inviteUserToProject(projectId, emailToInvite, PROJECT_ROLES.CUSTOMER) - onClose() + const { success: invitations = [], failed } = await inviteUserToProject(projectId, emailToInvite, PROJECT_ROLES.CUSTOMER) + + if (failed) { + const error = get(failed, '0.message', 'Unable to invite user') + setInviteUserError(error) + setIsInviting(false) + } else { + onMemberInvited(invitations[0] || {}) + onClose() + } } catch (e) { const error = get(e, 'response.data.message', 'Unable to invite user') setInviteUserError(error) @@ -122,6 +130,7 @@ const InviteUserModalContent = ({ projectId, onClose, projectMembers, invitedMem InviteUserModalContent.propTypes = { projectId: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired, + onMemberInvited: PropTypes.func.isRequired, projectMembers: PropTypes.arrayOf(PropTypes.object), invitedMembers: PropTypes.arrayOf(PropTypes.object) } diff --git a/src/config/constants.js b/src/config/constants.js index 96a88579..a597e955 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -195,6 +195,14 @@ export const UPDATE_PROJECT_PENDING = 'UPDATE_PROJECT_PENDING' export const UPDATE_PROJECT_SUCCESS = 'UPDATE_PROJECT_SUCCESS' export const UPDATE_PROJECT_FAILURE = 'UPDATE_PROJECT_FAILURE' +export const PROJECT_MEMBER_INVITE_STATUS_ACCEPTED = 'accepted' +export const PROJECT_MEMBER_INVITE_STATUS_REFUSED = 'refused' +export const PROJECT_MEMBER_INVITE_STATUS_CANCELED = 'canceled' +export const PROJECT_MEMBER_INVITE_STATUS_PENDING = 'pending' +export const PROJECT_MEMBER_INVITE_STATUS_REQUESTED = 'requested' +export const PROJECT_MEMBER_INVITE_STATUS_REQUEST_APPROVED = 'request_approved' +export const PROJECT_MEMBER_INVITE_STATUS_REQUEST_REJECTED = 'request_rejected' + // Name of challenge tracks export const CHALLENGE_TRACKS = { DESIGN: DES_TRACK_ID, diff --git a/src/containers/Challenges/index.js b/src/containers/Challenges/index.js index ca7759f5..8b5d5868 100644 --- a/src/containers/Challenges/index.js +++ b/src/containers/Challenges/index.js @@ -20,7 +20,8 @@ import { setActiveProject, resetSidebarActiveParams } from '../../actions/sidebar' -import { checkAdmin } from '../../util/tc' +import { checkAdmin, checkIsUserInvited } from '../../util/tc' +import { withRouter } from 'react-router-dom' class Challenges extends Component { constructor (props) { @@ -55,6 +56,14 @@ class Challenges extends Component { } } + componentDidUpdate () { + const { auth } = this.props + + if (checkIsUserInvited(auth.token, this.props.projectDetail)) { + this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`) + } + } + componentWillReceiveProps (nextProps) { if ( (nextProps.dashboard && this.props.dashboard !== nextProps.dashboard) || @@ -194,6 +203,7 @@ Challenges.defaultProps = { } Challenges.propTypes = { + history: PropTypes.object, projects: PropTypes.arrayOf(PropTypes.shape()), menu: PropTypes.string, challenges: PropTypes.arrayOf(PropTypes.object), @@ -268,4 +278,6 @@ const mapDispatchToProps = { deleteChallenge } -export default connect(mapStateToProps, mapDispatchToProps)(Challenges) +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(Challenges) +) diff --git a/src/containers/ProjectEditor/index.js b/src/containers/ProjectEditor/index.js index 653b02b5..241562b2 100644 --- a/src/containers/ProjectEditor/index.js +++ b/src/containers/ProjectEditor/index.js @@ -15,7 +15,7 @@ import { updateProject } from '../../actions/projects' import { setActiveProject } from '../../actions/sidebar' -import { checkAdminOrCopilot, checkAdmin } from '../../util/tc' +import { checkAdminOrCopilot, checkAdmin, checkIsUserInvited } from '../../util/tc' import { PROJECT_ROLES } from '../../config/constants' import Loader from '../../components/Loader' @@ -37,6 +37,11 @@ class ProjectEditor extends Component { componentDidUpdate () { const { auth } = this.props + + if (checkIsUserInvited(auth.token, this.props.projectDetail)) { + this.props.history.push(`/projects/${this.props.projectDetail.id}/invitation`) + } + if (!checkAdminOrCopilot(auth.token, this.props.projectDetail)) { this.props.history.push('/projects') } diff --git a/src/containers/ProjectInvitations/ProjectInvitations.module.scss b/src/containers/ProjectInvitations/ProjectInvitations.module.scss new file mode 100644 index 00000000..865f95f9 --- /dev/null +++ b/src/containers/ProjectInvitations/ProjectInvitations.module.scss @@ -0,0 +1,120 @@ +@import '../../styles/includes'; + +.modalContainer { + padding: 0; + position: fixed; + overflow: auto; + z-index: 10000; + top: 0; + right: 0; + bottom: 0; + left: 0; + box-sizing: border-box; + width: auto; + max-width: none; + transform: none; + background: transparent; + color: $text-color; + opacity: 1; + display: flex; + justify-content: center; + align-items: center; + + :global { + button.close { + margin-right: 5px; + margin-top: 5px; + } + } + + .contentContainer { + box-sizing: border-box; + background: $white; + opacity: 1; + position: relative; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + border-radius: 6px; + margin: 0 auto; + width: 852px; + padding: 30px; + + .content { + padding: 30px; + width: 100%; + height: 100%; + } + + .title { + @include roboto-bold(); + + font-size: 30px; + line-height: 36px; + margin-bottom: 30px; + margin-top: 0; + } + + span { + @include roboto; + + font-size: 22px; + font-weight: 400; + line-height: 26px; + } + + &.confirm { + width: 999px; + + .buttonGroup { + display: flex; + justify-content: space-between; + margin-top: 30px; + + .buttonSizeA { + width: 193px; + height: 40px; + margin-right: 33px; + + span { + font-size: 18px; + font-weight: 500; + } + } + + .buttonSizeB { + width: 160px; + height: 40px; + + span { + font-size: 18px; + font-weight: 500; + line-height: 22px; + } + } + } + } + + .buttonGroup { + display: flex; + justify-content: space-between; + margin-top: 30px; + + .button { + width: 135px; + height: 40px; + margin-right: 66px; + + span { + font-size: 18px; + font-weight: 500; + } + } + + .button:last-child { + margin-right: 0; + } + } + } +} \ No newline at end of file diff --git a/src/containers/ProjectInvitations/index.js b/src/containers/ProjectInvitations/index.js new file mode 100644 index 00000000..89048b82 --- /dev/null +++ b/src/containers/ProjectInvitations/index.js @@ -0,0 +1,119 @@ +import PropTypes from 'prop-types' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { toastr } from 'react-redux-toastr' +import { checkIsUserInvited } from '../../util/tc' +import { isEmpty } from 'lodash' +import { loadProject } from '../../actions/projects' +import ConfirmationModal from '../../components/Modal/ConfirmationModal' + +import styles from './ProjectInvitations.module.scss' +import { updateProjectMemberInvite } from '../../services/projectMemberInvites' +import { PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, PROJECT_MEMBER_INVITE_STATUS_REFUSED } from '../../config/constants' + +const theme = { + container: styles.modalContainer +} + +const ProjectInvitations = ({ match, auth, isProjectLoading, history, projectDetail, loadProject }) => { + const automaticAction = useMemo(() => [PROJECT_MEMBER_INVITE_STATUS_ACCEPTED, PROJECT_MEMBER_INVITE_STATUS_REFUSED].includes(match.params.action) ? match.params.action : undefined, [match.params]) + const projectId = useMemo(() => parseInt(match.params.projectId), [match.params]) + const invitation = useMemo(() => checkIsUserInvited(auth.token, projectDetail), [auth.token, projectDetail]) + const [isUpdating, setIsUpdating] = useState(automaticAction || false) + const isAccepting = isUpdating === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED + const isDeclining = isUpdating === PROJECT_MEMBER_INVITE_STATUS_REFUSED + + useEffect(() => { + if (!projectId) { + return + } + + if (isProjectLoading || isEmpty(projectDetail)) { + if (!isProjectLoading) { + loadProject(projectId) + } + return + } + + if (!invitation) { + history.push(`/projects`) + } + }, [projectId, auth, projectDetail, isProjectLoading, history]) + + const updateInvite = useCallback(async (status) => { + setIsUpdating(status) + await updateProjectMemberInvite(projectId, invitation.id, status) + toastr.success('Success', `Successfully ${status} the invitation.`) + history.push(status === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED ? `/projects/${projectId}/challenges` : '/projects') + }, [invitation]) + + const acceptInvite = useCallback(() => updateInvite(PROJECT_MEMBER_INVITE_STATUS_ACCEPTED), [updateInvite]) + const declineInvite = useCallback(() => updateInvite(PROJECT_MEMBER_INVITE_STATUS_REFUSED), [updateInvite]) + + useEffect(() => { + if (!invitation || !automaticAction) { + return + } + + setTimeout(() => { + if (automaticAction === PROJECT_MEMBER_INVITE_STATUS_ACCEPTED) { + acceptInvite() + } else if (automaticAction === PROJECT_MEMBER_INVITE_STATUS_REFUSED) { + declineInvite() + } + }, [1500]) + }, [invitation, automaticAction]) + + return ( + <> + {invitation && ( + + )} + + ) +} + +ProjectInvitations.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + projectId: PropTypes.string + }) + }).isRequired, + auth: PropTypes.object.isRequired, + isProjectLoading: PropTypes.bool, + history: PropTypes.object, + loadProject: PropTypes.func.isRequired, + projectDetail: PropTypes.object +} + +const mapStateToProps = ({ projects, auth }) => { + return { + projectDetail: projects.projectDetail, + isProjectLoading: projects.isLoading, + auth + } +} + +const mapDispatchToProps = { + loadProject +} + +export default withRouter( + connect(mapStateToProps, mapDispatchToProps)(ProjectInvitations) +) diff --git a/src/containers/Users/index.js b/src/containers/Users/index.js index d90ea7f9..df4ae179 100644 --- a/src/containers/Users/index.js +++ b/src/containers/Users/index.js @@ -25,6 +25,7 @@ class Users extends Component { this.loadProject = this.loadProject.bind(this) this.updateProjectNember = this.updateProjectNember.bind(this) this.removeProjectNember = this.removeProjectNember.bind(this) + this.addNewProjectInvite = this.addNewProjectInvite.bind(this) this.addNewProjectMember = this.addNewProjectMember.bind(this) } @@ -115,6 +116,15 @@ class Users extends Component { this.updateLoginUserRoleInProject(newProjectMembers, loggedInUser) } + addNewProjectInvite (invitedMember) { + this.setState(() => ({ + invitedMembers: [ + ...(this.state.invitedMembers || []), + invitedMember + ] + })) + } + render () { const { projects, @@ -135,6 +145,7 @@ class Users extends Component { updateProjectNember={this.updateProjectNember} removeProjectNember={this.removeProjectNember} addNewProjectMember={this.addNewProjectMember} + addNewProjectInvite={this.addNewProjectInvite} projectMembers={projectMembers} invitedMembers={invitedMembers} auth={auth} diff --git a/src/routes.js b/src/routes.js index aa87a527..fed56ded 100644 --- a/src/routes.js +++ b/src/routes.js @@ -33,6 +33,7 @@ import ConfirmationModal from './components/Modal/ConfirmationModal' import Users from './containers/Users' import { isBetaMode, removeFromLocalStorage, saveToLocalStorage } from './util/localstorage' import ProjectEditor from './containers/ProjectEditor' +import ProjectInvitations from './containers/ProjectInvitations' const { ACCOUNTS_APP_LOGIN_URL, IDLE_TIMEOUT_MINUTES, IDLE_TIMEOUT_GRACE_MINUTES, COMMUNITY_APP_URL } = process.env @@ -210,6 +211,14 @@ class Routes extends React.Component { )()} /> + renderApp( + , + , + , + + )()} + /> renderApp( , diff --git a/src/util/tc.js b/src/util/tc.js index 576e9df4..a728834f 100644 --- a/src/util/tc.js +++ b/src/util/tc.js @@ -227,6 +227,15 @@ export const checkAdminOrCopilot = (token, project) => { return isAdmin || (isCopilot && canManageProject) } +export const checkIsUserInvited = (token, project) => { + if (!token) { + return + } + + const tokenData = decodeToken(token) + return project && !_.isEmpty(project) && _.find(project.invites, { userId: tokenData.userId }) +} + /** * Get resource role by name *