diff --git a/api/main_endpoints/routes/User.js b/api/main_endpoints/routes/User.js index 928908360..603855ee6 100644 --- a/api/main_endpoints/routes/User.js +++ b/api/main_endpoints/routes/User.js @@ -29,6 +29,8 @@ const {sendUnsubscribeEmail} = require('../util/emailHelpers'); const crypto = require('crypto'); const ROWS_PER_PAGE = 20; +const ALLOWED_ROWS_PER_PAGE = [10, 20, 50]; +const ALL_ROWS = 'all'; const SENSITIVE_FIELDS = [ 'email', @@ -220,15 +222,34 @@ router.post('/users', async function(req, res) { }; const sortOrder = orderToInteger[req.query.order] || orderToInteger.default; + const total = await User.count(maybeOr); + + // 'all' collapses every match onto a single page. anything we don't + // recognize falls back to the default rather than erroring, so callers that + // don't send a size keep working unchanged. + const requestedRowsPerPage = req.body.rowsPerPage; + const showAllRows = requestedRowsPerPage === ALL_ROWS; + let rowsPerPage = ROWS_PER_PAGE; + if (showAllRows) { + rowsPerPage = total; + } else if (ALLOWED_ROWS_PER_PAGE.includes(Number(requestedRowsPerPage))) { + rowsPerPage = Number(requestedRowsPerPage); + } + + // mongo reads a limit of 0 as "no limit", which is what we want for 'all' + // and is also why we can't just pass rowsPerPage straight through + const limit = showAllRows ? 0 : rowsPerPage; + // make sure that the page we want to see is 0 by default // and avoid negative page numbers - let skip = Math.max(Number(req.body.page) || 0, 0); - skip *= ROWS_PER_PAGE; - const total = await User.count(maybeOr); - User.find(maybeOr, { password: 0, }, { skip, limit: ROWS_PER_PAGE, }) + const skip = showAllRows + ? 0 + : Math.max(Number(req.body.page) || 0, 0) * rowsPerPage; + + User.find(maybeOr, { password: 0, }, { skip, limit }) .sort({ [sortColumn] : sortOrder }) .then(items => { - res.status(OK).send({ items, total, rowsPerPage: ROWS_PER_PAGE, }); + res.status(OK).send({ items, total, rowsPerPage, }); }) .catch((e) => { res.sendStatus(BAD_REQUEST); @@ -368,6 +389,115 @@ router.post('/edit', async (req, res) => { } }); +// Change the accessLevel of many members at once +router.post('/bulkEdit', async (req, res) => { + const decoded = await decodeToken(req, membershipState.OFFICER); + if (decoded.status !== OK) { + return res.sendStatus(decoded.status); + } + + const { accessLevel: editorAccessLevel, _id: editorId } = decoded.token; + const { ids, accessLevel } = req.body; + const isEditorAdmin = editorAccessLevel === membershipState.ADMIN; + + if (!Array.isArray(ids) || ids.length === 0) { + return res + .status(BAD_REQUEST) + .send({ message: 'ids must be a non-empty array.' }); + } + + const validAccessLevels = Object.values(membershipState); + if (!validAccessLevels.includes(accessLevel)) { + return res + .status(BAD_REQUEST) + .send({ message: `${accessLevel} is not a valid access level.` }); + } + + // Only admins can hand out the admin role + if (accessLevel === membershipState.ADMIN && !isEditorAdmin) { + return res.sendStatus(UNAUTHORIZED); + } + + // Admins may re-role themselves, but an officer demoting themselves mid-bulk + // would lock them out of the page they're standing on + if (!isEditorAdmin && ids.some(id => String(id) === String(editorId))) { + return res + .status(FORBIDDEN) + .send({ message: 'Officers cannot change their own access level.' }); + } + + try { + const targetUsers = await User.find( + { _id: { $in: ids } }, + '_id email accessLevel' + ).lean(); + + if (targetUsers.length === 0) { + return res.status(NOT_FOUND).send({ message: 'No users found.' }); + } + + // An officer can't touch anyone ranked above them. /delete draws the same + // line; bulk selection makes it much easier to sweep up an admin by + // accident, so we skip those rather than fail the whole request. + const editable = []; + const skipped = []; + targetUsers.forEach(targetUser => { + if (isEditorAdmin || targetUser.accessLevel <= editorAccessLevel) { + editable.push(targetUser); + } else { + skipped.push({ _id: targetUser._id, email: targetUser.email }); + } + }); + + // Users already at the target level would otherwise produce audit log + // entries claiming a change that didn't happen + const changed = editable.filter( + targetUser => targetUser.accessLevel !== accessLevel + ); + + if (changed.length === 0) { + return res.status(OK).send({ + message: 'No changes submitted.', + modified: 0, + skipped, + }); + } + + const result = await User.updateMany( + { _id: { $in: changed.map(targetUser => targetUser._id) } }, + { accessLevel } + ); + + // one entry per user, shaped like the one /edit writes so the audit log + // page renders them the same way + changed.forEach(targetUser => { + const fieldChanges = { + accessLevel: { from: targetUser.accessLevel, to: accessLevel }, + }; + AuditLog.create({ + userId: editorId, + action: AuditLogActions.UPDATE_USER, + documentId: targetUser._id, + details: { + updatedInfo: JSON.stringify({ accessLevel }), + fieldChanges: JSON.stringify(fieldChanges), + }, + }).catch(logger.error); + }); + + return res.status(OK).send({ + message: `${changed.length} user(s) were updated.`, + modified: result.nModified, + skipped, + }); + } catch (error) { + logger.error('/bulkEdit had an error:', error); + return res + .status(BAD_REQUEST) + .send({ message: 'Bad Request: Unable to update users.' }); + } +}); + router.post('/getPagesPrintedCount', async (req, res) => { const decoded = await decodeToken(req); if (decoded.status !== OK) { diff --git a/src/APIFunctions/User.js b/src/APIFunctions/User.js index 23ac761f3..3c1a9842d 100644 --- a/src/APIFunctions/User.js +++ b/src/APIFunctions/User.js @@ -4,6 +4,8 @@ import { BASE_API_URL, membershipState, userFilterType } from '../Enums'; /** * Queries the database for all users. * @param {string} token The jwt token for verification + * @param {(number|'all'|null)} rowsPerPage How many users to return per page. + * Accepts 10, 20, 50, or 'all'; anything else falls back to the server default. * @returns {UserApiResponse} Containing any error information or the array of * users. */ @@ -14,6 +16,7 @@ export async function getAllUsers({ sortColumn = null, sortOrder = null, minRole = null, + rowsPerPage = null, }) { const url = new URL('/api/User/users', BASE_API_URL); @@ -37,6 +40,7 @@ export async function getAllUsers({ query, page, minRole, + rowsPerPage, }), }); if (res.ok) { @@ -168,6 +172,38 @@ export async function editUser(userToEdit, token) { return status; } +/** + * Change the access level of many users in one request. + * @param {string[]} ids The MongoDB ids of the users to update + * @param {number} accessLevel The membershipState value to apply to all of them + * @param {string} token The jwt token for authentication + * @returns {UserApiResponse} containing the number modified and any users that + * were skipped because the editor outranked them + */ +export async function bulkEditUsers(ids, accessLevel, token) { + let status = new UserApiResponse(); + const url = new URL('/api/User/bulkEdit', BASE_API_URL); + try { + const res = await fetch(url.href, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ ids, accessLevel }), + }); + if (res.ok) { + status.responseData = await res.json(); + } else { + status.error = true; + } + } catch (err) { + status.error = true; + status.responseData = err.message || err; + } + return status; +} + /** * Deletes a user by an ID * @param {string} _id The ID of the user to delete diff --git a/src/Components/DecisionModal/ConfirmationModal.js b/src/Components/DecisionModal/ConfirmationModal.js index 34d8a7dd2..e7df76482 100644 --- a/src/Components/DecisionModal/ConfirmationModal.js +++ b/src/Components/DecisionModal/ConfirmationModal.js @@ -5,14 +5,17 @@ export default function ConfirmationModal(props) { const confirmText = props.confirmText || 'Confirm'; const cancelText = props.cancelText || 'Cancel'; + // pages rendering more than one modal must pass distinct ids, otherwise + // getElementById below finds whichever one mounted first + const id = props.id || 'confirmation-modal'; useEffect(() => { if (open) { - document.getElementById('confirmation-modal').showModal(); + document.getElementById(id).showModal(); } }, [open]); return (<> - +

{headerText}

diff --git a/src/Pages/Overview/Overview.js b/src/Pages/Overview/Overview.js index fd5ca50eb..efe70af0a 100644 --- a/src/Pages/Overview/Overview.js +++ b/src/Pages/Overview/Overview.js @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; const svg = require('./SVG'); -import { getAllUsers, deleteUserByID, getNewPaidMembersThisSemester } from '../../APIFunctions/User'; +import { getAllUsers, deleteUserByID, getNewPaidMembersThisSemester, bulkEditUsers } from '../../APIFunctions/User'; import { formatFirstAndLastName } from '../../APIFunctions/Profile'; import { getAllUsersValidVerifiedAndSubscribed } from '../../APIFunctions/User'; // import { membershipState } from '../../Enums'; @@ -9,6 +9,18 @@ import ConfirmationModal from '../../Components/DecisionModal/ConfirmationModal.js'; const enums = require('../../Enums.js'); import { useSCE } from '../../Components/context/SceContext.js'; +import SelectDropdown from './SelectDropdown.js'; + +const ALL_ROWS = 'all'; +const DEFAULT_PAGE_SIZE = 20; +const PAGE_SIZE_OPTIONS = [10, DEFAULT_PAGE_SIZE, 50, ALL_ROWS].map(value => ({ + value, + label: value === ALL_ROWS ? 'All' : String(value), +})); +const ROLE_OPTIONS = Object.values(enums.membershipState).map(value => ({ + value, + label: enums.membershipStateToString(value), +})); export default function Overview() { const { user } = useSCE(); @@ -22,6 +34,14 @@ export default function Overview() { const [queryResult, setQueryResult] = useState([]); const [rowsPerPage, setRowsPerPage] = useState(0); const [query, setQuery] = useState(''); + // what the user picked in the dropdown. distinct from rowsPerPage, which is + // the size the server actually used -- under 'all' those two differ. + const [pageSizeChoice, setPageSizeChoice] = useState(DEFAULT_PAGE_SIZE); + const [selectedIds, setSelectedIds] = useState(new Set()); + const [bulkAccessLevel, setBulkAccessLevel] = useState( + enums.membershipState.MEMBER + ); + const [toggleBulkEdit, setToggleBulkEdit] = useState(false); const [currentSortColumn, setCurrentSortColumn] = useState('joinDate'); const [currentSortOrder, setCurrentSortOrder] = useState('desc'); const [clubRevenueData, setClubRevenueData] = useState({newMembersThisYear:0, newSingleSemesterMembers:0, newAnnualMembers:0, currentActiveMembers:0}); @@ -54,6 +74,13 @@ export default function Overview() { child => !child._id.includes(userToDel._id) ) ); + // the row is gone, so leaving it selected would let a bulk edit target a + // user who is no longer on screen + setSelectedIds(previouslySelected => { + const nextSelected = new Set(previouslySelected); + nextSelected.delete(userToDel._id); + return nextSelected; + }); } function mark(bool) { @@ -69,13 +96,17 @@ export default function Overview() { query: query, page: page, sortColumn: sortColumn, - sortOrder: sortOrder + sortOrder: sortOrder, + rowsPerPage: pageSizeChoice }); if (!apiResponse.error) { setUsers(apiResponse.responseData.items); setTotal(apiResponse.responseData.total); setRowsPerPage(apiResponse.responseData.rowsPerPage); } + // every refetch replaces the rendered rows, so any prior selection refers + // to users that are no longer on screen + setSelectedIds(new Set()); setLoading(false); } @@ -89,7 +120,7 @@ export default function Overview() { useEffect(() => { callDatabase(); getClubRevenueData(); - }, [page, currentSortColumn, currentSortOrder]); + }, [page, currentSortColumn, currentSortOrder, pageSizeChoice]); useEffect(() => { @@ -127,6 +158,52 @@ export default function Overview() { } } + function handlePageSizeChange(pageSize) { + setPageSizeChoice(pageSize); + // whatever page we were on probably doesn't exist at the new size + setPage(0); + } + + function toggleUserSelection(userId) { + setSelectedIds(previouslySelected => { + const nextSelected = new Set(previouslySelected); + if (nextSelected.has(userId)) { + nextSelected.delete(userId); + } else { + nextSelected.add(userId); + } + return nextSelected; + }); + } + + function toggleSelectAll() { + setSelectedIds(previouslySelected => + previouslySelected.size === users.length + ? new Set() + : new Set(users.map(userOnPage => userOnPage._id)) + ); + } + + async function bulkUpdateAccessLevel() { + const response = await bulkEditUsers( + [...selectedIds], + bulkAccessLevel, + user.token + ); + if (response.error) { + return alert('unable to update the selected users, check logs'); + } + const skipped = response.responseData.skipped || []; + if (skipped.length) { + alert( + `${skipped.length} user(s) were skipped because they outrank you: ` + + skipped.map(skippedUser => skippedUser.email).join(', ') + ); + } + // refetch so the table shows the new roles. this also clears the selection + callDatabase(); + } + function handleArrowVisibility(sortOrder, columnName) { if (currentSortOrder === sortOrder && currentSortColumn === columnName) return ''; @@ -215,6 +292,7 @@ export default function Overview() { return (

+ { + bulkUpdateAccessLevel(); + setToggleBulkEdit(!toggleBulkEdit); + }, + handleCancel: () => setToggleBulkEdit(!toggleBulkEdit), + open: toggleBulkEdit + } + } />
-
-