Skip to content
Open
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
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
12 changes: 10 additions & 2 deletions src/Pages/Events/CalendarView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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 &&
Expand Down
173 changes: 171 additions & 2 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 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';

Expand Down Expand Up @@ -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();
Expand All @@ -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()]);
Expand Down Expand Up @@ -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()) {
Expand All @@ -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;
}
Comment on lines +267 to +270
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm... should we have this? if we have no admins then ALL of Clark's admins are admins of this event...

does SCEvents handle this? if an events admins change to no admins then does SCEvents automatically know to allow all of Clark's admins to be an event admin?

that said this is a bit complicated + maybe not a useful feature so if you wanna ignore this then lmk

if (visibility === 'private' && !minimumVisibleRole) {
setSubmitError('Please select a minimum visible role for private events.');
return;
Expand All @@ -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),
Expand Down Expand Up @@ -471,6 +560,86 @@ export default function CreateEventPage() {
</select>
</label>
)}

<div>
<div className="label">
<span className="label-text">Event admins</span>
</div>

<div className="space-y-2">
{eventAdmins.map((admin) => (
<div
key={admin._id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-700"
>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">
{userDisplayName(admin)}
</p>
{admin.email && (
<p className="text-xs text-gray-500 dark:text-gray-400">{admin.email}</p>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={String(admin._id) === adminId}
onClick={() => removeEventAdmin(admin._id)}
>
Remove
</button>
</div>
))}
</div>

<div className="relative mt-4">
<input
type="text"
className="input input-bordered w-full"
value={adminSearch}
onChange={(e) => 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) && (
<div className="absolute z-10 mt-1 w-full max-h-48 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
{adminSearching && (
<div className="p-3 text-sm text-gray-500 dark:text-gray-400">Searching…</div>
)}
{!adminSearching && adminSearchError && (
<div className="p-3 text-sm text-red-600 dark:text-red-300">{adminSearchError}</div>
)}
{!adminSearching && adminSearchResults.map((admin) => (
<button
key={admin._id}
type="button"
className="flex w-full items-center justify-between p-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-100 last:border-b-0 dark:border-gray-700"
onClick={() => addEventAdmin(admin)}
>
<span>
<span className="block text-sm font-semibold text-gray-900 dark:text-white">
{userDisplayName(admin)}
</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
{admin.email}
</span>
</span>
<span className="text-sm font-medium text-blue-600 dark:text-blue-300">
Add
</span>
</button>
))}
</div>
)}
</div>
</div>
</div>

<h2 className="mb-3 text-xl font-semibold text-gray-900 dark:text-white">
Expand Down
Loading
Loading