diff --git a/backend/controllers/project.controller.js b/backend/controllers/project.controller.js index db017c890..03e710f37 100644 --- a/backend/controllers/project.controller.js +++ b/backend/controllers/project.controller.js @@ -1,4 +1,5 @@ const { Project, User } = require('../models'); +const { ObjectId } = require('mongodb'); const ProjectController = {}; @@ -87,11 +88,11 @@ ProjectController.updateManagedByUsers = async function (req, res) { managedProjects = managedProjects.filter((id) => id !== ProjectId); } - // Update project's managedByUsers + // Update project's managedByUsers project.managedByUsers = managedByUsers; await project.save({ validateBeforeSave: false }); - // Update user's managedProjects + // Update user's managedProjects user.managedProjects = managedProjects; await user.save({ validateBeforeSave: false }); @@ -102,4 +103,31 @@ ProjectController.updateManagedByUsers = async function (req, res) { } }; +ProjectController.bulkUpdateManagedByUsers = async function (req, res) { + const { bulkOps } = req.body; + + // Convert string IDs to ObjectId in bulkOps + bulkOps.forEach((op) => { + if (op?.updateOne?.filter._id) { + op.updateOne.filter._id = ObjectId(op.updateOne.filter._id); + } + if (op?.updateOne?.update) { + const update = op.updateOne.update; + if (update?.$addToSet?.managedByUsers) { + update.$addToSet.managedByUsers = ObjectId(update.$addToSet.managedByUsers); + } + if (update?.$pull?.managedByUsers) { + update.$pull.managedByUsers = ObjectId(update.$pull.managedByUsers); + } + } + }); + + try { + const result = await Project.bulkWrite(bulkOps); + res.status(200).json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + module.exports = ProjectController; diff --git a/backend/controllers/user.controller.js b/backend/controllers/user.controller.js index 267ab5e68..70fedc8e0 100644 --- a/backend/controllers/user.controller.js +++ b/backend/controllers/user.controller.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const { ObjectId } = require('mongodb'); const EmailController = require('./email.controller'); const { CONFIG_AUTH } = require('../config'); @@ -27,6 +28,25 @@ UserController.user_list = async function (req, res) { } }; +UserController.user_by_email = async function (req, res) { + const { headers } = req; + const { email } = req.params; + + console.log('email: ', email); + + if (headers['x-customrequired-header'] !== expectedHeader) { + return res.sendStatus(403); + } + + try { + const user = await User.find({ email }); + return res.status(200).send(user); + } catch (err) { + console.log(err); + return res.sendStatus(400); + } +}; + // Get list of Users with accessLevel 'admin' or 'superadmin' with GET UserController.admin_list = async function (req, res) { const { headers } = req; @@ -104,8 +124,6 @@ UserController.user_by_id = async function (req, res) { try { const user = await User.findById(UserId); - // TODO throw 404 if User.findById returns empty object - // and look downstream to see whether 404 would break anything return res.status(200).send(user); } catch (err) { console.error(err); @@ -294,7 +312,7 @@ UserController.updateManagedProjects = async function (req, res) { managedByUsers = managedByUsers.filter((id) => id !== UserId); } - // Update user's managedProjects + // Update user's managedProjects user.managedProjects = managedProjects; await user.save({ validateBeforeSave: false }); @@ -309,4 +327,31 @@ UserController.updateManagedProjects = async function (req, res) { } }; +UserController.bulkUpdateManagedProjects = async function (req, res) { + const { bulkOps } = req.body; + + // Convert string IDs to ObjectId in bulkOps + bulkOps.forEach((op) => { + if (op?.updateOne?.filter._id) { + op.updateOne.filter._id = ObjectId(op.updateOne.filter._id); + } + if (op?.updateOne?.update) { + const update = op.updateOne.update; + if (update?.$addToSet?.managedProjects) { + update.$addToSet.managedProjects = ObjectId(update.$addToSet.managedProjects); + } + if (update?.$pull?.managedProjects) { + update.$pull.managedProjects = ObjectId(update.$pull.managedProjects); + } + } + }); + + try { + const result = await User.bulkWrite(bulkOps); + res.status(200).json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}; + module.exports = UserController; diff --git a/backend/models/user.model.js b/backend/models/user.model.js index 6273320ff..99a64d1fa 100644 --- a/backend/models/user.model.js +++ b/backend/models/user.model.js @@ -37,7 +37,7 @@ const userSchema = mongoose.Schema({ isHflaGithubMember: { type: Boolean }, // pull from API once github handle in place? githubPublic2FA: { type: Boolean }, // does the user have 2FA enabled on their github and membership set to public? availability: { type: String }, // availability to meet outside of hacknight times; string for now, more structured in future - managedProjects: [{ type: String}], // Which projects managed by user. + managedProjects: [{ type: String }], // Which projects managed by user. //currentProject: { type: String } // no longer need this as we can get it from Project Team Member table // password: { type: String, required: true } isActive: { type: Boolean, default: true } diff --git a/backend/routers/projects.router.js b/backend/routers/projects.router.js index 19acaa43d..c60dd113d 100644 --- a/backend/routers/projects.router.js +++ b/backend/routers/projects.router.js @@ -12,11 +12,14 @@ router.put('/', ProjectController.pm_filtered_projects); router.post('/', AuthUtil.verifyCookie, ProjectController.create); -router.get('/:ProjectId', ProjectController.project_by_id); +router.get('/:ProjectId', AuthUtil.verifyCookie, ProjectController.project_by_id); router.put('/:ProjectId', AuthUtil.verifyCookie, ProjectController.update); // Update project's managedByUsers in db router.patch('/:ProjectId', AuthUtil.verifyCookie, ProjectController.updateManagedByUsers); +// Bulk update for editing project members +router.post('/bulk-updates', AuthUtil.verifyCookie, ProjectController.bulkUpdateManagedByUsers); + module.exports = router; diff --git a/backend/routers/users.router.js b/backend/routers/users.router.js index 832c9edf0..bf6306442 100644 --- a/backend/routers/users.router.js +++ b/backend/routers/users.router.js @@ -6,17 +6,20 @@ const { UserController } = require('../controllers'); // The base is /api/users router.get('/', UserController.user_list); +router.get('/id/:UserId', UserController.user_by_id); + +router.get('/email/:email', UserController.user_by_email); + router.get('/admins', UserController.admin_list); router.get('/projectManagers', UserController.projectManager_list); router.post('/', UserController.create); -router.get('/:UserId', UserController.user_by_id); +router.post('/bulk-updates', UserController.bulkUpdateManagedProjects); router.patch('/:UserId', UserController.update); -// Update user projects in db router.patch('/:UserId/managedProjects', UserController.updateManagedProjects); router.delete('/:UserId', UserController.delete); diff --git a/client/src/api/ProjectApiService.js b/client/src/api/ProjectApiService.js index 80dfc8523..9160fe7e4 100644 --- a/client/src/api/ProjectApiService.js +++ b/client/src/api/ProjectApiService.js @@ -119,6 +119,35 @@ class ProjectApiService { return undefined; } } + + async fetchManagedByUsers(projectId) { + const url = `${this.baseProjectUrl}${projectId}`; + try { + const res = await fetch(url, { + headers: this.headers, + method: 'GET', + }); + return await res.json(); + } catch (error) { + console.error(`fetchManagedByUsers error: ${error}`); + alert('Server not responding. Please refresh the page.'); + } + } + + async bulkUpdateManagedByUsers(bulkOps) { + const url = `${this.baseProjectUrl}bulk-updates`; + try { + const res = await fetch(url, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ bulkOps }), + }); + return await res.json(); + } catch (error) { + console.error(`bulkUpdateManagedByUsers error: ${error}`); + alert('Server not responding. Please refresh the page.'); + } + } } export default ProjectApiService; diff --git a/client/src/api/UserApiService.js b/client/src/api/UserApiService.js index 0c4e115d4..1682cfc9d 100644 --- a/client/src/api/UserApiService.js +++ b/client/src/api/UserApiService.js @@ -14,6 +14,36 @@ class UserApiService { const res = await fetch(this.baseUserUrl, { headers: this.headers, }); + console.log(res); + return await res.json(); + } catch (error) { + console.error(`fetchUsers error: ${error}`); + alert('Server not responding. Please refresh the page.'); + } + return []; + } + + async fetchUserById(id) { + try { + const uri = `${this.baseUserUrl}id/${id}`; + const res = await fetch(uri, { + headers: this.headers, + }); + return await res.json(); + } catch (error) { + console.error(`fetchUsers error: ${error}`); + alert('Server not responding. Please refresh the page.'); + } + return []; + } + + // Fetch user by email + async fetchUserByEmail(email) { + try { + const uri = `${this.baseUserUrl}email/${email}`; + const res = await fetch(uri, { + headers: this.headers, + }); return await res.json(); } catch (error) { console.error(`fetchUsers error: ${error}`); @@ -118,6 +148,21 @@ class UserApiService { alert('server not responding. Please try again.'); } } + + async bulkUpdateManagedProjects(bulkOps) { + const url = `${this.baseUserUrl}bulk-updates`; + try { + const res = await fetch(url, { + method: 'POST', + headers: this.headers, + body: JSON.stringify({ bulkOps }), + }); + return await res.json(); + } catch (error) { + console.error(`bulkUpdateManagedProjects error: ${error}`); + alert('Server not responding. Please try again.'); + } + } } export default UserApiService; diff --git a/client/src/components/manageProjects/editPMs/buttonGroup.jsx b/client/src/components/manageProjects/editPMs/buttonGroup.jsx new file mode 100644 index 000000000..29727dbd3 --- /dev/null +++ b/client/src/components/manageProjects/editPMs/buttonGroup.jsx @@ -0,0 +1,29 @@ +import { CircularProgress, Grid } from "@mui/material"; +import { StyledButton } from '../../ProjectForm'; + +const ButtonGroup = ({ btnName1, btnName2, callBackFn1, callBackFn2, isLoading }) => ( + + + callBackFn1(btn)} + > + {isLoading ? : `${btnName1}`} + + + + + {btnName2} + + + +); + +export default ButtonGroup \ No newline at end of file diff --git a/client/src/components/manageProjects/editPMs/editProjectMembers.jsx b/client/src/components/manageProjects/editPMs/editProjectMembers.jsx new file mode 100644 index 000000000..1ec35e78b --- /dev/null +++ b/client/src/components/manageProjects/editPMs/editProjectMembers.jsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react'; +import { + Typography, + Box, + Grid, + TextField, +} from "@mui/material"; +import useAuth from '../../../hooks/useAuth'; +import EditIcon from '../../../svg/Icon_Edit.svg?react'; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import CheckCircleOutline from '@mui/icons-material/CheckCircleOutline'; +import TitledBox from '../../parts/boxes/TitledBox'; +import UserApiService from '../../../api/UserApiService'; +import ProjectMembersList from './projectMembersList' + +const userApiService = new UserApiService(); + +const EditProjectMembers = ({ projectToEdit }) => { + // ----------------- States ----------------- + const { auth } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [editMode, setEditMode] = useState(false); + const [error, setError] = useState(""); + const [toggleSelect, setToggleSelect] = useState(false); + const [email, setEmail] = useState(''); + const [searchedUser, setSearchedUser] = useState({}); + const [closeConfirmModal, setCloseConfirmModal] = useState(false); + const [changesMade, setChangesMade] = useState(false); + const [projectMembers, setProjectMembers] = useState([]); + const [renderedUsers, setRenderedUsers] = useState([]); + + useEffect(() => { + // Create an array of projectMembers (users) from project's managedByUsers (user IDs) + const fetchProjectMembers = async () => { + if (projectToEdit?.managedByUsers?.length) { + setIsLoading(true); + try { + const members = await Promise.all( + projectToEdit.managedByUsers.map(async (userId) => { + const user = await userApiService.fetchUserById(userId.toString()); + return user; + }) + ); + setProjectMembers(members); + setRenderedUsers(members); + } catch (err) { + console.log(err) + } + setIsLoading(false); + } + } + if (!changesMade) fetchProjectMembers(); + }, []); + + const accessLevel = auth?.user?.accessLevel; + const userId = auth?.user?._id; + + // Edit icon component only avaiable for VRMS admins and project members (users in project) + const editIcon = () => { + return (accessLevel !== 'user' || projectToEdit?.managedByUsers?.includes(userId)) && ( + { + if (editMode && changesMade) { + setCloseConfirmModal(true); + } else { + setEditMode(!editMode); + setError(""); + } + }} + > + + + {editMode ? 'Cancel' : 'Edit'} + + + ); + }; + + const handleEmailSearch = async (search) => { + setEmail(search); + // Reset toggleSelect state if user starts typing again + if (toggleSelect) setToggleSelect(false); + + // RegEx for valid email check + const emailRegEx = /^((?:[A-Za-z0-9!#$%&'*+\-\/=?^_`{|}~]|(?<=^|\.)"|"(?=$|\.|@)|(?<=".*)[ .](?=.*")|(? { + // Check if user is already in renderedUsers + if (renderedUsers.some(user => user._id === addedUser._id)) { + setError("The user has already been added"); + return; + } + + setToggleSelect(true); + if (!toggleSelect) { + setRenderedUsers((prevUsers) => [...prevUsers, addedUser]); // Add user to project's managedByUsers array + setChangesMade(true); // Set changes made to true + } + // Confirmation message disappears after 1.5 seconds + setTimeout(() => { + setEmail(""); + setSearchedUser({}); + setToggleSelect(false); + }, 1500); + } + + + return ( + + + {/* Email search componennt */} + + + handleEmailSearch(e.target.value)} + placeholder="Enter user email address" + value={email} + size="small" + /> + + {searchedUser?.email && ( + + + + {!toggleSelect ? searchedUser?.email : User added to project successfully} + + {/* Icons for adding and confirming email of new user */} + {!toggleSelect ? handleAddUser(searchedUser)} /> + : } + + + )} + + {/* Display error message */} + {error && ({error})} + {/* Display project members */} + + + + ) +} + +export default EditProjectMembers \ No newline at end of file diff --git a/client/src/components/manageProjects/editPMs/projectMembersList.jsx b/client/src/components/manageProjects/editPMs/projectMembersList.jsx new file mode 100644 index 000000000..120f8885d --- /dev/null +++ b/client/src/components/manageProjects/editPMs/projectMembersList.jsx @@ -0,0 +1,297 @@ +import { useEffect, useState } from 'react'; +import { + Typography, + Box, + Grid, + List, + ListItem, + ListItemButton, + Modal, +} from "@mui/material"; +import ButtonGroup from './buttonGroup' +import DeleteIcon from '@mui/icons-material/Delete'; +import CloseIcon from '@mui/icons-material/Close'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; +import CheckCircleOutline from '@mui/icons-material/CheckCircleOutline'; +import UserApiService from '../../../api/UserApiService'; +import ProjectApiService from '../../../api/ProjectApiService'; + +const userApiService = new UserApiService(); +const projectApiService = new ProjectApiService(); + + +const ProjectMembersList = ({ projectId, projectMembers, renderedUsers, setRenderedUsers, editMode, closeConfirmModal, setChangesMade, setCloseConfirmModal, setEditMode, isLoading, setEmail, setIsLoading }) => { + const [openModal, setOpenModal] = useState(false); + const [removeConfirmModal, setRemoveConfirmModal] = useState(false); + const [removeId, setRemoveId] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(""); // Store user ID state of selected user to show info + + useEffect(() => { + setSelectedUserId(""); // close user info when exiting out of "Edit" mode + }, [projectMembers, editMode]) + + const handleSavePMs = async () => { + // Create addedUsers and removedUsers arrays from original projectMembers + const addedUsers = renderedUsers.filter( + newUser => !projectMembers.some(oldUser => oldUser._id === newUser._id) + ); + const removedUsers = projectMembers.filter( + oldUser => !renderedUsers.some(newUser => newUser._id === oldUser._id) + ); + + if (addedUsers.length === 0 && removedUsers.length === 0) { + // No changes made, exit edit mode + setEditMode(false); + setChangesMade(false); + return; + } + + // Use bulkWrite as opposed to a loop with Promise.all & controller for runtime and network efficiency + try { + setIsLoading(true); + + const addUsersToProjBulkOps = [ + ...addedUsers.map(user => ({ + // Update manageByUsers array for project + updateOne: { + filter: { _id: projectId }, + update: { $addToSet: { managedByUsers: user._id } }, + }, + })), + ] + const removeUsersFromProjBulkOps = [ + ...removedUsers.map(user => ({ + updateOne: { + filter: { _id: projectId }, + update: { $pull: { managedByUsers: user._id } }, + }, + })), + ] + + const projBulkOps = [...addUsersToProjBulkOps, ...removeUsersFromProjBulkOps]; + + const addProjToUserBulkOps = [ + ...addedUsers.map(user => ({ + // Update managedProjects array for user + updateOne: { + filter: { _id: user._id }, + update: { $addToSet: { managedProjects: projectId } }, + }, + })), + ] + const removeProjFromUserBulkOps = [ + ...removedUsers.map(user => ({ + updateOne: { + filter: { _id: user._id }, + update: { $pull: { managedProjects: projectId } }, + }, + })), + ] + + const userBulkOps = [...addProjToUserBulkOps, ...removeProjFromUserBulkOps]; + + await projectApiService.bulkUpdateManagedByUsers(projBulkOps); // Bulk update of each project's managedByUsers + await userApiService.bulkUpdateManagedProjects(userBulkOps); // Bulk update of each user's managedProjects + + setChangesMade(false); // Reset changes made + setEditMode(false); // Exit edit mode + } catch (err) { + console.log(err) + alert('Save failed. Please try again.'); + } + setIsLoading(false); + } + + const handleClosePMs = () => setCloseConfirmModal(true); + + const handleCloseOnYes = () => { + setChangesMade(false); // Discard changes + setRenderedUsers(projectMembers); // Reset renderedUsers to original projectMembers + setCloseConfirmModal(false); // Close modal and exit edit mode + setEmail(""); // Clear email search field + setEditMode(false); + } + + const handleCloseOnNo = () => setCloseConfirmModal(false); + + const handleRemoveConfirm = () => { + setChangesMade(true); + // Remove user from renderedUsers state to update UI + const updatedUsers = renderedUsers.filter(user => user._id !== removeId); + setRenderedUsers(updatedUsers); + // Show confirmation modal + setRemoveConfirmModal(true); + setOpenModal(false); + // Auto close confirmation modal after 1.5 seconds + setTimeout(() => { + setRemoveConfirmModal(false); + }, 1500); + } + + const modalStyle1 = { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + bgcolor: 'transparent', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1300, + } + + const modalStyle2 = { + alignItems: 'center', + textAlign: 'center', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + bgcolor: 'white', + border: '2px solid #000', + boxShadow: 'none', + p: 4, + }; + + return ( + + + {renderedUsers.map((user, idx) => { + const { _id, name, email } = user; // destructure user object + return ( + <> + + + { + if (editMode && !openModal) setSelectedUserId(_id); + }} + > + + + {name.firstName.toUpperCase() + + ' ' + + name.lastName[0].toUpperCase() + '.'} + + + + + {email} + + + {/* Remove Modal */} + + + + + Are you sure you want to remove this user from the project? + + { setSelectedUserId(""); setOpenModal(false); }} isLoading={isLoading} /> + + + {/* Remove Confirmation Modal */} + + setRemoveConfirmModal(false)} + sx={modalStyle1} + > + e.stopPropagation()}> + + + User removed from project. + + + + + + {editMode && { setOpenModal(true); setRemoveId(_id); }} />} + + + {/* User information */} + {selectedUserId === _id && + + + + setSelectedUserId("")} /> + + + + + {name.firstName.toUpperCase() + + ' ' + + name.lastName.toUpperCase()} + + + + + {email} + + + + + + } + + ); + })} + + {editMode && } + {/* Close Confirmation Modal */} + + + + + Are you sure you want to close without saving these changes? + + + + + + ); +}; + +export default ProjectMembersList \ No newline at end of file diff --git a/client/src/components/manageProjects/editProject.jsx b/client/src/components/manageProjects/editProject.jsx index 784e08cae..4af063275 100644 --- a/client/src/components/manageProjects/editProject.jsx +++ b/client/src/components/manageProjects/editProject.jsx @@ -10,6 +10,7 @@ import EditIcon from '../../svg/Icon_Edit.svg?react'; import PlusIcon from '../../svg/PlusIcon.svg?react'; import { Typography, Box } from '@mui/material'; +import EditProjectMembers from './editPMs/editProjectMembers'; // Need to hold user state to check which type of user they are and conditionally render editing fields in this component // for user level block access to all except for the ones checked @@ -106,6 +107,9 @@ const EditProject = ({ setFormData={setFormData} /> + {/* Insert Project Members (Event Editors) here */} + + userProjects.includes(item[0]) ); diff --git a/client/src/pages/ProjectList.jsx b/client/src/pages/ProjectList.jsx index 13d2bf6d8..4bbb2eaa1 100644 --- a/client/src/pages/ProjectList.jsx +++ b/client/src/pages/ProjectList.jsx @@ -63,7 +63,8 @@ export default function ProjectList({ auth }) { [projectApiService, user.accessLevel, user.managedProjects] ); - + const projsWithUsers = projects?.filter((project) => project.managedByUsers?.length > 0); + console.log('Projects with users:', projsWithUsers); // Render loading circle until project data is served from API if (!projects)