Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions api/main_endpoints/routes/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/APIFunctions/SCEvents.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function getAllSCEvents(token, { startDate, endDate } = {}) {
url.searchParams.set('endDate', endDate);
}

const res = await fetch(url.pathname + url.search, {
const res = await fetch(url.href, {
headers,
});

Expand Down
25 changes: 25 additions & 0 deletions src/APIFunctions/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -35,6 +36,7 @@ export async function getAllUsers({
body: JSON.stringify({
query,
page,
minRole,
}),
});
if (res.ok) {
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/Pages/Events/CalendarView.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ function getBadgeText(event, isAdminView) {
return '';
}

function getRegistrationCta(event) {
function getRegistrationCta(event, isAdminView) {
const registrationStatus = event?.registration_status || 'none';

switch (registrationStatus) {
Expand All @@ -183,6 +183,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,
Expand All @@ -205,15 +212,20 @@ function getRegistrationCta(event) {
*/
function canUserManageEvent(event, user) {
const userId = user?._id != null ? String(user._id) : '';
const access = user?.accessLevel ?? 0;
const eventAdmins = Array.isArray(event.admins) ? event.admins.map((id) => String(id)) : [];

// 1. If user is explicitly listed as an admin for this event
if (eventAdmins.length > 0 && userId && eventAdmins.includes(userId)) {
return true;
}

if (event.all_org_admins_can_edit && access >= membershipState.OFFICER) {
return true;
}

// 2. If the event has no admins, allow users with level 3 (ADMIN) or higher to manage it
if (eventAdmins.length === 0 && (user?.accessLevel ?? 0) >= membershipState.ADMIN) {
if (eventAdmins.length === 0 && access >= membershipState.ADMIN) {
return true;
}

Expand All @@ -239,7 +251,7 @@ function EventPopup({ event, onClose, isAdminView, user }) {
const eventId = event?.id || event?._id;
const authToken = user?.token;
const { maxAttendees, hasCapacityLimit, remainingSpots, isFull } = calculateEventCapacity(event, attendeeCount);
const registrationCta = getRegistrationCta(event);
const registrationCta = getRegistrationCta(event, isAdminView);
const shouldShowWaitlistJoin =
!canManageEvent &&
event.status === 'published' &&
Expand Down
120 changes: 115 additions & 5 deletions src/Pages/Events/CreateEventPage.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/* eslint-disable camelcase -- mirrors SCEvents JSON field names in state and payloads */
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { useSCE } from '../../Components/context/SceContext';
import { createSCEvent } from '../../APIFunctions/SCEvents';
import { getAllUsers } from '../../APIFunctions/User';
import { membershipState } from '../../Enums';
import { useEventQuestions, toApiRegistrationForm } from './useEventQuestions';
import { getApiErrorMessage } from './eventUtils';
Expand Down Expand Up @@ -37,6 +38,11 @@ function defaultQuestions() {
];
}

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();
Expand All @@ -61,7 +67,6 @@ export default function CreateEventPage() {
const [maxAttendees, setMaxAttendees] = useState(UNLIMITED_ATTENDEES);
const [waitlistEnabled, setWaitlistEnabled] = useState(false);
const [waitlistSize, setWaitlistSize] = useState(10);

const {
questions,
addQuestion,
Expand All @@ -70,15 +75,94 @@ export default function CreateEventPage() {
updateQuestionType,
updateAnswerOption,
addAnswerOption,
removeAnswerOption
removeAnswerOption,
} = useEventQuestions(defaultQuestions());

const [eventAdmins, setEventAdmins] = useState([]);
const [allOrgAdminsCanEdit, setAllOrgAdminsCanEdit] = 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 adminId = useMemo(() => (user?._id != null ? String(user._id) : ''), [user]);
const eventAdminIds = useMemo(
() => eventAdmins.map((admin) => String(admin._id)),
[eventAdmins],
);

useEffect(() => {
if (!adminId || allOrgAdminsCanEdit) 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, allOrgAdminsCanEdit]);

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('');
Expand All @@ -90,6 +174,10 @@ export default function CreateEventPage() {
setSubmitError('Could not resolve your user id.');
return;
}
if (!allOrgAdminsCanEdit && eventAdminIds.length === 0) {
setSubmitError('Please select at least one event admin, or allow all officers and administrators to edit.');
return;
}
if (visibility === 'private' && !minimumVisibleRole) {
setSubmitError('Please select a minimum visible role for private events.');
return;
Expand All @@ -110,7 +198,8 @@ export default function CreateEventPage() {
time,
location: location.trim(),
description: description.trim(),
admins: [adminId], // The event creator becomes the initial event admin in SCEvents
admins: allOrgAdminsCanEdit ? [] : eventAdminIds,
all_org_admins_can_edit: allOrgAdminsCanEdit,
registration_form: toApiRegistrationForm(questions),
max_attendees:
maxAttendees === UNLIMITED_ATTENDEES ? UNLIMITED_ATTENDEES : Number(maxAttendees),
Expand Down Expand Up @@ -202,6 +291,27 @@ export default function CreateEventPage() {
addAnswerOption,
removeAnswerOption,
}}
adminActions={{
eventAdmins,
userDisplayName,
adminSearch,
adminSearchResults,
adminSearchError,
adminSearching,
debounceRef,
handleAdminSearchChange,
performAdminSearch,
addEventAdmin,
removeEventAdmin,
isRemoveDisabledForAdmin: (admin) => String(admin._id) === adminId,
allOrgAdminsCanEdit,
setAllOrgAdminsCanEdit: (next) => {
setAllOrgAdminsCanEdit(next);
if (next) {
setEventAdmins([]);
}
},
}}
/>
);
}
Loading
Loading