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 = {