diff --git a/api/main_endpoints/routes/User.js b/api/main_endpoints/routes/User.js
index 80fdf0e43..928908360 100644
--- a/api/main_endpoints/routes/User.js
+++ b/api/main_endpoints/routes/User.js
@@ -128,6 +128,57 @@ router.post('/search', async function(req, res) {
});
});
+router.post('/admins/validate', async function(req, res) {
+ const decoded = await decodeToken(req, membershipState.NON_MEMBER);
+ if (decoded.status !== OK) {
+ return res.sendStatus(decoded.status);
+ }
+
+ if (!Array.isArray(req.body.ids)) {
+ return res.status(BAD_REQUEST).send({ message: 'ids must be an array.' });
+ }
+
+ const requestedIds = [];
+ const invalidIds = [];
+ const seen = new Set();
+
+ req.body.ids.forEach((id) => {
+ if (typeof id !== 'string' || !id.trim()) {
+ invalidIds.push(id);
+ return;
+ }
+
+ const normalizedId = id.trim();
+ if (seen.has(normalizedId)) {
+ return;
+ }
+ seen.add(normalizedId);
+ requestedIds.push(normalizedId);
+ });
+
+ const objectIdPattern = /^[0-9a-fA-F]{24}$/;
+ const candidateIds = requestedIds.filter((id) => objectIdPattern.test(id));
+ invalidIds.push(...requestedIds.filter((id) => !objectIdPattern.test(id)));
+
+ try {
+ const validAdmins = await User.find({
+ _id: { $in: candidateIds },
+ accessLevel: { $gte: membershipState.OFFICER }
+ }, '_id firstName lastName email accessLevel').lean();
+
+ const validIdSet = new Set(validAdmins.map((user) => user._id.toString()));
+ invalidIds.push(...candidateIds.filter((id) => !validIdSet.has(id)));
+
+ return res.status(OK).send({
+ validAdmins,
+ invalidIds
+ });
+ } catch (err) {
+ logger.error('/admins/validate had an error:', err);
+ return res.sendStatus(BAD_REQUEST);
+ }
+});
+
// Search for all members
router.post('/users', async function(req, res) {
const decoded = await decodeToken(req, membershipState.OFFICER);
@@ -147,6 +198,19 @@ router.post('/users', async function(req, res) {
};
}
+ if (req.body.minRole !== undefined && req.body.minRole !== null) {
+ if (Object.keys(maybeOr).length > 0) {
+ maybeOr = {
+ $and: [
+ maybeOr,
+ { accessLevel: { $gte: req.body.minRole } }
+ ]
+ };
+ } else {
+ maybeOr = { accessLevel: { $gte: req.body.minRole } };
+ }
+ }
+
const sortColumn = req.query.sort || 'joinDate';
const orderToInteger = {
diff --git a/src/APIFunctions/User.js b/src/APIFunctions/User.js
index bcb9ebbd1..23ac761f3 100644
--- a/src/APIFunctions/User.js
+++ b/src/APIFunctions/User.js
@@ -13,6 +13,7 @@ export async function getAllUsers({
page = null,
sortColumn = null,
sortOrder = null,
+ minRole = null,
}) {
const url = new URL('/api/User/users', BASE_API_URL);
@@ -35,6 +36,7 @@ export async function getAllUsers({
body: JSON.stringify({
query,
page,
+ minRole,
}),
});
if (res.ok) {
@@ -49,6 +51,29 @@ export async function getAllUsers({
return status;
}
+export async function validateEventAdmins(token, ids) {
+ let status = new UserApiResponse();
+ const url = new URL('/api/User/admins/validate', BASE_API_URL);
+ try {
+ const res = await fetch(url.href, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ ids }),
+ });
+ const result = await res.json();
+ status.responseData = result;
+ if (!res.ok) {
+ status.error = true;
+ }
+ } catch(err) {
+ status.error = true;
+ }
+ return status;
+}
+
/**
* Edit an existing users
* @param {Object} userToEdit - The user that is to be updated
diff --git a/src/Pages/Events/CalendarView.js b/src/Pages/Events/CalendarView.js
index 69dc9fc21..bff56880a 100644
--- a/src/Pages/Events/CalendarView.js
+++ b/src/Pages/Events/CalendarView.js
@@ -162,7 +162,7 @@ function getRegistrationStatus(event) {
return event?.registration_status || 'none';
}
-function getRegistrationCta(event) {
+function getRegistrationCta(event, isAdminView) {
const registrationStatus = getRegistrationStatus(event);
switch (registrationStatus) {
@@ -185,6 +185,13 @@ function getRegistrationCta(event) {
className: 'border border-violet-400/30 bg-violet-500/10 text-violet-200',
};
case 'rejected':
+ if (isAdminView) {
+ return {
+ label: 'Register',
+ disabled: false,
+ className: '',
+ };
+ }
return {
label: 'Unavailable',
disabled: true,
@@ -285,6 +292,7 @@ function EventPopup({ event, onClose, isAdminView, user }) {
const [waitlistMessage, setWaitlistMessage] = useState('');
const [waitlistError, setWaitlistError] = useState('');
const canEditEvent =
+ isAdminView &&
Array.isArray(event.admins) &&
userId &&
event.admins.includes(userId);
@@ -294,7 +302,7 @@ function EventPopup({ event, onClose, isAdminView, user }) {
hasCapacityLimit && typeof attendeeCount === 'number'
? Math.max(maxAttendees - attendeeCount, 0)
: null;
- const registrationCta = getRegistrationCta(event);
+ const registrationCta = getRegistrationCta(event, isAdminView);
const isFull = hasCapacityLimit && typeof remainingSpots === 'number' && remainingSpots <= 0;
const shouldShowWaitlistJoin =
!canEditEvent &&
diff --git a/src/Pages/Events/CreateEventPage.js b/src/Pages/Events/CreateEventPage.js
index a9edd305c..3c02ec473 100644
--- a/src/Pages/Events/CreateEventPage.js
+++ b/src/Pages/Events/CreateEventPage.js
@@ -1,8 +1,9 @@
/* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */
-import React, { useMemo, useState } from 'react';
+import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { useSCE } from '../../Components/context/SceContext.js';
import { createSCEvent } from '../../APIFunctions/SCEvents.js';
+import { getAllUsers } from '../../APIFunctions/User.js';
import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock.js';
import { membershipState } from '../../Enums';
@@ -67,6 +68,11 @@ function toApiRegistrationForm(questions) {
});
}
+function userDisplayName(admin) {
+ const name = [admin.firstName, admin.lastName].filter(Boolean).join(' ').trim();
+ return name || admin.email || admin._id;
+}
+
export default function CreateEventPage() {
const { user } = useSCE();
const history = useHistory();
@@ -92,12 +98,41 @@ export default function CreateEventPage() {
const [waitlistEnabled, setWaitlistEnabled] = useState(false);
const [waitlistSize, setWaitlistSize] = useState(10);
const [questions, setQuestions] = useState(defaultQuestions);
+ const [eventAdmins, setEventAdmins] = useState([]);
+ const [adminSearch, setAdminSearch] = useState('');
+ const [adminSearchResults, setAdminSearchResults] = useState([]);
+ const [adminSearchError, setAdminSearchError] = useState('');
+ const [adminSearching, setAdminSearching] = useState(false);
const [submitError, setSubmitError] = useState('');
const [submitting, setSubmitting] = useState(false);
+ const debounceRef = useRef(null);
const isOfficerOrAdmin = user?.accessLevel >= membershipState.OFFICER;
const adminId = useMemo(() => (user?._id != null ? String(user._id) : ''), [user]);
+ const eventAdminIds = useMemo(
+ () => eventAdmins.map((admin) => String(admin._id)),
+ [eventAdmins],
+ );
+
+ useEffect(() => {
+ if (!adminId) return;
+ setEventAdmins((prev) => {
+ if (prev.some((admin) => String(admin._id) === adminId)) {
+ return prev;
+ }
+ return [
+ ...prev,
+ {
+ _id: adminId,
+ firstName: user?.firstName || '',
+ lastName: user?.lastName || '',
+ email: user?.email || '',
+ accessLevel: user?.accessLevel,
+ },
+ ];
+ });
+ }, [adminId, user]);
function addQuestion() {
setQuestions((prev) => [...prev, newQuestionTemplate()]);
@@ -169,6 +204,56 @@ export default function CreateEventPage() {
);
}
+ function addEventAdmin(admin) {
+ setEventAdmins((prev) => {
+ if (prev.some((selected) => String(selected._id) === String(admin._id))) {
+ return prev;
+ }
+ return [...prev, admin];
+ });
+ setAdminSearchResults((prev) => (
+ prev.filter((candidate) => String(candidate._id) !== String(admin._id))
+ ));
+ }
+
+ function removeEventAdmin(id) {
+ if (String(id) === adminId) {
+ return;
+ }
+ setEventAdmins((prev) => prev.filter((admin) => String(admin._id) !== String(id)));
+ }
+
+ async function performAdminSearch(query) {
+ setAdminSearching(true);
+ const token = window.localStorage.getItem('jwtToken');
+ const result = await getAllUsers({ token, query, minRole: membershipState.OFFICER });
+ setAdminSearching(false);
+ if (result.error) {
+ setAdminSearchError('Failed to search admins.');
+ return;
+ }
+ const users = Array.isArray(result.responseData?.items) ? result.responseData.items : [];
+ setAdminSearchResults(
+ users.filter((candidate) => (
+ !eventAdminIds.includes(String(candidate._id))
+ )),
+ );
+ }
+
+ function handleAdminSearchChange(value) {
+ setAdminSearch(value);
+ setAdminSearchError('');
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ const query = value.trim();
+ if (query.length < 2) {
+ setAdminSearchResults([]);
+ setAdminSearching(false);
+ return;
+ }
+ setAdminSearching(true);
+ debounceRef.current = setTimeout(() => performAdminSearch(query), 300);
+ }
+
async function handleCreateEvent() {
setSubmitError('');
if (!eventName.trim()) {
@@ -179,6 +264,10 @@ export default function CreateEventPage() {
setSubmitError('Could not resolve your user id.');
return;
}
+ if (eventAdminIds.length === 0) {
+ setSubmitError('Please select at least one event admin.');
+ return;
+ }
if (visibility === 'private' && !minimumVisibleRole) {
setSubmitError('Please select a minimum visible role for private events.');
return;
@@ -198,7 +287,7 @@ export default function CreateEventPage() {
time,
location: location.trim(),
description: description.trim(),
- admins: [adminId], // The event creator becomes the initial event admin in SCEvents
+ admins: eventAdminIds,
registration_form: toApiRegistrationForm(questions),
max_attendees:
maxAttendees === UNLIMITED_ATTENDEES ? UNLIMITED_ATTENDEES : Number(maxAttendees),
@@ -471,6 +560,86 @@ export default function CreateEventPage() {
)}
+
+
+
+ Event admins
+
+
+
+ {eventAdmins.map((admin) => (
+
+
+
+ {userDisplayName(admin)}
+
+ {admin.email && (
+
{admin.email}
+ )}
+
+
+
+ ))}
+
+
+
+
handleAdminSearchChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ const query = adminSearch.trim();
+ if (query.length >= 2) performAdminSearch(query);
+ }
+ }}
+ placeholder="Search users by name or email"
+ />
+ {adminSearch.trim().length >= 2 && (adminSearching || adminSearchResults.length > 0 || adminSearchError) && (
+
+ {adminSearching && (
+
Searching…
+ )}
+ {!adminSearching && adminSearchError && (
+
{adminSearchError}
+ )}
+ {!adminSearching && adminSearchResults.map((admin) => (
+
+ ))}
+
+ )}
+
+
diff --git a/src/Pages/Events/EditEventPage.js b/src/Pages/Events/EditEventPage.js
index 9ce6b0cd3..655a78b28 100644
--- a/src/Pages/Events/EditEventPage.js
+++ b/src/Pages/Events/EditEventPage.js
@@ -1,8 +1,9 @@
/* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */
-import React, { useMemo, useState, useEffect } from 'react';
+import React, { useMemo, useRef, useState, useEffect } from 'react';
import { Link, useHistory, useParams } from 'react-router-dom';
import { useSCE } from '../../Components/context/SceContext.js';
import { getEventByID, updateSCEvent } from '../../APIFunctions/SCEvents.js';
+import { getAllUsers, validateEventAdmins } from '../../APIFunctions/User.js';
import CreateEventFormQuestionBlock from './CreateEventFormQuestionBlock.js';
import { membershipState } from '../../Enums';
@@ -41,6 +42,11 @@ function toApiRegistrationForm(questions) {
});
}
+function userDisplayName(admin) {
+ const name = [admin.firstName, admin.lastName].filter(Boolean).join(' ').trim();
+ return name || admin.email || admin._id;
+}
+
export default function EditEventPage() {
const { id } = useParams();
const { user } = useSCE();
@@ -62,12 +68,21 @@ export default function EditEventPage() {
const [waitlistSize, setWaitlistSize] = useState(10);
const [questions, setQuestions] = useState([]);
const [eventAdmins, setEventAdmins] = useState([]);
+ const [canEditLoadedEvent, setCanEditLoadedEvent] = useState(false);
+ const [adminSearch, setAdminSearch] = useState('');
+ const [adminSearchResults, setAdminSearchResults] = useState([]);
+ const [adminSearchError, setAdminSearchError] = useState('');
+ const [adminSearching, setAdminSearching] = useState(false);
const [submitError, setSubmitError] = useState('');
const [submitting, setSubmitting] = useState(false);
+ const debounceRef = useRef(null);
- const isOfficerOrAdmin = user?.accessLevel >= membershipState.OFFICER;
const userId = useMemo(() => (user?._id != null ? String(user._id) : ''), [user]);
+ const eventAdminIds = useMemo(
+ () => eventAdmins.map((admin) => String(admin._id)),
+ [eventAdmins],
+ );
useEffect(() => {
async function loadEvent() {
@@ -100,11 +115,22 @@ export default function EditEventPage() {
typeof evt.waitlist_size === 'number' && evt.waitlist_size > 0 ? evt.waitlist_size : 10,
);
setQuestions(evt.registration_form || []);
- setEventAdmins(evt.admins || []);
+ const adminIds = Array.isArray(evt.admins) ? evt.admins.map(String) : [];
+ setCanEditLoadedEvent(adminIds.includes(userId));
+ setEventAdmins(adminIds.map((adminId) => ({ _id: adminId })));
+
+ const adminResult = await validateEventAdmins(token, adminIds);
+ if (!adminResult.error) {
+ const validAdmins = Array.isArray(adminResult.responseData?.validAdmins)
+ ? adminResult.responseData.validAdmins
+ : [];
+ const validAdminIds = validAdmins.map((admin) => String(admin._id));
+ setEventAdmins(validAdmins);
+ }
}
loadEvent();
- }, [id]);
+ }, [id, userId]);
function addQuestion() {
setQuestions((prev) => [...prev, newQuestionTemplate()]);
@@ -176,6 +202,57 @@ export default function EditEventPage() {
);
}
+ function addEventAdmin(admin) {
+ setEventAdmins((prev) => {
+ if (prev.some((selected) => String(selected._id) === String(admin._id))) {
+ return prev;
+ }
+ return [...prev, admin];
+ });
+ setAdminSearchResults((prev) => (
+ prev.filter((candidate) => String(candidate._id) !== String(admin._id))
+ ));
+ }
+
+ function removeEventAdmin(adminId) {
+ if (eventAdminIds.length <= 1) {
+ window.alert('An event must have at least one admin.');
+ return;
+ }
+ setEventAdmins((prev) => prev.filter((admin) => String(admin._id) !== String(adminId)));
+ }
+
+ async function performAdminSearch(query) {
+ setAdminSearching(true);
+ const token = window.localStorage.getItem('jwtToken');
+ const result = await getAllUsers({ token, query, minRole: membershipState.OFFICER });
+ setAdminSearching(false);
+ if (result.error) {
+ setAdminSearchError('Failed to search admins.');
+ return;
+ }
+ const users = Array.isArray(result.responseData?.items) ? result.responseData.items : [];
+ setAdminSearchResults(
+ users.filter((candidate) => (
+ !eventAdminIds.includes(String(candidate._id))
+ )),
+ );
+ }
+
+ function handleAdminSearchChange(value) {
+ setAdminSearch(value);
+ setAdminSearchError('');
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ const query = value.trim();
+ if (query.length < 2) {
+ setAdminSearchResults([]);
+ setAdminSearching(false);
+ return;
+ }
+ setAdminSearching(true);
+ debounceRef.current = setTimeout(() => performAdminSearch(query), 300);
+ }
+
async function handleUpdateEvent() {
setSubmitError('');
if (!eventName.trim()) {
@@ -193,6 +270,18 @@ export default function EditEventPage() {
return;
}
+ if (eventAdminIds.length === 0) {
+ setSubmitError('Please select at least one event admin.');
+ return;
+ }
+
+ if (!eventAdminIds.includes(userId)) {
+ const confirmed = window.confirm('You will lose edit access to this event after saving.');
+ if (!confirmed) {
+ return;
+ }
+ }
+
const payload = {
name: eventName.trim(),
date,
@@ -207,6 +296,7 @@ export default function EditEventPage() {
minimum_visible_role: visibility === 'private' ? minimumVisibleRole : '',
waitlist_enabled: waitlistEnabled,
waitlist_size: waitlistEnabled ? Number(waitlistSize) : 0,
+ admins: eventAdminIds,
};
setSubmitting(true);
@@ -239,22 +329,6 @@ export default function EditEventPage() {
history.push('/events');
}
- if (!isOfficerOrAdmin) {
- return (
-
-
- Edit event
-
-
- Only officers and administrators can edit events.
-
-
- Back to events
-
-
- );
- }
-
if (isLoading) {
return
Loading event details...
;
}
@@ -272,8 +346,7 @@ export default function EditEventPage() {
}
// Edit access: only users listed in event.admins can update an event
- const isEventAdmin = eventAdmins.includes(userId);
- if (!isEventAdmin) {
+ if (!canEditLoadedEvent) {
return (
@@ -483,6 +556,85 @@ export default function EditEventPage() {
)}
+
+
+
+ Event admins
+
+
+
+ {eventAdmins.map((admin) => (
+
+
+
+ {userDisplayName(admin)}
+
+ {admin.email && (
+
{admin.email}
+ )}
+
+
+
+ ))}
+
+
+
+
handleAdminSearchChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ if (debounceRef.current) clearTimeout(debounceRef.current);
+ const query = adminSearch.trim();
+ if (query.length >= 2) performAdminSearch(query);
+ }
+ }}
+ placeholder="Search users by name or email"
+ />
+ {adminSearch.trim().length >= 2 && (adminSearching || adminSearchResults.length > 0 || adminSearchError) && (
+
+ {adminSearching && (
+
Searching…
+ )}
+ {!adminSearching && adminSearchError && (
+
{adminSearchError}
+ )}
+ {!adminSearching && adminSearchResults.map((admin) => (
+
+ ))}
+
+ )}
+
+
diff --git a/src/Pages/Events/Events.js b/src/Pages/Events/Events.js
index c0927d0ba..397bbae45 100644
--- a/src/Pages/Events/Events.js
+++ b/src/Pages/Events/Events.js
@@ -166,6 +166,7 @@ function EventCard({ event, user }) {
const [attendeeCount, setAttendeeCount] = useState(0);
const userAccess = getUserAccessLevel(user);
const isEventAdmin =
+ userAccess >= membershipState.OFFICER &&
Array.isArray(event.admins) && user?._id
? event.admins.includes(String(user._id))
: false;
diff --git a/src/Pages/Events/EventsRegistation.js b/src/Pages/Events/EventsRegistation.js
index f04f7d09a..014c49495 100644
--- a/src/Pages/Events/EventsRegistation.js
+++ b/src/Pages/Events/EventsRegistation.js
@@ -3,6 +3,7 @@ import React, { useState, useEffect } from 'react';
import { useParams, useHistory } from 'react-router-dom';
import { useSCE } from '../../Components/context/SceContext';
import { getEventByID, getEventAttendanceSummary, registerForSCEvent } from '../../APIFunctions/SCEvents';
+import { membershipState } from '../../Enums';
function ArrowLeftIcon() {
return (
@@ -255,7 +256,10 @@ export default function EventRegistration() {
);
}
- if (registrationStatus === 'rejected') {
+ const canCreateEvent = user?.accessLevel >= membershipState.OFFICER;
+ const isAdminView = canCreateEvent;
+
+ if (registrationStatus === 'rejected' && !isAdminView) {
return (
{
});
});
+ describe('/POST admins/validate', () => {
+ it('Should return statusCode 401 if no token is passed in', async () => {
+ const result = await test.sendPostRequest(
+ '/api/User/admins/validate', { ids: [] });
+ expect(result).to.have.status(UNAUTHORIZED);
+ });
+
+ it('Should return statusCode 403 if an invalid token was passed in', async () => {
+ setTokenStatus(null);
+ const result = await test.sendPostRequestWithToken(
+ token, '/api/User/admins/validate', { ids: [] });
+ expect(result).to.have.status(FORBIDDEN);
+ });
+
+ it('Should return statusCode 400 if ids is not an array', async () => {
+ setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.ADMIN });
+ const result = await test.sendPostRequestWithToken(
+ token, '/api/User/admins/validate', { ids: 'not-array' });
+ expect(result).to.have.status(BAD_REQUEST);
+ });
+
+ it('Should return valid admin users and invalid ids', async () => {
+ await User.deleteMany({});
+
+ const admin = await new User({
+ email: 'admin@sce.dev',
+ password: 'Passw0rd',
+ firstName: 'Ada',
+ lastName: 'Admin',
+ major: 'Computer Science',
+ accessLevel: MEMBERSHIP_STATE.ADMIN,
+ }).save();
+ const officer = await new User({
+ email: 'officer@sce.dev',
+ password: 'Passw0rd',
+ firstName: 'Ollie',
+ lastName: 'Officer',
+ major: 'Computer Science',
+ accessLevel: MEMBERSHIP_STATE.OFFICER,
+ }).save();
+ const missingId = new mongoose.Types.ObjectId().toString();
+
+ setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.ADMIN });
+ const result = await test.sendPostRequestWithToken(
+ token,
+ '/api/User/admins/validate',
+ {
+ ids: [
+ admin._id.toString(),
+ officer._id.toString(),
+ missingId,
+ 'not-object-id',
+ admin._id.toString()
+ ]
+ }
+ );
+
+ expect(result).to.have.status(OK);
+ expect(result.body.validAdmins).to.have.length(2);
+ expect(result.body.validAdmins).to.deep.include.members([
+ {
+ _id: admin._id.toString(),
+ firstName: 'Ada',
+ lastName: 'Admin',
+ email: 'admin@sce.dev',
+ accessLevel: MEMBERSHIP_STATE.ADMIN
+ },
+ {
+ _id: officer._id.toString(),
+ firstName: 'Ollie',
+ lastName: 'Officer',
+ email: 'officer@sce.dev',
+ accessLevel: MEMBERSHIP_STATE.OFFICER
+ }
+ ]);
+ expect(result.body.invalidIds).to.have.members([
+ missingId,
+ 'not-object-id'
+ ]);
+ });
+ });
+
describe('/POST edit', () => {
it('Should return statusCode 401 if no token is passed in', async () => {
const user = {