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));
+ })
+
+