mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2025-07-23 14:00:08 +02:00
Compare commits
2 commits
05d7dac34c
...
af43af93e4
Author | SHA1 | Date | |
---|---|---|---|
![]() |
af43af93e4 | ||
![]() |
448b727956 |
9 changed files with 444 additions and 0 deletions
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
258
services/web/modules/user-activate/app/views/user/list.pug
Normal file
258
services/web/modules/user-activate/app/views/user/list.pug
Normal 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));
|
||||
})
|
||||
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue