From 448b727956f9cfeadbed57c68600d4c5eb1c19c2 Mon Sep 17 00:00:00 2001 From: Kcho Date: Mon, 23 Jun 2025 14:10:11 -0300 Subject: [PATCH 01/17] Add List of registered users to admins --- .../app/src/Features/User/UserController.js | 7 + .../web/app/src/Features/User/UserGetter.js | 75 +++++ .../layout/navbar-marketing-bootstrap-5.pug | 1 + .../web/app/views/layout/navbar-marketing.pug | 2 + .../views/layout/navbar-website-redesign.pug | 2 + .../bootstrap-5/navbar/admin-menu.tsx | 3 + .../app/src/UserActivateController.mjs | 80 ++++++ .../app/src/UserActivateRouter.mjs | 16 ++ .../user-activate/app/views/user/list.pug | 258 ++++++++++++++++++ 9 files changed, 444 insertions(+) create mode 100644 services/web/modules/user-activate/app/views/user/list.pug 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..cbff1d52b3 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") List Users +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..87a7647976 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') List Users 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..dd8f3e8359 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') List Users 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..1f06553c7b 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 + + List Users + 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..12ff4423c2 --- /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 Users list + 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('admin')} + 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('yes') : translate('no')} + 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-success' : 'btn-danger'), + 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)); + }) + + From af43af93e45f2b142aea2d95b4815a51b24b60e2 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 24 Jun 2025 02:19:31 +0200 Subject: [PATCH 02/17] Admin panel: improved naming for clarity --- .../web/app/views/layout/navbar-marketing-bootstrap-5.pug | 2 +- services/web/app/views/layout/navbar-marketing.pug | 2 +- services/web/app/views/layout/navbar-website-redesign.pug | 2 +- .../ui/components/bootstrap-5/navbar/admin-menu.tsx | 2 +- .../web/modules/user-activate/app/views/user/list.pug | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) 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 cbff1d52b3..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,7 +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") List 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 87a7647976..b920328fe0 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -71,7 +71,7 @@ nav.navbar.navbar-default.navbar-main( li a(href='/admin/user') Manage Users li - a(href='/admin/users') List Users + 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 dd8f3e8359..c5e73ced8c 100644 --- a/services/web/app/views/layout/navbar-website-redesign.pug +++ b/services/web/app/views/layout/navbar-website-redesign.pug @@ -66,7 +66,7 @@ nav.navbar.navbar-default.navbar-main.website-redesign-navbar li a(href='/admin/user') Manage Users li - a(href='/admin/users') List Users + 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 1f06553c7b..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 @@ -40,7 +40,7 @@ export default function AdminMenu({ Manage Users - List Users + Admin Panel Project URL lookup diff --git a/services/web/modules/user-activate/app/views/user/list.pug b/services/web/modules/user-activate/app/views/user/list.pug index 12ff4423c2..25be97cd35 100644 --- a/services/web/modules/user-activate/app/views/user/list.pug +++ b/services/web/modules/user-activate/app/views/user/list.pug @@ -71,7 +71,7 @@ block content .card .card-body .page-header - h1 Users list + h1 #{translate('admin_panel')} if users.length table.table.table-striped thead @@ -84,7 +84,7 @@ block content th #{translate('last_logged_in')} th #{translate('projects_count')} th #{translate('file_size')} - th #{translate('admin')} + th #{translate('role')} th #{translate('status')} tbody each user in users @@ -109,7 +109,7 @@ block content td span.me-2 #{user.projectsCount || 0} td #{user.projectsSizeFormatted || 0} - td #{user.isAdmin ? translate('yes') : translate('no')} + td #{user.isAdmin ? translate('admin') : ''} td span.badge(class=(user.suspended ? 'bg-secondary' : 'bg-success')) #{user.suspended ? translate('suspended') : translate('active')} @@ -117,7 +117,7 @@ block content 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-success' : 'btn-danger'), + class=(user.suspended ? 'btn-danger' : 'btn-success'), type="submit", data-bs-toggle="tooltip", title=(user.suspended ? translate('activate') : translate('suspend')), From 7ecee2e0aa6005190116c5e9633ddf2be15383b3 Mon Sep 17 00:00:00 2001 From: Christopher Hoskin <4855578+mans0954@users.noreply.github.com> Date: Mon, 21 Jul 2025 09:01:53 +0100 Subject: [PATCH 03/17] Merge pull request #27255 from overleaf/revert-27252-revert-26843-csh-issue-26608-mongo8-dev-ci Revert "Revert "Upgrade the dev environment and CI to mongo 8"" GitOrigin-RevId: 5074b012504e65240017f1fde9b0d8d04c7b8b61 --- server-ce/test/docker-compose.yml | 2 +- services/chat/docker-compose.ci.yml | 2 +- services/chat/docker-compose.yml | 2 +- services/contacts/docker-compose.ci.yml | 2 +- services/contacts/docker-compose.yml | 2 +- services/docstore/docker-compose.ci.yml | 2 +- services/docstore/docker-compose.yml | 2 +- services/document-updater/docker-compose.ci.yml | 2 +- services/document-updater/docker-compose.yml | 2 +- services/history-v1/docker-compose.ci.yml | 2 +- services/history-v1/docker-compose.yml | 2 +- services/notifications/docker-compose.ci.yml | 2 +- services/notifications/docker-compose.yml | 2 +- services/project-history/docker-compose.ci.yml | 2 +- services/project-history/docker-compose.yml | 2 +- services/web/docker-compose.ci.yml | 2 +- services/web/docker-compose.yml | 2 +- 17 files changed, 17 insertions(+), 17 deletions(-) diff --git a/server-ce/test/docker-compose.yml b/server-ce/test/docker-compose.yml index 029b73fc62..1652baeae9 100644 --- a/server-ce/test/docker-compose.yml +++ b/server-ce/test/docker-compose.yml @@ -35,7 +35,7 @@ services: MAILTRAP_PASSWORD: 'password-for-mailtrap' mongo: - image: mongo:6.0 + image: mongo:8.0.11 command: '--replSet overleaf' volumes: - ../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.ci.yml b/services/chat/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/chat/docker-compose.ci.yml +++ b/services/chat/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/chat/docker-compose.yml b/services/chat/docker-compose.yml index ddc5f9e698..e7b8ce7385 100644 --- a/services/chat/docker-compose.yml +++ b/services/chat/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.ci.yml b/services/contacts/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/contacts/docker-compose.ci.yml +++ b/services/contacts/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/contacts/docker-compose.yml b/services/contacts/docker-compose.yml index 6c77ef5e31..474ea224f8 100644 --- a/services/contacts/docker-compose.yml +++ b/services/contacts/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.ci.yml b/services/docstore/docker-compose.ci.yml index 40decc4aea..cdb4783c5a 100644 --- a/services/docstore/docker-compose.ci.yml +++ b/services/docstore/docker-compose.ci.yml @@ -47,7 +47,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/docstore/docker-compose.yml b/services/docstore/docker-compose.yml index 8c11eb5a91..a9099c7e7b 100644 --- a/services/docstore/docker-compose.yml +++ b/services/docstore/docker-compose.yml @@ -49,7 +49,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.ci.yml b/services/document-updater/docker-compose.ci.yml index ca15f35fef..c6ec24a84b 100644 --- a/services/document-updater/docker-compose.ci.yml +++ b/services/document-updater/docker-compose.ci.yml @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/document-updater/docker-compose.yml b/services/document-updater/docker-compose.yml index cf7c9a2eb6..c1b23c11c5 100644 --- a/services/document-updater/docker-compose.yml +++ b/services/document-updater/docker-compose.yml @@ -57,7 +57,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/history-v1/docker-compose.ci.yml b/services/history-v1/docker-compose.ci.yml index da664d6b30..cf6ec3357d 100644 --- a/services/history-v1/docker-compose.ci.yml +++ b/services/history-v1/docker-compose.ci.yml @@ -75,7 +75,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/history-v1/docker-compose.yml b/services/history-v1/docker-compose.yml index 22b739abf9..3a33882d28 100644 --- a/services/history-v1/docker-compose.yml +++ b/services/history-v1/docker-compose.yml @@ -83,7 +83,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.ci.yml b/services/notifications/docker-compose.ci.yml index 24b57ab084..ca3303a079 100644 --- a/services/notifications/docker-compose.ci.yml +++ b/services/notifications/docker-compose.ci.yml @@ -42,7 +42,7 @@ services: command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . user: root mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/notifications/docker-compose.yml b/services/notifications/docker-compose.yml index 081bbfa002..e43e9aeef5 100644 --- a/services/notifications/docker-compose.yml +++ b/services/notifications/docker-compose.yml @@ -44,7 +44,7 @@ services: command: npm run --silent test:acceptance mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.ci.yml b/services/project-history/docker-compose.ci.yml index ca15f35fef..c6ec24a84b 100644 --- a/services/project-history/docker-compose.ci.yml +++ b/services/project-history/docker-compose.ci.yml @@ -55,7 +55,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/project-history/docker-compose.yml b/services/project-history/docker-compose.yml index eeca03de6e..dd3c6468fe 100644 --- a/services/project-history/docker-compose.yml +++ b/services/project-history/docker-compose.yml @@ -57,7 +57,7 @@ services: retries: 20 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js diff --git a/services/web/docker-compose.ci.yml b/services/web/docker-compose.ci.yml index 33b5a3ca2e..8376103315 100644 --- a/services/web/docker-compose.ci.yml +++ b/services/web/docker-compose.ci.yml @@ -95,7 +95,7 @@ services: image: redis:7.4.3 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 logging: driver: none command: --replSet overleaf diff --git a/services/web/docker-compose.yml b/services/web/docker-compose.yml index 069c1e77de..e0a4a064c5 100644 --- a/services/web/docker-compose.yml +++ b/services/web/docker-compose.yml @@ -91,7 +91,7 @@ services: image: redis:7.4.3 mongo: - image: mongo:7.0.20 + image: mongo:8.0.11 command: --replSet overleaf volumes: - ../../bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js From 5d79cf18c0b880e39a2679b58c66721c93bf25c5 Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 17 Jul 2025 14:25:48 +0100 Subject: [PATCH 04/17] Define all initial roles GitOrigin-RevId: ad613bad4d8a47e327281e90b5475e989a3ccec4 --- services/web/types/admin-capabilities.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/services/web/types/admin-capabilities.ts b/services/web/types/admin-capabilities.ts index 7d87c77a15..0c98d7df04 100644 --- a/services/web/types/admin-capabilities.ts +++ b/services/web/types/admin-capabilities.ts @@ -1,3 +1,10 @@ export type AdminCapability = 'modify-user-email' | 'view-project' -export type AdminRole = 'engineering' +export type AdminRole = + | 'engagement' + | 'engineering' + | 'finance' + | 'product' + | 'sales' + | 'support' + | 'support_tier_1' From 868d562d96ba768b96bd2d0ec10591646a992fce Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 21 Jul 2025 11:53:05 +0200 Subject: [PATCH 05/17] Support password-fallbackPassword array in requireBasicAuth (#27237) GitOrigin-RevId: 33b15a05996bfa0190041f347772867a9667e2ca --- .../AuthenticationController.js | 17 +- .../AuthenticationControllerTests.js | 327 ++++++++++++++++++ 2 files changed, 343 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.js b/services/web/app/src/Features/Authentication/AuthenticationController.js index 7a97d2ac9c..99c418df1b 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.js +++ b/services/web/app/src/Features/Authentication/AuthenticationController.js @@ -36,7 +36,22 @@ function send401WithChallenge(res) { function checkCredentials(userDetailsMap, user, password) { const expectedPassword = userDetailsMap.get(user) const userExists = userDetailsMap.has(user) && expectedPassword // user exists with a non-null password - const isValid = userExists && tsscmp(expectedPassword, password) + + let isValid = false + if (userExists) { + if (Array.isArray(expectedPassword)) { + const isValidPrimary = Boolean( + expectedPassword[0] && tsscmp(expectedPassword[0], password) + ) + const isValidFallback = Boolean( + expectedPassword[1] && tsscmp(expectedPassword[1], password) + ) + isValid = isValidPrimary || isValidFallback + } else { + isValid = tsscmp(expectedPassword, password) + } + } + if (!isValid) { logger.err({ user }, 'invalid login details') } diff --git a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js index 0e4f675b1b..1fa3aba6a6 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js +++ b/services/web/test/unit/src/Authentication/AuthenticationControllerTests.js @@ -1500,4 +1500,331 @@ describe('AuthenticationController', function () { }) }) }) + + describe('checkCredentials', function () { + beforeEach(function () { + this.userDetailsMap = new Map() + this.logger.err = sinon.stub() + this.Metrics.inc = sinon.stub() + }) + + describe('with valid credentials', function () { + describe('single password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'correctpassword' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + + describe('array with primary password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'primary' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + + describe('array with fallback password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'fallback' + ) + }) + + it('should return true', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + + it('should record success metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'pass', + } + ) + }) + }) + }) + + describe('with invalid credentials', function () { + describe('unknown user', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'unknownuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'unknownuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + + describe('wrong password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', 'correctpassword') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'wrongpassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'fail', + } + ) + }) + }) + + describe('wrong password with array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'wrongpassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'known-user', + status: 'fail', + } + ) + }) + }) + + describe('null user entry', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', null) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics for unknown user', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + + describe('empty primary password in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['', 'fallback']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'fallback' + ) + }) + + it('should return true with fallback password', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + }) + + describe('empty fallback password in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['primary', '']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'primary' + ) + }) + + it('should return true with primary password', function () { + this.result.should.equal(true) + }) + + it('should not log an error', function () { + this.logger.err.called.should.equal(false) + }) + }) + + describe('both passwords empty in array', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', ['', '']) + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + }) + + describe('empty single password', function () { + beforeEach(function () { + this.userDetailsMap.set('testuser', '') + this.result = this.AuthenticationController.checkCredentials( + this.userDetailsMap, + 'testuser', + 'anypassword' + ) + }) + + it('should return false', function () { + this.result.should.equal(false) + }) + + it('should log an error', function () { + this.logger.err.should.have.been.calledWith( + { user: 'testuser' }, + 'invalid login details' + ) + }) + + it('should record failure metrics for unknown user', function () { + this.Metrics.inc.should.have.been.calledWith( + 'security.http-auth.check-credentials', + 1, + { + path: 'unknown-user', + status: 'fail', + } + ) + }) + }) + }) + }) }) From d5b5710d018dea1f3ba5e84fd4869af18c99c756 Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Mon, 21 Jul 2025 11:53:48 +0200 Subject: [PATCH 06/17] Add docModified hook in ds-mobile-app module (#27196) * Add docModified hook in ds-mobile-app module * use Object.entries when iterating over promises * avoid project lookup * update tests GitOrigin-RevId: 88676746f56558a97ce31010b57f5eeb254fefef --- .../Features/Documents/DocumentController.mjs | 4 ++++ .../web/app/src/infrastructure/Modules.js | 3 +-- .../src/Documents/DocumentController.test.mjs | 21 +++++++++++++++++++ services/web/types/web-module.ts | 5 ++++- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/services/web/app/src/Features/Documents/DocumentController.mjs b/services/web/app/src/Features/Documents/DocumentController.mjs index 6998c0b36a..9a16811894 100644 --- a/services/web/app/src/Features/Documents/DocumentController.mjs +++ b/services/web/app/src/Features/Documents/DocumentController.mjs @@ -7,6 +7,7 @@ import logger from '@overleaf/logger' import _ from 'lodash' import { plainTextResponse } from '../../infrastructure/Response.js' import { expressify } from '@overleaf/promise-utils' +import Modules from '../../infrastructure/Modules.js' async function getDocument(req, res) { const { Project_id: projectId, doc_id: docId } = req.params @@ -92,6 +93,9 @@ async function setDocument(req, res) { { docId, projectId }, 'finished receiving set document request from api (docupdater)' ) + + await Modules.promises.hooks.fire('docModified', projectId, docId) + res.json(result) } diff --git a/services/web/app/src/infrastructure/Modules.js b/services/web/app/src/infrastructure/Modules.js index 20975a3642..aea3aeb087 100644 --- a/services/web/app/src/infrastructure/Modules.js +++ b/services/web/app/src/infrastructure/Modules.js @@ -150,8 +150,7 @@ async function linkedFileAgentsIncludes() { async function attachHooks() { for (const module of await modules()) { const { promises, ...hooks } = module.hooks || {} - for (const hook in promises || {}) { - const method = promises[hook] + for (const [hook, method] of Object.entries(promises || {})) { attachHook(hook, method) } for (const hook in hooks || {}) { diff --git a/services/web/test/unit/src/Documents/DocumentController.test.mjs b/services/web/test/unit/src/Documents/DocumentController.test.mjs index e3fe3bdec2..b683cc5d14 100644 --- a/services/web/test/unit/src/Documents/DocumentController.test.mjs +++ b/services/web/test/unit/src/Documents/DocumentController.test.mjs @@ -87,6 +87,14 @@ describe('DocumentController', function () { }, } + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves(), + }, + }, + } + vi.doMock('../../../../app/src/Features/Project/ProjectGetter', () => ({ default: ctx.ProjectGetter, })) @@ -113,6 +121,10 @@ describe('DocumentController', function () { default: ctx.ChatApiHandler, })) + vi.doMock('../../../../app/src/infrastructure/Modules.js', () => ({ + default: ctx.Modules, + })) + ctx.DocumentController = (await import(MODULE_PATH)).default }) @@ -208,6 +220,15 @@ describe('DocumentController', function () { it('should return a successful response', function (ctx) { ctx.res.success.should.equal(true) }) + + it('should call the docModified hook', function (ctx) { + sinon.assert.calledWith( + ctx.Modules.promises.hooks.fire, + 'docModified', + ctx.project._id, + ctx.doc._id + ) + }) }) describe("when the document doesn't exist", function () { diff --git a/services/web/types/web-module.ts b/services/web/types/web-module.ts index 298f430df2..f6b59cdf6f 100644 --- a/services/web/types/web-module.ts +++ b/services/web/types/web-module.ts @@ -53,7 +53,10 @@ export type WebModule = { apply: (webRouter: any, privateApiRouter: any, publicApiRouter: any) => void } hooks?: { - [name: string]: (args: any[]) => void + promises?: { + [name: string]: (...args: any[]) => Promise + } + [name: string]: ((...args: any[]) => void) | any } middleware?: { [name: string]: RequestHandler From 0778bab9103c1441ba4101319d08e65490d7b2d5 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Mon, 21 Jul 2025 11:50:29 +0100 Subject: [PATCH 07/17] Merge pull request #27254 from overleaf/td-project-dashboard-cookie-banner Implement React cookie banner on project dashboard GitOrigin-RevId: 95d2778d7ce7cb3054a06b06486b815a3453a623 --- services/web/app/views/_cookie_banner.pug | 8 +-- .../web/app/views/general/post-gateway.pug | 2 +- services/web/app/views/layout-marketing.pug | 2 +- services/web/app/views/layout-react.pug | 2 +- .../web/app/views/layout-website-redesign.pug | 2 +- .../project/editor/new_from_template.pug | 2 +- .../app/views/project/ide-react-detached.pug | 2 +- services/web/app/views/project/list-react.pug | 1 + .../app/views/project/token/access-react.pug | 2 +- .../views/project/token/sharing-updates.pug | 2 +- .../web/frontend/extracted-translations.json | 4 ++ .../js/features/cookie-banner/index.js | 53 ----------------- .../js/features/cookie-banner/index.ts | 32 ++++++++++ .../js/features/cookie-banner/utils.ts | 43 ++++++++++++++ .../components/project-list-ds-nav.tsx | 2 + .../components/project-list-root.tsx | 10 +++- .../js/shared/components/cookie-banner.tsx | 58 +++++++++++++++++++ .../pages/project-list-ds-nav.scss | 18 +++++- services/web/locales/en.json | 4 ++ services/web/types/window.ts | 1 + 20 files changed, 181 insertions(+), 69 deletions(-) delete mode 100644 services/web/frontend/js/features/cookie-banner/index.js create mode 100644 services/web/frontend/js/features/cookie-banner/index.ts create mode 100644 services/web/frontend/js/features/cookie-banner/utils.ts create mode 100644 services/web/frontend/js/shared/components/cookie-banner.tsx diff --git a/services/web/app/views/_cookie_banner.pug b/services/web/app/views/_cookie_banner.pug index 56974326cd..7cbc569bc1 100644 --- a/services/web/app/views/_cookie_banner.pug +++ b/services/web/app/views/_cookie_banner.pug @@ -1,13 +1,13 @@ -section.cookie-banner.hidden-print.hidden(aria-label='Cookie banner') - .cookie-banner-content We only use cookies for essential purposes and to improve your experience on our site. You can find out more in our cookie policy. +section.cookie-banner.hidden-print.hidden(aria-label=translate('cookie_banner')) + .cookie-banner-content !{translate('cookie_banner_info', {}, [{ name: 'a', attrs: { href: '/legal#Cookies' }}])} .cookie-banner-actions button( type='button' class='btn btn-link btn-sm' data-ol-cookie-banner-set-consent='essential' - ) Essential cookies only + ) #{translate('essential_cookies_only')} button( type='button' class='btn btn-primary btn-sm' data-ol-cookie-banner-set-consent='all' - ) Accept all cookies + ) #{translate('accept_all_cookies')} diff --git a/services/web/app/views/general/post-gateway.pug b/services/web/app/views/general/post-gateway.pug index c6bbc92d01..86f379ac1b 100644 --- a/services/web/app/views/general/post-gateway.pug +++ b/services/web/app/views/general/post-gateway.pug @@ -4,7 +4,7 @@ block vars - var suppressNavbar = true - var suppressFooter = true - var suppressSkipToContent = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true block content .content.content-alt diff --git a/services/web/app/views/layout-marketing.pug b/services/web/app/views/layout-marketing.pug index b54c30f033..26e4eb539d 100644 --- a/services/web/app/views/layout-marketing.pug +++ b/services/web/app/views/layout-marketing.pug @@ -24,7 +24,7 @@ block body else include layout/fat-footer - if typeof suppressCookieBanner == 'undefined' + if typeof suppressPugCookieBanner == 'undefined' include _cookie_banner if bootstrapVersion === 5 diff --git a/services/web/app/views/layout-react.pug b/services/web/app/views/layout-react.pug index 94ff3ba247..e9c4c932c4 100644 --- a/services/web/app/views/layout-react.pug +++ b/services/web/app/views/layout-react.pug @@ -69,5 +69,5 @@ block body else include layout/fat-footer-react-bootstrap-5 - if typeof suppressCookieBanner === 'undefined' + if typeof suppressPugCookieBanner === 'undefined' include _cookie_banner diff --git a/services/web/app/views/layout-website-redesign.pug b/services/web/app/views/layout-website-redesign.pug index 61ed83043b..aa7fea9f07 100644 --- a/services/web/app/views/layout-website-redesign.pug +++ b/services/web/app/views/layout-website-redesign.pug @@ -27,7 +27,7 @@ block body else include layout/fat-footer-website-redesign - if typeof suppressCookieBanner == 'undefined' + if typeof suppressPugCookieBanner == 'undefined' include _cookie_banner block contactModal diff --git a/services/web/app/views/project/editor/new_from_template.pug b/services/web/app/views/project/editor/new_from_template.pug index c84288a21a..a5dc3ff33c 100644 --- a/services/web/app/views/project/editor/new_from_template.pug +++ b/services/web/app/views/project/editor/new_from_template.pug @@ -2,7 +2,7 @@ extends ../../layout-marketing block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block content diff --git a/services/web/app/views/project/ide-react-detached.pug b/services/web/app/views/project/ide-react-detached.pug index ca1a178bbf..fa695b1af5 100644 --- a/services/web/app/views/project/ide-react-detached.pug +++ b/services/web/app/views/project/ide-react-detached.pug @@ -7,7 +7,7 @@ block vars - var suppressNavbar = true - var suppressFooter = true - var suppressSkipToContent = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - metadata.robotsNoindexNofollow = true block content diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index 78103e75a6..47bff344b6 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -7,6 +7,7 @@ block vars - const suppressNavContentLinks = true - const suppressNavbar = true - const suppressFooter = true + - const suppressPugCookieBanner = true block append meta meta( diff --git a/services/web/app/views/project/token/access-react.pug b/services/web/app/views/project/token/access-react.pug index 80b91f1a99..6c01ad15b1 100644 --- a/services/web/app/views/project/token/access-react.pug +++ b/services/web/app/views/project/token/access-react.pug @@ -5,7 +5,7 @@ block entrypointVar block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block append meta diff --git a/services/web/app/views/project/token/sharing-updates.pug b/services/web/app/views/project/token/sharing-updates.pug index d1818be0af..2f67e5a3c1 100644 --- a/services/web/app/views/project/token/sharing-updates.pug +++ b/services/web/app/views/project/token/sharing-updates.pug @@ -5,7 +5,7 @@ block entrypointVar block vars - var suppressFooter = true - - var suppressCookieBanner = true + - var suppressPugCookieBanner = true - var suppressSkipToContent = true block append meta diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ef2a9c6a2c..2775c04601 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -35,6 +35,7 @@ "about_to_remove_user_preamble": "", "about_to_trash_projects": "", "abstract": "", + "accept_all_cookies": "", "accept_and_continue": "", "accept_change": "", "accept_change_error_description": "", @@ -332,6 +333,8 @@ "continue_to": "", "continue_using_free_features": "", "continue_with_free_plan": "", + "cookie_banner": "", + "cookie_banner_info": "", "copied": "", "copy": "", "copy_code": "", @@ -544,6 +547,7 @@ "error_opening_document_detail": "", "error_performing_request": "", "error_processing_file": "", + "essential_cookies_only": "", "example_project": "", "existing_plan_active_until_term_end": "", "expand": "", diff --git a/services/web/frontend/js/features/cookie-banner/index.js b/services/web/frontend/js/features/cookie-banner/index.js deleted file mode 100644 index 3d9b2b8d6c..0000000000 --- a/services/web/frontend/js/features/cookie-banner/index.js +++ /dev/null @@ -1,53 +0,0 @@ -import getMeta from '@/utils/meta' - -function loadGA() { - if (window.olLoadGA) { - window.olLoadGA() - } -} - -function setConsent(value) { - document.querySelector('.cookie-banner').classList.add('hidden') - const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain - const oneYearInSeconds = 60 * 60 * 24 * 365 - const cookieAttributes = - '; path=/' + - '; domain=' + - cookieDomain + - '; max-age=' + - oneYearInSeconds + - '; SameSite=Lax; Secure' - if (value === 'all') { - document.cookie = 'oa=1' + cookieAttributes - loadGA() - window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true })) - } else { - document.cookie = 'oa=0' + cookieAttributes - window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false })) - } -} - -if ( - getMeta('ol-ExposedSettings').gaToken || - getMeta('ol-ExposedSettings').gaTokenV4 || - getMeta('ol-ExposedSettings').propensityId || - getMeta('ol-ExposedSettings').hotjarId -) { - document - .querySelectorAll('[data-ol-cookie-banner-set-consent]') - .forEach(el => { - el.addEventListener('click', function (e) { - e.preventDefault() - const consentType = el.getAttribute('data-ol-cookie-banner-set-consent') - setConsent(consentType) - }) - }) - - const oaCookie = document.cookie.split('; ').find(c => c.startsWith('oa=')) - if (!oaCookie) { - const cookieBannerEl = document.querySelector('.cookie-banner') - if (cookieBannerEl) { - cookieBannerEl.classList.remove('hidden') - } - } -} diff --git a/services/web/frontend/js/features/cookie-banner/index.ts b/services/web/frontend/js/features/cookie-banner/index.ts new file mode 100644 index 0000000000..2ea97e875a --- /dev/null +++ b/services/web/frontend/js/features/cookie-banner/index.ts @@ -0,0 +1,32 @@ +import { + CookieConsentValue, + cookieBannerRequired, + hasMadeCookieChoice, + setConsent, +} from '@/features/cookie-banner/utils' + +function toggleCookieBanner(hidden: boolean) { + const cookieBannerEl = document.querySelector('.cookie-banner') + if (cookieBannerEl) { + cookieBannerEl.classList.toggle('hidden', hidden) + } +} + +if (cookieBannerRequired()) { + document + .querySelectorAll('[data-ol-cookie-banner-set-consent]') + .forEach(el => { + el.addEventListener('click', function (e) { + e.preventDefault() + toggleCookieBanner(true) + const consentType = el.getAttribute( + 'data-ol-cookie-banner-set-consent' + ) as CookieConsentValue | null + setConsent(consentType) + }) + }) + + if (!hasMadeCookieChoice()) { + toggleCookieBanner(false) + } +} diff --git a/services/web/frontend/js/features/cookie-banner/utils.ts b/services/web/frontend/js/features/cookie-banner/utils.ts new file mode 100644 index 0000000000..5c045d4e71 --- /dev/null +++ b/services/web/frontend/js/features/cookie-banner/utils.ts @@ -0,0 +1,43 @@ +import getMeta from '@/utils/meta' + +export type CookieConsentValue = 'all' | 'essential' + +function loadGA() { + if (window.olLoadGA) { + window.olLoadGA() + } +} + +export function setConsent(value: CookieConsentValue | null) { + const cookieDomain = getMeta('ol-ExposedSettings').cookieDomain + const oneYearInSeconds = 60 * 60 * 24 * 365 + const cookieAttributes = + '; path=/' + + '; domain=' + + cookieDomain + + '; max-age=' + + oneYearInSeconds + + '; SameSite=Lax; Secure' + if (value === 'all') { + document.cookie = 'oa=1' + cookieAttributes + loadGA() + window.dispatchEvent(new CustomEvent('cookie-consent', { detail: true })) + } else { + document.cookie = 'oa=0' + cookieAttributes + window.dispatchEvent(new CustomEvent('cookie-consent', { detail: false })) + } +} + +export function cookieBannerRequired() { + const exposedSettings = getMeta('ol-ExposedSettings') + return Boolean( + exposedSettings.gaToken || + exposedSettings.gaTokenV4 || + exposedSettings.propensityId || + exposedSettings.hotjarId + ) +} + +export function hasMadeCookieChoice() { + return document.cookie.split('; ').some(c => c.startsWith('oa=')) +} diff --git a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx index 3d24f9845c..07319ffaf1 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx @@ -20,6 +20,7 @@ import Footer from '@/features/ui/components/bootstrap-5/footer/footer' import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav' import SystemMessages from '@/shared/components/system-messages' import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import CookieBanner from '@/shared/components/cookie-banner' export function ProjectListDsNav() { const navbarProps = getMeta('ol-navbar') @@ -125,6 +126,7 @@ export function ProjectListDsNav() {