From e967ba486da33a8ce3723185f6e8b5bfe5a92417 Mon Sep 17 00:00:00 2001 From: Musicminion Date: Fri, 6 Feb 2026 12:24:53 +0000 Subject: [PATCH 1/8] feat: add user info update for admin panel - add `features` for UserApi - add `reset password` and `Features` for user info update --- .../app/src/UserListController.mjs | 53 +++ .../components/modals/update-user-modal.tsx | 327 +++++++++++++++--- .../frontend/stylesheets/user-list.scss | 5 +- .../modules/admin-tools/types/user/api.d.ts | 4 + 4 files changed, 343 insertions(+), 46 deletions(-) diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs index 6a3f88d1be..947db65c22 100644 --- a/services/web/modules/admin-tools/app/src/UserListController.mjs +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -21,7 +21,9 @@ import OwnershipTransferHandler from '../../../../app/src/Features/Collaborators import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs' import Errors, { OError } from '../../../../app/src/Features/Errors/Errors.js' +import HaveIBeenPwned from '../../../../app/src/Features/Authentication/HaveIBeenPwned.mjs' import { db } from '../../../../app/src/infrastructure/mongodb.mjs' +import AuthenticationManager from '../../../../app/src/Features/Authentication/AuthenticationManager.mjs' const __dirname = Path.dirname(fileURLToPath(import.meta.url)) @@ -212,6 +214,8 @@ async function _getUsers( samlIdentifiers: 1, thirdPartyIdentifiers: 1, suspended: 1, + 'features.collaborators': 1, + 'features.compileTimeout': 1, } const projectionDeleted = {}; for (const key of Object.keys(projection)) { @@ -313,6 +317,10 @@ function _formatUserInfo(user, maxDate) { authMethods, allowUpdateDetails, allowUpdateIsAdmin, + features: user.features && { + collaborators: user.features.collaborators, + compileTimeout: user.features.compileTimeout, + }, ...(user.suspended && { suspended: user.suspended }), inactive: !user.lastActive || user.lastActive < maxDate, ...(user.deletedAt && { deletedAt: user.deletedAt }), @@ -543,6 +551,46 @@ async function updateUser(req, res, next) { for (let [key, value] of Object.entries(updatesInput)) { if (key === 'email') continue + // Update features if needed + if (key === 'features' && value && typeof value === 'object') { + const features = updatesInput.features + if (features && typeof features === 'object') { + if ('collaborators' in features && (!Number.isInteger(features.collaborators) || features.collaborators < -1)) { + return HttpErrorHandler.unprocessableEntity(req, res, 'invalid_collaborators') + } + if ('compileTimeout' in features && (!Number.isInteger(features.compileTimeout) || features.compileTimeout <= 0)) { + return HttpErrorHandler.unprocessableEntity(req, res, 'invalid_compile_timeout') + } + } + update.features = { + ...(user.features ?? {}), + ...value, + } + continue + } + // Update password if needed + if (key === 'password') { + // ignore empty password updates + if (value == null || value === '') { + continue + } + // validate password + if (typeof value !== 'string' || value.length < 8) { + return HttpErrorHandler.unprocessableEntity(req, res, 'Password must be at least 8 characters long') + } + let isPasswordReused + try { + isPasswordReused = await HaveIBeenPwned.promises.isPasswordReused(value) + } catch (err) { + logger.warn({ err }, 'Failed to check password against HaveIBeenPwned') + } + if (isPasswordReused) { + return HttpErrorHandler.unprocessableEntity(req, res, 'Password is too common, please choose a different one') + } + const hashedPassword = await AuthenticationManager.promises.hashPassword(value) + update.hashedPassword = hashedPassword + continue + } const newValue = typeof value === 'string' ? value.trim() : value if (newValue === user[key]) continue @@ -575,6 +623,11 @@ async function updateUser(req, res, next) { delete update.last_name } + // delete password from response for security reasons + if (update.password) { + delete update.password + } + return res.json(update) } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx index 4d53480f31..73ae0f2261 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx @@ -2,70 +2,52 @@ import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import getMeta from '@/utils/meta' import UsersActionModal from './users-action-modal' -import UsersList from './users-list' -import Notification from '@/shared/components/notification' -import OLForm from '@/shared/components/ol/ol-form' +import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' import OLFormLabel from '@/shared/components/ol/ol-form-label' import OLFormControl from '@/shared/components/ol/ol-form-control' import OLFormGroup from '@/shared/components/ol/ol-form-group' import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' -import OLButton from '@/shared/components/ol/ol-button' import OLRow from '@/shared/components/ol/ol-row' import OLCol from '@/shared/components/ol/ol-col' import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus' +import { getAdditionalUserInfo } from '../../util/api' +import MaterialIcon from '@/shared/components/material-icon' + type UpdateUserModalProps = Pick< React.ComponentProps, 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' > -const pickUserFields = ({ firstName, lastName, email, isAdmin }) => ({ firstName, lastName, email, isAdmin }) +const pickUserFields = ({ id, firstName, lastName, email, isAdmin, features }) => ({ + id, firstName, lastName, email, isAdmin, + features: { + collaborators: features?.collaborators, + compileTimeout: features?.compileTimeout, + } +}) -function UpdateUserModal({ - users, - actionHandler, - showModal, - handleCloseModal, -}: UpdateUserModalProps) { +function UserBasicInfoTab({ userData, handleTextChange, handleCheckboxChange, allowUpdateDetails, allowUpdateIsAdmin, isSelf }) { const { t } = useTranslation() - const { autoFocusedRef } = useRefWithAutoFocus() - if (users.length !== 1) return null - const [userData, setUserData] = useState(pickUserFields(users[0])) - const isSelf = getMeta('ol-user_id') === users[0].id - const allowUpdateDetails = users[0].allowUpdateDetails - const allowUpdateIsAdmin = users[0].allowUpdateIsAdmin - - useEffect(() => { - if (showModal) { - setUserData(pickUserFields(users[0])) - } - }, [showModal, users]) - - const handleTextChange = (e: React.ChangeEvent) => { - const { name, value } = e.currentTarget - setUserData(prev => ({ ...prev, [name]: value })) - } - - const handleCheckboxChange = (e: React.ChangeEvent) => { - const { name, checked } = e.currentTarget - setUserData(prev => ({ ...prev, [name]: checked })) - } - return ( - + <> + + {t('ID')} + + {t('email_address')} + + ) +} + +function UserPasswordTab({ + userData, + handleTextChange, + activationLink, + onGeneratePassword, +}: { + userData: any + handleTextChange: (e: React.ChangeEvent) => void + activationLink: string | null + onGeneratePassword: () => void +}) { + const { t } = useTranslation() + + return ( + <> +

You can also manually set a new password for the user.

+ + {t('new_password')} + + + + } + /> + +
+

+ You can also manually send them URLs below to allow them to reset their + password and log in for the first time. +
+ The password reset link or randomly generated password is below: +

+
+ {activationLink} + + + +
+ + ) +} + +function UserFeaturesTab({ + userData, + handleFeatureNumChange, + autoFocusedRef, +}: { + userData: any + handleFeatureNumChange: (e: React.ChangeEvent) => void + autoFocusedRef: React.Ref +}) { + return ( + <> + + Compile Timeout (In second, no more than 300s) + + + + Collaborator limit (use -1 for unlimited) + + + + ) +} + +function UpdateUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: UpdateUserModalProps) { + const { t } = useTranslation() + const { autoFocusedRef } = useRefWithAutoFocus() + const [activeTab, setActiveTab] = useState('basic-info') + + if (users.length !== 1) return null + const [userData, setUserData] = useState(pickUserFields(users[0])) + const isSelf = getMeta('ol-user_id') === users[0].id + const allowUpdateDetails = users[0].allowUpdateDetails + const allowUpdateIsAdmin = users[0].allowUpdateIsAdmin + const [activationLink, setActivationLink] = useState(null) + + useEffect(() => { + if (showModal) { + setUserData(pickUserFields(users[0])) + setActiveTab('basic-info') + + getAdditionalUserInfo(users[0].id) + .then(({ activationLink }) => { + setActivationLink(activationLink) + }) + .catch(() => { + setActivationLink(null) + }) + } + }, [showModal, users]) + + const handleTextChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: value })) + } + + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { name, checked } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: checked })) + } + + const handleFeatureNumChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget + const numberValue = parseInt(value, 10) + if (!isNaN(numberValue)) { + setUserData(prev => ({ + ...prev, + features: { + ...prev.features, + [name]: numberValue, + }, + })) + } + } + + const generatePassword = () => { + const PASSWORD_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ0123456789' + const length = 12 + const values = new Uint32Array(length) + window.crypto.getRandomValues(values) + + let password = '' + for (let i = 0; i < length; i++) { + password += PASSWORD_CHARS[values[i] % PASSWORD_CHARS.length] + } + setUserData(prev => ({ ...prev, password })) + setActivationLink(password) + } + + return ( + + ) } diff --git a/services/web/modules/admin-tools/frontend/stylesheets/user-list.scss b/services/web/modules/admin-tools/frontend/stylesheets/user-list.scss index 4babc4c2c0..8d599bf1ef 100644 --- a/services/web/modules/admin-tools/frontend/stylesheets/user-list.scss +++ b/services/web/modules/admin-tools/frontend/stylesheets/user-list.scss @@ -610,5 +610,6 @@ form.user-search { } } - - \ No newline at end of file +.tab-content { + margin-top: var(--spacing-05) !important; +} \ No newline at end of file diff --git a/services/web/modules/admin-tools/types/user/api.d.ts b/services/web/modules/admin-tools/types/user/api.d.ts index 46abb9bedb..b20bc64fda 100644 --- a/services/web/modules/admin-tools/types/user/api.d.ts +++ b/services/web/modules/admin-tools/types/user/api.d.ts @@ -47,6 +47,10 @@ export type UserApi = { inactive: boolean deleted?: boolean deletedAt?: Date + features?: { + collaborators?: number + compileTimeout?: number + } } export type User = MergeAndOverride< From 929cd5f04ce9b45076b53ae3284636cccb4315aa Mon Sep 17 00:00:00 2001 From: Musicminion Date: Fri, 6 Feb 2026 12:42:12 +0000 Subject: [PATCH 2/8] fix: some security problems by Codex --- .../web/modules/admin-tools/app/src/UserListController.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs index 947db65c22..e88b02ef02 100644 --- a/services/web/modules/admin-tools/app/src/UserListController.mjs +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -580,7 +580,7 @@ async function updateUser(req, res, next) { } let isPasswordReused try { - isPasswordReused = await HaveIBeenPwned.promises.isPasswordReused(value) + isPasswordReused = await HaveIBeenPwned.promises.checkPasswordForReuse(value) } catch (err) { logger.warn({ err }, 'Failed to check password against HaveIBeenPwned') } @@ -627,7 +627,9 @@ async function updateUser(req, res, next) { if (update.password) { delete update.password } - + if (update.hashedPassword) { + delete update.hashedPassword + } return res.json(update) } From 1496065092c2f1cbfbf8812d46ced54d758fa6a8 Mon Sep 17 00:00:00 2001 From: Musicminion Date: Sat, 7 Feb 2026 12:37:57 +0000 Subject: [PATCH 3/8] fix: remove show all in admin panel when user/project nums are too large --- .../js/project-list/components/load-more.tsx | 17 ++++++++++------- .../js/user-list/components/load-more.tsx | 17 ++++++++++------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx index 3843a89a19..ce3850f148 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx @@ -34,13 +34,16 @@ export default function LoadMore() { n: visibleProjects.length + hiddenProjectsCount, })} {' '} - showAllProjects()} - className="btn-inline-link" - > - {t('show_all_projects')} - + { + hiddenProjectsCount <= 500 && + showAllProjects()} + className="btn-inline-link" + > + {t('show_all_projects')} + + } ) : ( diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx index 8d47109704..ec66001511 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx @@ -35,13 +35,16 @@ export default function LoadMore() { n: visibleUsers.length + hiddenUsersCount, })} {' '} - showAllUsers()} - className="btn-inline-link" - > - {t('show_all_users')} - + { + hiddenUsersCount <= 200 && + showAllUsers()} + className="btn-inline-link" + > + {t('view_all')} + + } ) : ( From 0bd0a024856178aa0df06aa5c26454aea69280df Mon Sep 17 00:00:00 2001 From: Musicminion Date: Mon, 9 Feb 2026 04:05:28 +0000 Subject: [PATCH 4/8] feat: remote search - add search router and frontend function - limit frontend users to 1000(normal)+1000(del) - fix i18n translations --- .../admin-tools/app/src/AdminToolsRouter.mjs | 4 ++ .../app/src/UserListController.mjs | 54 ++++++++++++++++++- .../components/table/user-list-table.tsx | 2 +- .../user-list/context/user-list-context.tsx | 38 ++++++++----- .../frontend/js/user-list/util/api.ts | 4 ++ 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs index 39e6605a17..b6f905d13d 100644 --- a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs +++ b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs @@ -32,6 +32,10 @@ export default { AuthorizationMiddleware.ensureUserIsSiteAdmin, UserListController.getUsersJson ) + webRouter.post('/admin/users/search', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.getUsersJsonBySearch + ) webRouter.post('/admin/user/:userId/delete', AuthorizationMiddleware.ensureUserIsSiteAdmin, UserListController.deleteUser diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs index e88b02ef02..0e18fdfcd5 100644 --- a/services/web/modules/admin-tools/app/src/UserListController.mjs +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -223,10 +223,14 @@ async function _getUsers( } projectionDeleted.deletedAt = '$deleterData.deletedAt' - const activeUsers = await UserGetter.promises.getUsers({}, projection) + const activeUsers = await User.find({}, projection) + .limit(1000) + .lean() + const deletedUsers = await DeletedUser.aggregate([ { $match: { user: { $type: 'object' } } }, { $project: projectionDeleted }, + { $limit: 1000 }, ]) const allUsers = [...activeUsers, ...deletedUsers] @@ -241,6 +245,42 @@ async function _getUsers( } } +async function _searchUsers(searchTerm) { + const projection = { + _id: 1, + email: 1, + first_name: 1, + last_name: 1, + lastActive: 1, + lastLoggedIn: 1, + signUpDate: 1, + loginCount: 1, + isAdmin: 1, + hashedPassword: 1, + samlIdentifiers: 1, + thirdPartyIdentifiers: 1, + suspended: 1, + 'features.collaborators': 1, + 'features.compileTimeout': 1, + } + + const activeUsers = await User.find({ + $or: [ + { email: { $regex: searchTerm, $options: 'i' } }, + { first_name: { $regex: searchTerm, $options: 'i' } }, + { last_name: { $regex: searchTerm, $options: 'i' } }, + ] + }, projection) + .limit(1000) + .lean() + + const formattedUsers = _formatUsers(activeUsers) + return { + totalSize: formattedUsers.length, + users: formattedUsers, + } +} + function _formatUsers(users) { const formattedUsers = [] const yearAgo = new Date() @@ -658,9 +698,21 @@ async function getAdditionalUserInfo(req, res, next) { res.json({ activationLink }) } +async function getUsersJsonBySearch(req, res) { + const { search } = req.body + if (typeof search !== 'string' || search.trim() === '') { + return HttpErrorHandler.unprocessableEntity(req, res, 'Search term is empty') + } + + const usersPage = await _searchUsers(search.trim()) + res.json(usersPage) +} + + export default { manageUsersPage: expressify(manageUsersPage), getUsersJson: expressify(getUsersJson), + getUsersJsonBySearch: expressify(getUsersJsonBySearch), getAdditionalUserInfo: expressify(getAdditionalUserInfo), registerNewUser: expressify(registerNewUser), activateAccountPage: expressify(activateAccountPage), diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx index 3df582f676..c307eec86a 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx @@ -190,7 +190,7 @@ function UserListTable() { ) : ( - {t('no_users')} + {t('no_search_results')} )} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx index e64a74db14..8f6dfda715 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx @@ -20,7 +20,7 @@ import { User, Sort, } from '../../../../types/user/api' -import { getUsers } from '../util/api' +import { getUsers, searchUsers } from '../util/api' import sortUsers from '../util/sort-users' import { UserIdentityProvider } from './user-identity-context' @@ -131,6 +131,7 @@ export function UserListProvider({ children }: UserListProviderProps) { ) const [searchText, setSearchText] = useState('') + const [searchResults, setSearchResults] = useState(null) const { isLoading: loading, @@ -157,26 +158,35 @@ export function UserListProvider({ children }: UserListProviderProps) { }) }, [prefetchedUsersBlob, runAsync]) + useEffect(() => { + if (!searchText.length) { + setSearchResults(null) + return + } + + const timer = setTimeout(() => { + searchUsers(searchText) + .then(data => { + setSearchResults(data.users) + }) + .catch(error => { + debugConsole.error('Error searching users:', error) + setSearchResults(null) + }) + }, 300) // 300ms debounce + + return () => clearTimeout(timer) + }, [searchText]) + const addUserToView = useCallback((newUser: Partial) => { setLoadedUsers(prev => [newUser as User, ...prev]) }, []) const processedUsers = useMemo(() => { - let users = loadedUsers - - if (searchText.length) { - const searchTextLowerCase = searchText.toLowerCase() - users = users.filter(user => - user.email?.toLowerCase().includes(searchTextLowerCase) || - user.firstName?.toLowerCase().includes(searchTextLowerCase) || - user.lastName?.toLowerCase().includes(searchTextLowerCase) - ) - } - + let users = searchResults !== null ? searchResults : loadedUsers users = arrayFilter(users, filters[filter]) - return sortUsers(users, sort) - }, [loadedUsers, searchText, filter, sort]) + }, [searchResults, loadedUsers, filter, sort]) const visibleUsers = useMemo(() => { return processedUsers.slice(0, maxVisibleUsers) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts index 033ae3a06e..fabedc07f1 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts @@ -5,6 +5,10 @@ export function getUsers(sortBy: Sort): Promise { return postJSON('/admin/users', { body: { sort: sortBy } }) } +export function searchUsers(search: string): Promise { + return postJSON('/admin/users/search', { body: { search } }) +} + export function updateUser(userId: string, userData: Partial) { return postJSON(`/admin/user/${userId}/update`, { body: userData }) } From 4eae6a4ee8a398313783e8864fa92913310036e1 Mon Sep 17 00:00:00 2001 From: Musicminion Date: Mon, 9 Feb 2026 05:41:23 +0000 Subject: [PATCH 5/8] feat: remote search for project - add search router and frontend function for project search - limit frontend projects to 1000(normal)+1000(del) --- .../admin-tools/app/src/AdminToolsRouter.mjs | 4 + .../app/src/ProjectListController.mjs | 111 ++++++++++++++++-- .../context/project-list-context.tsx | 35 ++++-- .../frontend/js/project-list/util/api.ts | 4 + 4 files changed, 133 insertions(+), 21 deletions(-) diff --git a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs index b6f905d13d..4afe0d92e8 100644 --- a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs +++ b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs @@ -36,6 +36,10 @@ export default { AuthorizationMiddleware.ensureUserIsSiteAdmin, UserListController.getUsersJsonBySearch ) + webRouter.post('/admin/projects/search', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.getProjectsJsonBySearch + ) webRouter.post('/admin/user/:userId/delete', AuthorizationMiddleware.ensureUserIsSiteAdmin, UserListController.deleteUser diff --git a/services/web/modules/admin-tools/app/src/ProjectListController.mjs b/services/web/modules/admin-tools/app/src/ProjectListController.mjs index 3d84457dd2..8b6121ebdd 100644 --- a/services/web/modules/admin-tools/app/src/ProjectListController.mjs +++ b/services/web/modules/admin-tools/app/src/ProjectListController.mjs @@ -4,17 +4,12 @@ import { fileURLToPath } from 'node:url' import { expressify } from '@overleaf/promise-utils' import logger from '@overleaf/logger' import Metrics from '@overleaf/metrics' +import mongoose from 'mongoose' import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.mjs' -import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' -import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs' -import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' -import UserGetter from '../../../../app/src/Features/User/UserGetter.mjs' import { OError } from '../../../../app/src/Features/Errors/Errors.js' -import { User } from '../../../../app/src/models/User.mjs' import { Project } from '../../../../app/src/models/Project.mjs' import { DeletedProject } from '../../../../app/src/models/DeletedProject.mjs' import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs' -import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.mjs' const __dirname = Path.dirname(fileURLToPath(import.meta.url)) @@ -64,7 +59,7 @@ async function _getProjects( const actualProjects = await Project.find( userId == null ? {} : { owner_ref: userId }, projection, - ).lean().exec() + ).limit(1000).lean().exec() const delProjection = Object.fromEntries( Object.keys(projection).map(k => [`project.${k}`, 1]) @@ -75,7 +70,7 @@ async function _getProjects( const deletedProjects = await DeletedProject.find( userId == null ? { project: { $type: 'object' } } : { 'project.owner_ref': userId }, delProjection - ).lean().exec() + ).limit(1000).lean().exec() const formattedActualProjects = _formatProjects(actualProjects, _formatProjectInfo) const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo) @@ -89,6 +84,98 @@ async function _getProjects( } } +async function _searchProjects( + userId = null, + search = '', +) { + const projection = { + _id: 1, + name: 1, + lastUpdated: 1, + lastUpdatedBy: 1, + lastOpened: 1, + trashed: 1, + owner_ref: 1, + } + const userIdObj = userId ? new mongoose.Types.ObjectId(userId) : null + + const activeProjects = await Project.aggregate([ + { + $lookup: { + from: 'users', + localField: 'owner_ref', + foreignField: '_id', + as: 'owner' + } + }, + { $unwind: { path: '$owner', preserveNullAndEmptyArrays: true } }, + { + $match: { + $and: [ + { + $or: [ + { name: { $regex: search, $options: 'i' } }, + { $expr: { $regexMatch: { input: { $toString: '$_id' }, regex: search, options: 'i' } } }, + { 'owner.email': { $regex: search, $options: 'i' } }, + { 'owner.first_name': { $regex: search, $options: 'i' } }, + { 'owner.last_name': { $regex: search, $options: 'i' } } + ] + }, + ...(userIdObj ? [{ owner_ref: userIdObj }] : []) + ] + } + }, + { $project: projection }, + { $limit: 1000 }, + ]).exec() + + const delProjection = Object.fromEntries( + Object.keys(projection).map(k => [`project.${k}`, 1]) + ) + delProjection['deleterData.deletedAt'] = 1 + delProjection['deleterData.deleterId'] = 1 + + const deletedProjects = await DeletedProject.aggregate([ + { $match: { project: { $type: 'object' } } }, + { + $lookup: { + from: 'users', + localField: 'project.owner_ref', + foreignField: '_id', + as: 'owner' + } + }, + { $unwind: { path: '$owner', preserveNullAndEmptyArrays: true } }, + { + $match: { + $and: [ + { + $or: [ + { 'project.name': { $regex: search, $options: 'i' } }, + { $expr: { $regexMatch: { input: { $toString: '$project._id' }, regex: search, options: 'i' } } }, + { 'owner.email': { $regex: search, $options: 'i' } }, + { 'owner.first_name': { $regex: search, $options: 'i' } }, + { 'owner.last_name': { $regex: search, $options: 'i' } } + ], + }, + ...(userIdObj ? [{ 'project.owner_ref': userIdObj }] : [{ project: { $type: 'object' } }]) + ] + } + }, + { $project: delProjection }, + { $limit: 1000 }, + ]).exec() + + const formattedActiveProjects = _formatProjects(activeProjects, _formatProjectInfo) + const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo) + const formattedProjects = [...formattedActiveProjects, ...formattedDeletedProjects] + + return { + totalSize: formattedProjects.length, + projects: formattedProjects, + } +} + function _formatProjects(projects, formatProjectInfo) { const yearAgo = new Date() yearAgo.setFullYear(yearAgo.getFullYear() - 1) @@ -229,9 +316,17 @@ async function purgeDeletedProject(req, res) { res.sendStatus(200) } +async function getProjectsJsonBySearch(req, res) { + const { search } = req.body + const { userId } = req.body + const projects = await _searchProjects(userId, search) + res.json(projects) +} + export default { manageProjectsPage: expressify(manageProjectsPage), getProjectsJson: expressify(getProjectsJson), + getProjectsJsonBySearch: expressify(getProjectsJsonBySearch), undeleteProject: expressify(undeleteProject), purgeDeletedProject: expressify(purgeDeletedProject), trashProjectForUser: expressify(trashProjectForUser), diff --git a/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx index 1d4823b390..8ff1bdb592 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx +++ b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx @@ -17,7 +17,7 @@ import { Project, Sort, } from '../../../../types/project/api' -import { getProjects } from '../util/api' +import { getProjects, searchProjects } from '../util/api' import { useUserIdentityContext } from '../../user-list/context/user-identity-context' import sortProjects from '../util/sort-projects' @@ -114,6 +114,7 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr const prevSortRef = useRef(sort) const [searchText, setSearchText] = useState('') + const [searchResults, setSearchResults] = useState(null) const { isLoading: loading, @@ -143,6 +144,22 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr }) }, [projectsOwnerId, runAsync, prefetchedProjectsBlob]) + useEffect(() => { + if (searchText.length === 0) { + setSearchResults(null) + return + } + + const timer = setTimeout(() => { + searchProjects(searchText, projectsOwnerId) + .then(data => { + setSearchResults(data.projects) + }) + .catch(debugConsole.error) + }, 500) + return () => clearTimeout(timer) + }, [searchText]) + const sortedProjects = useMemo(() => { if (prevSortRef.current === sort) return loadedProjects @@ -152,18 +169,10 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr }, [loadedProjects, sort, getUserById]) const filteredProjects = useMemo(() => { - let result = sortedProjects - - if (searchText.length) { - const lower = searchText.toLowerCase() - result = result.filter(project => - project.name.toLowerCase().includes(lower) || - (lower.length >= 6 && project.id.toString().toLowerCase().includes(lower)) - ) - } - - return result.filter(filters[filter]) - }, [sortedProjects, searchText, filter]) + let result = searchResults !== null ? searchResults : sortedProjects + result = result.filter(filters[filter]) + return sortProjects(result, sort, getUserById) + }, [sortedProjects, searchResults, filter]) const visibleProjects = useMemo(() => { return filteredProjects.slice(0, maxVisibleProjects) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts index 3d862a4933..00067ad1f5 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts @@ -19,6 +19,10 @@ export function getProjects( return postJSON(`/admin/user/${userId}/projects`, { body: { sort } }) } +export function searchProjects(search: string, userId?: string): Promise { + return postJSON('/admin/projects/search', { body: { search, userId } }) +} + export function deleteProject(projectId: string) { return deleteJSON(`/project/${projectId}`) } From d6a6eb482449aefac8b098cad751522b4f08a6f2 Mon Sep 17 00:00:00 2001 From: Musicminion Date: Mon, 9 Feb 2026 05:41:55 +0000 Subject: [PATCH 6/8] feat: add id search for userlist (remote) --- .../app/src/UserListController.mjs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs index 0e18fdfcd5..7cc2aaf5e3 100644 --- a/services/web/modules/admin-tools/app/src/UserListController.mjs +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -269,12 +269,32 @@ async function _searchUsers(searchTerm) { { email: { $regex: searchTerm, $options: 'i' } }, { first_name: { $regex: searchTerm, $options: 'i' } }, { last_name: { $regex: searchTerm, $options: 'i' } }, + { $expr: { $regexMatch: { input: { $toString: '$_id' }, regex: searchTerm, options: 'i' } } } ] }, projection) .limit(1000) .lean() - const formattedUsers = _formatUsers(activeUsers) + const projectionDeleted = {}; + for (const key of Object.keys(projection)) { + projectionDeleted[key] = `$user.${key}` + } + projectionDeleted.deletedAt = '$deleterData.deletedAt' + + const deletedUsers = await DeletedUser.aggregate([ + { $match: { user: { $type: 'object' }, $or: [ + { 'user.email': { $regex: searchTerm, $options: 'i' } }, + { 'user.first_name': { $regex: searchTerm, $options: 'i' } }, + { 'user.last_name': { $regex: searchTerm, $options: 'i' } }, + { $expr: { $regexMatch: { input: { $toString: '$user._id' }, regex: searchTerm, options: 'i' } } } + ] } }, + { $project: projectionDeleted }, + { $limit: 1000 }, + ]) + + let allUsers = [...activeUsers, ...deletedUsers] + const formattedUsers = _formatUsers(allUsers) + return { totalSize: formattedUsers.length, users: formattedUsers, From 9a7a729bed85f217d52edb017b9928f092e051b4 Mon Sep 17 00:00:00 2001 From: Musicminion Date: Mon, 9 Feb 2026 06:13:31 +0000 Subject: [PATCH 7/8] fix: update user selection logic to use processed users --- .../frontend/js/user-list/context/user-list-context.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx index 8f6dfda715..1b5480fe94 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx @@ -238,8 +238,8 @@ export function UserListProvider({ children }: UserListProviderProps) { ) const selectedUsers = useMemo(() => { - return loadedUsers.filter(user => selectedUserIds.has(user.id)) - }, [selectedUserIds, loadedUsers]) + return processedUsers.filter(user => selectedUserIds.has(user.id)) + }, [selectedUserIds, processedUsers]) const selectOrUnselectAllUsers = useCallback( (checked: any) => { From c1fb661966c55cb8830faaf330ee46852e782b2c Mon Sep 17 00:00:00 2001 From: Musicminion Date: Mon, 9 Feb 2026 06:30:02 +0000 Subject: [PATCH 8/8] fix: race condition in search input (user/project) The debounced effect writes setSearchResults(data.users) for every completed request without checking whether that response matches the latest query, so a slower response for an older term can overwrite newer results when typing quickly. This causes stale/incorrect user lists and can lead admins to act on the wrong result set unless requests are cancelled or versioned. --- .../context/project-list-context.tsx | 18 ++++++++++++++---- .../frontend/js/project-list/util/api.ts | 4 ++-- .../js/user-list/context/user-list-context.tsx | 13 ++++++++++--- .../frontend/js/user-list/util/api.ts | 4 ++-- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx index 8ff1bdb592..295df1b27a 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx +++ b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx @@ -149,15 +149,25 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr setSearchResults(null) return } - + const abortController = new AbortController() const timer = setTimeout(() => { - searchProjects(searchText, projectsOwnerId) + searchProjects(searchText, projectsOwnerId, abortController.signal) .then(data => { setSearchResults(data.projects) }) - .catch(debugConsole.error) + .catch(error => { + if (error.name === 'AbortError') { + debugConsole.log('Search aborted') + return + } + debugConsole.error('Error searching projects:', error) + setSearchResults(null) + }) }, 500) - return () => clearTimeout(timer) + return () => { + clearTimeout(timer) + abortController.abort() + } }, [searchText]) const sortedProjects = useMemo(() => { diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts index 00067ad1f5..909cad0251 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts @@ -19,8 +19,8 @@ export function getProjects( return postJSON(`/admin/user/${userId}/projects`, { body: { sort } }) } -export function searchProjects(search: string, userId?: string): Promise { - return postJSON('/admin/projects/search', { body: { search, userId } }) +export function searchProjects(search: string, userId?: string, signal?: AbortSignal): Promise { + return postJSON('/admin/projects/search', { body: { search, userId }, signal }) } export function deleteProject(projectId: string) { diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx index 1b5480fe94..dd7045b22d 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx @@ -163,19 +163,26 @@ export function UserListProvider({ children }: UserListProviderProps) { setSearchResults(null) return } - + const abortController = new AbortController() const timer = setTimeout(() => { - searchUsers(searchText) + searchUsers(searchText, abortController.signal) .then(data => { setSearchResults(data.users) }) .catch(error => { + if (error.name === 'AbortError') { + debugConsole.log('Search aborted') + return + } debugConsole.error('Error searching users:', error) setSearchResults(null) }) }, 300) // 300ms debounce - return () => clearTimeout(timer) + return () => { + clearTimeout(timer) + abortController.abort() + } }, [searchText]) const addUserToView = useCallback((newUser: Partial) => { diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts index fabedc07f1..5a28b0d99a 100644 --- a/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts @@ -5,8 +5,8 @@ export function getUsers(sortBy: Sort): Promise { return postJSON('/admin/users', { body: { sort: sortBy } }) } -export function searchUsers(search: string): Promise { - return postJSON('/admin/users/search', { body: { search } }) +export function searchUsers(search: string, signal?: AbortSignal): Promise { + return postJSON('/admin/users/search', { body: { search }, signal }) } export function updateUser(userId: string, userData: Partial) {