diff --git a/services/web/app/src/Features/User/UserController.js b/services/web/app/src/Features/User/UserController.js index b767dcd4a1..24a2ba9119 100644 --- a/services/web/app/src/Features/User/UserController.js +++ b/services/web/app/src/Features/User/UserController.js @@ -506,6 +506,12 @@ async function expireDeletedUsersAfterDuration(req, res, next) { res.sendStatus(204) } +async function listAllUsers(req, res, next) { + const users = await UserGetter.promises.getAllUsers() + + res.json(users) +} + module.exports = { clearSessions: expressify(clearSessions), changePassword: expressify(changePassword), @@ -518,4 +524,5 @@ module.exports = { expireDeletedUsersAfterDuration: expressify(expireDeletedUsersAfterDuration), ensureAffiliationMiddleware: expressify(ensureAffiliationMiddleware), ensureAffiliation, + listAllUsers: expressify(listAllUsers), } diff --git a/services/web/app/src/Features/User/UserGetter.js b/services/web/app/src/Features/User/UserGetter.js index a5fbe42651..21f8f13e8f 100644 --- a/services/web/app/src/Features/User/UserGetter.js +++ b/services/web/app/src/Features/User/UserGetter.js @@ -150,6 +150,44 @@ async function getWritefullData(userId) { } } +getTotalProjectStorageForUser = async function (userId) { + const ProjectEntityHandler = require('../Project/ProjectEntityHandler') + const { Project } = require('../../models/Project') + const fs = require('fs') + const path = require('path') + + let totalsize = 0 + // only owned projects, not shared + const ownedProjects = await Project.find( + { owner_ref: userId }, + "_id" + ).exec() + + for (let i = 0; i < ownedProjects.length; i++) { + const project = ownedProjects[i] + const files = await ProjectEntityHandler.promises.getAllFiles(project._id) + + for (const [filePath, file] of Object.entries(files)) { + const f = path.join(settings.filestore.stores.user_files, project._id.toString() + '_' + file._id.toString()) + + const fstat = await fs.promises.stat(f) + const fsize = fstat.size + totalsize += fsize + } + } // foreach Project + return { count: ownedProjects.length, total: totalsize } // bytes +} + +function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let i = 0 + while (bytes >= 1024 && i < units.length - 1) { + bytes /= 1024 + i++ + } + return `${bytes.toFixed(2)} ${units[i]}` +} + const UserGetter = { getSsoUsersAtInstitution: callbackify(getSsoUsersAtInstitution), @@ -286,6 +324,43 @@ const UserGetter = { }) }, getWritefullData: callbackify(getWritefullData), + + getAllUsers(callback) { + const projection = { + _id: 1, + email: 1, + first_name: 1, + last_name: 1, + lastLoggedIn: 1, + signUpDate: 1, + loginCount: 1, + isAdmin: 1, + suspended: 1, + institution: 1, + } + + const query = { $or: [{ 'emails.email': { $exists: true } },], } + + db.users.find(query, {projection: projection}).toArray(async (err, users) => { + if (err) { + console.error('Error fetching users:', err) + return callback(err) + } + for (let i = 0; i < users.length; i++) { + const user = users[i] + user.signUpDateformatted = moment(user.signUpDate).format('DD/MM/YYYY') + user.lastLoggedInformatted = moment(user.lastLoggedIn).format('DD/MM/YYYY') + const ProjectsInfo = await getTotalProjectStorageForUser(user._id) + + user.projectsSize = ProjectsInfo.total + user.projectsSizeFormatted = formatBytes(ProjectsInfo.total) + user.projectsCount = ProjectsInfo.count + } + + callback(null, users) + }) + + } } const decorateFullEmails = ( diff --git a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug index 75cc065e73..28cbe30fd5 100644 --- a/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug +++ b/services/web/app/views/layout/navbar-marketing-bootstrap-5.pug @@ -66,6 +66,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg( if canDisplayAdminMenu +dropdown-menu-link-item(href='/admin') Manage Site +dropdown-menu-link-item(href='/admin/user') Manage Users + +dropdown-menu-link-item(href='/admin/users') #{translate('admin_panel')} +dropdown-menu-link-item(href='/admin/project') Project URL Lookup if canDisplayAdminRedirect +dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index bb26ff8d40..b920328fe0 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -70,6 +70,8 @@ nav.navbar.navbar-default.navbar-main( a(href='/admin') Manage Site li a(href='/admin/user') Manage Users + li + a(href='/admin/users') #{translate('admin_panel')} li a(href='/admin/project') Project URL Lookup if canDisplayAdminRedirect diff --git a/services/web/app/views/layout/navbar-website-redesign.pug b/services/web/app/views/layout/navbar-website-redesign.pug index 210cf3a120..c5e73ced8c 100644 --- a/services/web/app/views/layout/navbar-website-redesign.pug +++ b/services/web/app/views/layout/navbar-website-redesign.pug @@ -65,6 +65,8 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar a(href='/admin') Manage Site li a(href='/admin/user') Manage Users + li + a(href='/admin/users') #{translate('admin_panel')} li a(href='/admin/project') Project URL Lookup if canDisplayAdminRedirect diff --git a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx index fdc670423a..759e485ffe 100644 --- a/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx +++ b/services/web/frontend/js/features/ui/components/bootstrap-5/navbar/admin-menu.tsx @@ -39,6 +39,9 @@ export default function AdminMenu({ Manage Users + + Admin Panel + Project URL lookup diff --git a/services/web/modules/user-activate/app/src/UserActivateController.mjs b/services/web/modules/user-activate/app/src/UserActivateController.mjs index e8ce258ba7..3bf0191776 100644 --- a/services/web/modules/user-activate/app/src/UserActivateController.mjs +++ b/services/web/modules/user-activate/app/src/UserActivateController.mjs @@ -62,8 +62,88 @@ async function activateAccountPage(req, res, next) { }) } +// +async function listAllUsers(req, res, next) { + const users = await UserGetter.promises.getAllUsers() + + res.render(Path.resolve(__dirname, '../views/user/list'), { + title: 'Users list', + users, + currentUserId: req.user._id, + _csrf: req.csrfToken(), + }) +} + +import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js' +/* + * it is a modified copy of /overleaf/services/web/scripts/suspend_users.mjs + * @param {request} req + * @param {response} res + */ +async function suspendUser(req, res) { + const userId = req.params.userId + + try { + await UserUpdater.promises.suspendUser(userId, { + initiatorId: userId, + ip: req.ip, + info: { script: false }, + }) + } catch (error) { + console.log(`Failed to suspend ${userId}`, error) + } + + res.redirect('/admin/users') +} + +/* + * it is a modified copy of UserUpdater.suspendUser + * @param {request} req + * @param {response} res + */ +async function unsuspendUser(req, res) { + const userId = req.params.userId + const upd = await UserUpdater.promises.updateUser( + { _id: userId, suspended: { $ne: false } }, + { $set: { suspended: false } } + ) + if (upd.matchedCount !== 1) { + console.log('user id not found or already unsuspended') + } + + res.redirect('/admin/users') +} + +/* + * it is a modified copy of UserUpdater.suspendUser + * It is used to update user first and last name + * @param {request} req.body.userId + * @param {request} req.body.first_name + * @param {request} req.body.last_name + * @param {response} res + */ +async function updateUser(req, res) { + const { userId, first_name, last_name } = req.body; + const upd = await UserUpdater.promises.updateUser( + { _id: userId }, + { $set: { + first_name: first_name, + last_name: last_name, + } } + ) + if (upd.matchedCount !== 1) { + console.log(`user id not found ${userId}`) + } else { + res.json({ success: true }); + } +} + export default { registerNewUser, register: expressify(register), activateAccountPage: expressify(activateAccountPage), + listAllUsers: expressify(listAllUsers), + suspendUser: expressify(suspendUser), + unsuspendUser: expressify(unsuspendUser), + updateUser: expressify(updateUser), } diff --git a/services/web/modules/user-activate/app/src/UserActivateRouter.mjs b/services/web/modules/user-activate/app/src/UserActivateRouter.mjs index 402d05c562..e7cb84e218 100644 --- a/services/web/modules/user-activate/app/src/UserActivateRouter.mjs +++ b/services/web/modules/user-activate/app/src/UserActivateRouter.mjs @@ -26,5 +26,21 @@ export default { AuthorizationMiddleware.ensureUserIsSiteAdmin, UserActivateController.register ) + webRouter.get('/admin/users', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserActivateController.listAllUsers + ) + webRouter.post('/admin/users/:userId/suspend', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserActivateController.suspendUser + ) + webRouter.post('/admin/users/:userId/unsuspend', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserActivateController.unsuspendUser + ) + webRouter.post('/admin/users/settings', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserActivateController.updateUser + ) }, } diff --git a/services/web/modules/user-activate/app/views/user/list.pug b/services/web/modules/user-activate/app/views/user/list.pug new file mode 100644 index 0000000000..25be97cd35 --- /dev/null +++ b/services/web/modules/user-activate/app/views/user/list.pug @@ -0,0 +1,258 @@ +extends ../../../../../app/views/layout-react + +block append meta + meta(name="ol-usersBestSubscription" data-type="json" content=usersBestSubscription) + meta(name="ol-notifications" data-type="json" content=notifications) + meta(name="ol-notificationsInstitution" data-type="json" content=notificationsInstitution) + meta(name="ol-userEmails" data-type="json" content=userEmails) + meta(name="ol-allInReconfirmNotificationPeriods" data-type="json" content=allInReconfirmNotificationPeriods) + meta(name="ol-user" data-type="json" content=user) + meta(name="ol-userAffiliations" data-type="json" content=userAffiliations) + meta(name="ol-reconfirmedViaSAML" content=reconfirmedViaSAML) + meta(name="ol-survey" data-type="json" content=survey) + meta(name="ol-tags" data-type="json" content=tags) + meta(name="ol-portalTemplates" data-type="json" content=portalTemplates) + meta(name="ol-prefetchedProjectsBlob" data-type="json" content=prefetchedProjectsBlob) + if (suggestedLanguageSubdomainConfig) + meta(name="ol-suggestedLanguage" data-type="json" content=Object.assign(suggestedLanguageSubdomainConfig, { + lngName: translate(suggestedLanguageSubdomainConfig.lngCode), + imgUrl: buildImgPath("flags/24/" + suggestedLanguageSubdomainConfig.lngCode + ".png") + })) + meta(name="ol-currentUrl" data-type="string" content=currentUrl) + meta(name="ol-showGroupsAndEnterpriseBanner" data-type="boolean" content=showGroupsAndEnterpriseBanner) + meta(name="ol-groupsAndEnterpriseBannerVariant" data-type="string" content=groupsAndEnterpriseBannerVariant) + meta(name="ol-showInrGeoBanner" data-type="boolean" content=showInrGeoBanner) + meta(name="ol-showBrlGeoBanner" data-type="boolean" content=showBrlGeoBanner) + meta(name="ol-recommendedCurrency" data-type="string" content=recommendedCurrency) + meta(name="ol-showLATAMBanner" data-type="boolean" content=showLATAMBanner) + meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment) + meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) + meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess) + meta(name="ol-showUSGovBanner" data-type="boolean" content=showUSGovBanner) + meta(name="ol-usGovBannerVariant" data-type="string" content=usGovBannerVariant) + +block css + style. + .edit-icon { + display: none; + } + .user-name:hover .edit-icon { + display: inline-block; + } + .edit-save-icon, .edit-cancel-icon { + margin-left: 5px; + } + .form-control:focus { + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); /* Bootstrap-style focus */ + } + /* Ensure consistent width during editing */ + .user-name { + display: block; + width: 100%; + min-width: 200px; + } + /* Fixed width for name column */ + table td:nth-child(3), + table th:nth-child(3) { + width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + +block append meta + link(rel='stylesheet', href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css", id="main-stylesheet") + +block content + .content.content-alt#main-content + .container + .row + .col-sm-12 + .card + .card-body + .page-header + h1 #{translate('admin_panel')} + if users.length + table.table.table-striped + thead + tr + th #{translate('email')} + th #{translate('name')} + th #{translate('institution')} + th #{translate('sign_up')} #{translate('date')} + th #{translate('login_count')} + th #{translate('last_logged_in')} + th #{translate('projects_count')} + th #{translate('file_size')} + th #{translate('role')} + th #{translate('status')} + tbody + each user in users + tr + td #{user.email} + td + div.user-name( + data-user-id=user._id + data-original-name=(user.first_name || '') + ', ' + (user.last_name || '') + style="width: 200px; min-width: 200px;" + ) + span.name-text #{user.first_name || ''}, #{user.last_name || ''} + i.bi.bi-pencil.edit-icon( + title=translate('edit'), + style="cursor: pointer; margin-left: 5px;" + data-user-id=user._id + ) + td #{user.institution || ''} + td #{user.signUpDateformatted ? user.signUpDateformatted: ''} + td #{user.loginCount || ''} + td #{user.lastLoggedInformatted ? user.lastLoggedInformatted: ''} + td + span.me-2 #{user.projectsCount || 0} + td #{user.projectsSizeFormatted || 0} + td #{user.isAdmin ? translate('admin') : ''} + td + span.badge(class=(user.suspended ? 'bg-secondary' : 'bg-success')) + #{user.suspended ? translate('suspended') : translate('active')} + if user._id.toString() !== currentUserId + form.d-inline(method="POST", action=`/admin/users/${user._id}/${user.suspended ? 'unsuspend' : 'suspend'}`) + input(type="hidden", name="_csrf", value=csrfToken) + button.btn.btn-sm( + class=(user.suspended ? 'btn-danger' : 'btn-success'), + type="submit", + data-bs-toggle="tooltip", + title=(user.suspended ? translate('activate') : translate('suspend')), + ) #{user.suspended ? translate('suspended') : translate('active')} + else + p There are no registered users. + + +block head-scripts + script(type="text/javascript", nonce=scriptNonce). + // Enable editing functionality for user names + function enableEdit(icon) { + // Prevent editing multiple rows + if (currentlyEditingRow != null) { return; } + + const container = icon.parentElement; + const userId = icon.getAttribute("data-user-id"); + const originalName = container.getAttribute("data-original-name"); + [firstName, lastName] = originalName.split(",").map(str => str.trim()); + if (lastName === undefined) { lastName = ''; } + + // Save reference to current row being edited + currentlyEditingRow = container; + + // Replace content with editable inputs and buttons + container.innerHTML = ` + + + + + `; + + // Add Enter key listener to input fields + const firstInput = container.querySelector('[data-first-name]'); + const lastInput = container.querySelector('[data-last-name]'); + + // Function to trigger save on Enter or discard on Escape + const handleKeys = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Prevent form submission + saveChanges(container, userId); + } + + if (e.key === 'Escape') { + e.preventDefault(); // Prevent form submission + const container = currentlyEditingRow; + const userId = container.getAttribute("data-user-id"); + discardChanges(container.firstChild, userId); + } + }; + + // Attach event listener to both input fields + firstInput.addEventListener('keydown', handleKeys); + lastInput.addEventListener('keydown', handleKeys); + + firstInput.focus(); + } + + script(type="text/javascript", nonce=scriptNonce). + // Save changes made to the user name + function saveChanges(icon, userId) { + const container = icon.parentElement; + const firstName = container.querySelector('[data-first-name]').value; + const lastName = container.querySelector('[data-last-name]').value; + const csrfToken = document.querySelector('input[name="_csrf"]').value; + + fetch('/admin/users/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + userId: userId, + first_name: firstName, + last_name: lastName, + _csrf: csrfToken + }), + credentials: 'same-origin' + }) + .then(response => { + if (!response.ok) throw new Error('Error en la solicitud'); + return response.json(); + }) + .then(data => { + // Restore original view + icon.innerHTML = ` + ${firstName}, ${lastName} + + `; + icon.setAttribute("data-original-name", `${firstName}, ${lastName}`); + + // Clear editing reference + currentlyEditingRow = null; + }) + .catch(error => { + console.error('Error:', error); + }); + } + + script(type="text/javascript", nonce=scriptNonce). + function discardChanges(icon, userId) { + const container = icon.parentElement; + const originalName = container.getAttribute("data-original-name"); + + // Restore original view + container.innerHTML = ` + ${originalName} + + `; + + // Clear editing reference + currentlyEditingRow = null; + } + + script(type="text/javascript", nonce=scriptNonce). + document.addEventListener("DOMContentLoaded", function() { + currentlyEditingRow = null; + + document.addEventListener("click", (event) => { + if (event.target.classList.contains("edit-icon")) { + enableEdit(event.target); + } else if (event.target.classList.contains("edit-save-icon")) { + const userId = event.target.getAttribute("data-user-id"); + saveChanges(event.target, userId); + } else if (event.target.classList.contains("edit-cancel-icon")) { + const userId = event.target.getAttribute("data-user-id"); + discardChanges(event.target, userId); + } + }); + }) + + script(type="text/javascript", nonce=scriptNonce). + document.querySelectorAll(".edit-icon").forEach(icon => { + icon.addEventListener("click", () => enableEdit(icon)); + }) + +