Compare commits

...

2 commits

Author SHA1 Message Date
yu-i-i
09f47e75e0 Admin panel: improved naming for clarity 2025-06-24 02:19:31 +02:00
Kcho
565b3040d7 Add List of registered users to admins 2025-06-24 01:02:40 +02:00
9 changed files with 444 additions and 0 deletions

View file

@ -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),
}

View file

@ -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 = (

View file

@ -59,6 +59,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(class={
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

View file

@ -58,6 +58,8 @@ nav.navbar.navbar-default.navbar-main(class={
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

View file

@ -56,6 +56,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

View file

@ -39,6 +39,9 @@ export default function AdminMenu({
<NavDropdownLinkItem href="/admin/user">
Manage Users
</NavDropdownLinkItem>
<NavDropdownLinkItem href="/admin/users">
Admin Panel
</NavDropdownLinkItem>
<NavDropdownLinkItem href="/admin/project">
Project URL lookup
</NavDropdownLinkItem>

View file

@ -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),
}

View file

@ -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
)
},
}

View file

@ -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 = `
<input type="text" class="form-control form-control-sm d-inline-block w-auto me-1" value="${firstName}" data-first-name>
<input type="text" class="form-control form-control-sm d-inline-block w-auto me-1" value="${lastName}" data-last-name>
<i class="bi bi-save edit-save-icon" title="#{translate('save')}" style="cursor: pointer;" data-user-id="${userId}"></i>
<i class="bi bi-x-circle edit-cancel-icon" title="#{translate('cancel')}" style="cursor: pointer; margin-left: 5px;" data-user-id="${userId}"></i>
`;
// 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 = `
<span class="name-text">${firstName}, ${lastName}</span>
<i class="bi bi-pencil edit-icon" style="cursor: pointer; margin-left: 5px;" data-user-id="${userId}"></i>
`;
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 = `
<span class="name-text">${originalName}</span>
<i class="bi bi-pencil edit-icon" style="cursor: pointer; margin-left: 5px;" data-user-id="${userId}"></i>
`;
// 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));
})