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)