👨‍👨‍👦‍👦 feat: Admin Users API Endpoints (#12446)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* feat: add admin user management endpoints

Add /api/admin/users with list, search, and delete handlers gated by
ACCESS_ADMIN + READ_USERS/MANAGE_USERS system grants. Handler factory
in packages/api uses findUsers, countUsers, and deleteUserById from
data-schemas.

* fix: address convention violations in admin users handlers

* fix: add pagination, self-deletion guard, and DB-level search limit

- listUsers now uses parsePagination + countUsers for proper pagination
  matching the roles/groups pattern
- findUsers extended with optional limit/offset options
- deleteUser returns 403 when caller tries to delete own account
- searchUsers passes limit to DB query instead of fetching all and
  slicing in JS
- Fix import ordering per CLAUDE.md, complete logger mock
- Replace fabricated date fallback with undefined

* fix: deterministic sort, null-safe pagination, consistent search filter

- Add sort option to findUsers; listUsers sorts by createdAt desc for
  deterministic pagination
- Use != null guards for offset/limit to handle zero values correctly
- Remove username from search filter since it is not in the projection
  or AdminUserSearchResult response type

* fix: last-admin deletion guard and search query max-length

- Prevent deleting the last admin user (look up target role, count
  admins, reject with 400 if count <= 1)
- Cap search query at 200 characters to prevent regex DoS
- Add tests for both guards

* fix: include missing capability name in 403 Forbidden response

* fix: cascade user deletion cleanup, search username, parallel capability checks

- Cascade Config, AclEntry, and SystemGrant cleanup on user deletion
  (matching the pattern in roles/groups handlers)
- Add username to admin search $or filter for parity with searchUsers
- Parallelize READ_* capability checks in listAllGrants with Promise.all

* fix: TOCTOU safety net, capability info leak, DRY/style cleanup, data-layer tests

- Add post-delete admin recount with CRITICAL log if race leaves 0 admins
- Revert capability name from 403 response to server-side log only
- Document thin deleteUserById limitation (full cascade is a future task)
- DRY: extract query.trim() to local variable in searchUsersHandler
- Add username to search projection, response type, and AdminUserSearchResult
- Functional filter/map in grants.ts parallel capability check
- Consistent null guards and limit>0 guard in findUsers options
- Fallback for empty result.message on delete response
- Fix mockUser() to generate unique _id per call
- Break long destructuring across multiple lines
- Assert countUsers filter and non-admin skip in delete tests
- Add data-layer tests for findUsers limit, offset, sort, and pagination

* chore: comment out admin delete user endpoint (out of scope)

* fix: cast USER principalId to ObjectId for ACL entry cleanup

ACL entries store USER principalId as ObjectId (via grantPermission casting),
but deleteAclEntries is a raw deleteMany that passes the filter through.
Passing a string won't match stored ObjectIds, leaving orphaned entries.

* chore: comment out unused requireManageUsers alongside disabled delete route

* fix: add missing logger.warn mock in capabilities test

* fix: harden admin users handlers — type safety, response consistency, test coverage

- Unify response shape: AdminUserSearchResult.userId → id, add AdminUserListItem type
- Fix unsafe req.query type assertion in searchUsersHandler (typeof guards)
- Anchor search regex with ^ for prefix matching (enables index usage)
- Add total/capped to search response for truncation signaling
- Add parseInt radix, remove redundant new Date() wrap
- Add tests: countUsers throw, countUsers call args, array query param, capped flag

* fix: scope deleteGrantsForPrincipal to tenant, deterministic search sort, align test mocks

- Add tenantId option to AdminUsersDeps.deleteGrantsForPrincipal and
  pass req.user.tenantId at the call site, matching the pattern already
  used by the roles and groups handlers
- Add sort: { name: 1 } to searchUsersHandler for deterministic results
- Align test mock deleteUserById messages with production output
  ('User was deleted successfully.')
- Make capped-results test explicitly set limit: '20' instead of
  relying on the implicit default

* test: add tenantId propagation test for deleteGrantsForPrincipal

Add tenantId to createReqRes user type and test that a non-undefined
tenantId is threaded through to deleteGrantsForPrincipal.

* test: remove redundant deleteUserById override in tenantId test

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2026-03-30 20:06:50 -07:00 committed by GitHub
parent fd01dfc083
commit 3d1b883e9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 843 additions and 2 deletions

View file

@ -157,6 +157,7 @@ const startServer = async () => {
app.use('/api/admin/grants', routes.adminGrants);
app.use('/api/admin/groups', routes.adminGroups);
app.use('/api/admin/roles', routes.adminRoles);
app.use('/api/admin/users', routes.adminUsers);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);

View file

@ -0,0 +1,29 @@
const express = require('express');
const { createAdminUsersHandlers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { requireJwtAuth } = require('~/server/middleware');
const db = require('~/models');
const router = express.Router();
const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const requireReadUsers = requireCapability(SystemCapabilities.READ_USERS);
// const requireManageUsers = requireCapability(SystemCapabilities.MANAGE_USERS);
const handlers = createAdminUsersHandlers({
findUsers: db.findUsers,
countUsers: db.countUsers,
deleteUserById: db.deleteUserById,
deleteConfig: db.deleteConfig,
deleteAclEntries: db.deleteAclEntries,
deleteGrantsForPrincipal: db.deleteGrantsForPrincipal,
});
router.use(requireJwtAuth, requireAdminAccess);
router.get('/', requireReadUsers, handlers.listUsers);
router.get('/search', requireReadUsers, handlers.searchUsers);
// router.delete('/:id', requireManageUsers, handlers.deleteUser);
module.exports = router;

View file

@ -6,6 +6,7 @@ const adminConfig = require('./admin/config');
const adminGrants = require('./admin/grants');
const adminGroups = require('./admin/groups');
const adminRoles = require('./admin/roles');
const adminUsers = require('./admin/users');
const endpoints = require('./endpoints');
const staticRoute = require('./static');
const messages = require('./messages');
@ -39,6 +40,7 @@ module.exports = {
adminGrants,
adminGroups,
adminRoles,
adminUsers,
keys,
apiKeys,
user,

View file

@ -2,7 +2,9 @@ export { createAdminConfigHandlers } from './config';
export { createAdminGrantsHandlers } from './grants';
export { createAdminGroupsHandlers } from './groups';
export { createAdminRolesHandlers } from './roles';
export { createAdminUsersHandlers } from './users';
export type { AdminConfigDeps } from './config';
export type { AdminGrantsDeps, GrantPrincipalType } from './grants';
export type { AdminGroupsDeps } from './groups';
export type { AdminRolesDeps } from './roles';
export type { AdminUsersDeps } from './users';

View file

@ -0,0 +1,525 @@
import { Types } from 'mongoose';
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import type { IUser, UserDeleteResult } from '@librechat/data-schemas';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
import type { AdminUsersDeps } from './users';
import { createAdminUsersHandlers } from './users';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
}));
const validUserId = new Types.ObjectId().toString();
function mockUser(overrides: Partial<IUser> = {}): IUser {
return {
_id: new Types.ObjectId(),
name: 'Test User',
username: 'testuser',
email: 'test@example.com',
avatar: 'https://example.com/avatar.png',
role: 'USER',
provider: 'local',
createdAt: new Date('2025-01-01'),
updatedAt: new Date('2025-06-01'),
...overrides,
} as IUser;
}
function createReqRes(
overrides: {
params?: Record<string, string>;
query?: Record<string, string | string[]>;
user?: { _id?: Types.ObjectId; id?: string; role?: string; tenantId?: string };
} = {},
) {
const req = {
params: overrides.params ?? {},
query: overrides.query ?? {},
body: {},
user: overrides.user ?? { _id: new Types.ObjectId(), role: 'admin' },
} as unknown as ServerRequest;
const json = jest.fn();
const status = jest.fn().mockReturnValue({ json });
const res = { status, json } as unknown as Response;
return { req, res, status, json };
}
function createDeps(overrides: Partial<AdminUsersDeps> = {}): AdminUsersDeps {
return {
findUsers: jest.fn().mockResolvedValue([]),
countUsers: jest.fn().mockResolvedValue(0),
deleteUserById: jest
.fn()
.mockResolvedValue({ deletedCount: 1, message: 'User was deleted successfully.' }),
deleteConfig: jest.fn().mockResolvedValue(null),
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
deleteGrantsForPrincipal: jest.fn().mockResolvedValue(undefined),
...overrides,
};
}
describe('createAdminUsersHandlers', () => {
describe('listUsers', () => {
it('returns paginated users with total count', async () => {
const users = [
mockUser({ _id: new Types.ObjectId(validUserId) }),
mockUser({ name: 'Other' }),
];
const deps = createDeps({
findUsers: jest.fn().mockResolvedValue(users),
countUsers: jest.fn().mockResolvedValue(2),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes();
await handlers.listUsers(req, res);
expect(status).toHaveBeenCalledWith(200);
const response = json.mock.calls[0][0];
expect(response.users).toHaveLength(2);
expect(response.total).toBe(2);
expect(response).toHaveProperty('limit');
expect(response).toHaveProperty('offset');
expect(response.users[0]).toHaveProperty('id');
expect(response.users[0]).toHaveProperty('name');
expect(response.users[0]).toHaveProperty('email');
expect(response.users[0]).toHaveProperty('role');
});
it('passes pagination params to findUsers and unfiltered count', async () => {
const findUsers = jest.fn().mockResolvedValue([]);
const countUsers = jest.fn().mockResolvedValue(0);
const deps = createDeps({ findUsers, countUsers });
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ query: { limit: '10', offset: '20' } });
await handlers.listUsers(req, res);
expect(findUsers).toHaveBeenCalledWith({}, expect.any(String), {
limit: 10,
offset: 20,
sort: { createdAt: -1 },
});
expect(countUsers).toHaveBeenCalledWith();
});
it('returns empty list when no users', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes();
await handlers.listUsers(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(json.mock.calls[0][0].users).toEqual([]);
expect(json.mock.calls[0][0].total).toBe(0);
});
it('returns 500 when findUsers throws', async () => {
const deps = createDeps({ findUsers: jest.fn().mockRejectedValue(new Error('db down')) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes();
await handlers.listUsers(req, res);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Failed to list users' });
});
it('returns 500 when countUsers throws', async () => {
const deps = createDeps({
countUsers: jest.fn().mockRejectedValue(new Error('count failed')),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes();
await handlers.listUsers(req, res);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Failed to list users' });
});
});
describe('searchUsers', () => {
it('returns matching users with total and capped flag', async () => {
const users = [mockUser()];
const deps = createDeps({ findUsers: jest.fn().mockResolvedValue(users) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: 'test' } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(200);
const response = json.mock.calls[0][0];
expect(response.users).toHaveLength(1);
expect(response.total).toBe(1);
expect(response.capped).toBe(false);
expect(response.users[0]).toHaveProperty('id');
expect(response.users[0]).toHaveProperty('name');
expect(response.users[0]).toHaveProperty('email');
expect(response.users[0]).toHaveProperty('username');
});
it('sets capped to true when results hit the limit', async () => {
const users = Array.from({ length: 20 }, () => mockUser());
const deps = createDeps({ findUsers: jest.fn().mockResolvedValue(users) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, json } = createReqRes({ query: { q: 'test', limit: '20' } });
await handlers.searchUsers(req, res);
const response = json.mock.calls[0][0];
expect(response.total).toBe(20);
expect(response.capped).toBe(true);
});
it('searches name, email, and username with anchored prefix regex', async () => {
const findUsers = jest.fn().mockResolvedValue([]);
const deps = createDeps({ findUsers });
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ query: { q: 'test' } });
await handlers.searchUsers(req, res);
const filter = findUsers.mock.calls[0][0];
expect(filter.$or).toHaveLength(3);
expect(filter.$or[0]).toHaveProperty('name');
expect(filter.$or[1]).toHaveProperty('email');
expect(filter.$or[2]).toHaveProperty('username');
expect(filter.$or[0].name.source).toBe('^test');
});
it('projects username in the field selection', async () => {
const findUsers = jest.fn().mockResolvedValue([]);
const deps = createDeps({ findUsers });
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ query: { q: 'test' } });
await handlers.searchUsers(req, res);
const projection = findUsers.mock.calls[0][1];
expect(projection).toContain('username');
});
it('escapes regex special characters in query', async () => {
const findUsers = jest.fn().mockResolvedValue([]);
const deps = createDeps({ findUsers });
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ query: { q: 'test.user+1' } });
await handlers.searchUsers(req, res);
const filter = findUsers.mock.calls[0][0];
expect(filter.$or[0].name).toBeInstanceOf(RegExp);
expect(filter.$or[0].name.source).toBe('^test\\.user\\+1');
});
it('returns 400 when query is missing', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: {} });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
});
it('returns 400 when query is empty string', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: '' } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
});
it('returns 400 when query is whitespace-only', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: ' ' } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
});
it('returns 400 when query is too short', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: 'a' } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Query must be at least 2 characters' });
});
it('returns 400 when query exceeds max length', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: 'a'.repeat(201) } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.stringContaining('200') }),
);
});
it('treats array query param as missing', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: ['foo', 'bar'] } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Query parameter "q" is required' });
});
it('passes limit to findUsers', async () => {
const findUsers = jest.fn().mockResolvedValue([mockUser()]);
const deps = createDeps({ findUsers });
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ query: { q: 'User', limit: '3' } });
await handlers.searchUsers(req, res);
expect(findUsers).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
limit: 3,
sort: { name: 1 },
});
});
it('caps limit at 50', async () => {
const findUsers = jest.fn().mockResolvedValue([]);
const deps = createDeps({ findUsers });
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ query: { q: 'User', limit: '100' } });
await handlers.searchUsers(req, res);
expect(findUsers).toHaveBeenCalledWith(expect.any(Object), expect.any(String), {
limit: 50,
sort: { name: 1 },
});
});
it('returns 500 on error', async () => {
const deps = createDeps({ findUsers: jest.fn().mockRejectedValue(new Error('db down')) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ query: { q: 'test' } });
await handlers.searchUsers(req, res);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Failed to search users' });
});
});
describe('deleteUser', () => {
it('deletes user and returns 200', async () => {
const result: UserDeleteResult = {
deletedCount: 1,
message: 'User was deleted successfully.',
};
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ message: 'User was deleted successfully.' });
});
it('returns fallback message when result.message is empty', async () => {
const result: UserDeleteResult = { deletedCount: 1, message: '' };
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ message: 'User deleted successfully' });
});
it('returns 403 when deleting own account', async () => {
const userId = new Types.ObjectId();
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({
params: { id: userId.toString() },
user: { _id: userId, role: 'admin' },
});
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(403);
expect(json).toHaveBeenCalledWith({ error: 'Cannot delete your own account' });
expect(deps.deleteUserById).not.toHaveBeenCalled();
});
it('returns 400 when deleting the last admin', async () => {
const targetId = new Types.ObjectId().toString();
const deps = createDeps({
findUsers: jest.fn().mockResolvedValue([mockUser({ role: SystemRoles.ADMIN })]),
countUsers: jest.fn().mockResolvedValue(1),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: targetId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Cannot delete the last admin user' });
expect(deps.deleteUserById).not.toHaveBeenCalled();
expect(deps.countUsers).toHaveBeenCalledWith({ role: SystemRoles.ADMIN });
});
it('allows deleting an admin when other admins exist', async () => {
const targetId = new Types.ObjectId().toString();
const deps = createDeps({
findUsers: jest.fn().mockResolvedValue([mockUser({ role: SystemRoles.ADMIN })]),
countUsers: jest.fn().mockResolvedValue(3),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: targetId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(deps.deleteUserById).toHaveBeenCalledWith(targetId);
});
it('does not check admin count when target is a regular user', async () => {
const targetId = new Types.ObjectId().toString();
const deps = createDeps({
findUsers: jest.fn().mockResolvedValue([mockUser({ role: 'USER' })]),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: targetId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(deps.countUsers).not.toHaveBeenCalled();
});
it('cascades cleanup of Config, AclEntries, and SystemGrants', async () => {
const result: UserDeleteResult = {
deletedCount: 1,
message: 'User was deleted successfully.',
};
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(deps.deleteConfig).toHaveBeenCalledWith(PrincipalType.USER, validUserId);
expect(deps.deleteAclEntries).toHaveBeenCalledWith({
principalType: PrincipalType.USER,
principalId: expect.any(Types.ObjectId),
});
expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.USER, validUserId, {
tenantId: undefined,
});
});
it('scopes grant cleanup to the caller tenantId', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({
params: { id: validUserId },
user: { _id: new Types.ObjectId(), role: 'admin', tenantId: 'tenant-xyz' },
});
await handlers.deleteUser(req, res);
expect(deps.deleteGrantsForPrincipal).toHaveBeenCalledWith(PrincipalType.USER, validUserId, {
tenantId: 'tenant-xyz',
});
});
it('returns success even when cascade cleanup partially fails', async () => {
const result: UserDeleteResult = {
deletedCount: 1,
message: 'User was deleted successfully.',
};
const deps = createDeps({
deleteUserById: jest.fn().mockResolvedValue(result),
deleteConfig: jest.fn().mockRejectedValue(new Error('cleanup failed')),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(200);
expect(json).toHaveBeenCalledWith({ message: 'User was deleted successfully.' });
});
it('does not cascade when user is not found', async () => {
const result: UserDeleteResult = { deletedCount: 0, message: '' };
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(deps.deleteConfig).not.toHaveBeenCalled();
expect(deps.deleteAclEntries).not.toHaveBeenCalled();
expect(deps.deleteGrantsForPrincipal).not.toHaveBeenCalled();
});
it('returns 400 for invalid ObjectId', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: 'not-valid' } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(400);
expect(json).toHaveBeenCalledWith({ error: 'Invalid user ID format' });
});
it('returns 404 when user not found', async () => {
const result: UserDeleteResult = { deletedCount: 0, message: '' };
const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(404);
expect(json).toHaveBeenCalledWith({ error: 'User not found' });
});
it('returns 500 on error', async () => {
const deps = createDeps({
deleteUserById: jest.fn().mockRejectedValue(new Error('db crash')),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });
await handlers.deleteUser(req, res);
expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Failed to delete user' });
});
});
});

View file

@ -0,0 +1,198 @@
import { Types } from 'mongoose';
import { PrincipalType, SystemRoles } from 'librechat-data-provider';
import { logger, isValidObjectIdString } from '@librechat/data-schemas';
import type {
IUser,
IConfig,
AdminUserListItem,
AdminUserSearchResult,
UserDeleteResult,
} from '@librechat/data-schemas';
import type { FilterQuery } from 'mongoose';
import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
import { parsePagination } from './pagination';
const MAX_SEARCH_LENGTH = 200;
const USER_LIST_FIELDS = '_id name username email avatar role provider createdAt updatedAt';
export interface AdminUsersDeps {
findUsers: (
searchCriteria: FilterQuery<IUser>,
fieldsToSelect?: string | string[] | null,
options?: { limit?: number; offset?: number; sort?: Record<string, 1 | -1> },
) => Promise<IUser[]>;
countUsers: (filter?: FilterQuery<IUser>) => Promise<number>;
/**
* Thin data-layer delete removes the User document only.
* Full cascade of user-owned resources (conversations, messages, files, tokens, etc.)
* is handled by `UserController.deleteUserController` in the self-delete flow.
* This admin endpoint currently cascades Config, AclEntries, and SystemGrants.
* A future iteration should consolidate the full cascade into a shared service function.
*/
deleteUserById: (userId: string) => Promise<UserDeleteResult>;
deleteConfig: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
) => Promise<IConfig | null>;
deleteAclEntries: (filter: {
principalType: PrincipalType;
principalId: string | Types.ObjectId;
}) => Promise<void>;
deleteGrantsForPrincipal: (
principalType: PrincipalType,
principalId: string | Types.ObjectId,
options?: { tenantId?: string },
) => Promise<void>;
}
export function createAdminUsersHandlers(deps: AdminUsersDeps) {
const {
findUsers,
countUsers,
deleteUserById,
deleteConfig,
deleteAclEntries,
deleteGrantsForPrincipal,
} = deps;
async function listUsersHandler(req: ServerRequest, res: Response) {
try {
const { limit, offset } = parsePagination(req.query);
const [users, total] = await Promise.all([
findUsers({}, USER_LIST_FIELDS, { limit, offset, sort: { createdAt: -1 } }),
countUsers(),
]);
const mapped: AdminUserListItem[] = users.map((u) => ({
id: u._id?.toString() ?? '',
name: u.name ?? '',
username: u.username ?? '',
email: u.email ?? '',
avatar: u.avatar ?? '',
role: u.role ?? 'USER',
provider: u.provider ?? 'local',
createdAt: u.createdAt?.toISOString(),
updatedAt: u.updatedAt?.toISOString(),
}));
return res.status(200).json({ users: mapped, total, limit, offset });
} catch (error) {
logger.error('[adminUsers] listUsers error:', error);
return res.status(500).json({ error: 'Failed to list users' });
}
}
async function searchUsersHandler(req: ServerRequest, res: Response) {
try {
const rawQ = req.query.q;
const rawLimit = req.query.limit;
const query = typeof rawQ === 'string' ? rawQ : undefined;
const limitStr = typeof rawLimit === 'string' ? rawLimit : '20';
const trimmed = query?.trim() ?? '';
if (!trimmed) {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}
if (trimmed.length < 2) {
return res.status(400).json({ error: 'Query must be at least 2 characters' });
}
if (trimmed.length > MAX_SEARCH_LENGTH) {
return res
.status(400)
.json({ error: `Query must not exceed ${MAX_SEARCH_LENGTH} characters` });
}
const searchLimit = Math.min(Math.max(1, parseInt(limitStr, 10) || 20), 50);
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^${escaped}`, 'i');
const users = await findUsers(
{ $or: [{ name: regex }, { email: regex }, { username: regex }] },
'_id name email username avatar',
{ limit: searchLimit, sort: { name: 1 } },
);
const results: AdminUserSearchResult[] = users.map((u) => ({
id: u._id?.toString() ?? '',
name: u.name ?? '',
email: u.email ?? '',
username: u.username,
avatarUrl: u.avatar,
}));
return res
.status(200)
.json({ users: results, total: results.length, capped: results.length >= searchLimit });
} catch (error) {
logger.error('[adminUsers] searchUsers error:', error);
return res.status(500).json({ error: 'Failed to search users' });
}
}
async function deleteUserHandler(req: ServerRequest, res: Response) {
try {
const { id } = req.params as { id: string };
if (!isValidObjectIdString(id)) {
return res.status(400).json({ error: 'Invalid user ID format' });
}
const callerId = req.user?._id?.toString() ?? req.user?.id;
if (callerId === id) {
return res.status(403).json({ error: 'Cannot delete your own account' });
}
const [targetUser] = await findUsers({ _id: id }, 'role', { limit: 1 });
if (targetUser?.role === SystemRoles.ADMIN) {
const adminCount = await countUsers({ role: SystemRoles.ADMIN });
if (adminCount <= 1) {
return res.status(400).json({ error: 'Cannot delete the last admin user' });
}
}
const result = await deleteUserById(id);
if (result.deletedCount === 0) {
return res.status(404).json({ error: 'User not found' });
}
if (targetUser?.role === SystemRoles.ADMIN) {
const remaining = await countUsers({ role: SystemRoles.ADMIN });
if (remaining === 0) {
logger.error(
`[adminUsers] CRITICAL: last admin deleted via race condition, user: ${id}. ` +
'Manual DB intervention required to restore an ADMIN user.',
);
}
}
const objectId = new Types.ObjectId(id);
const tenantId = req.user?.tenantId;
const cleanupResults = await Promise.allSettled([
deleteConfig(PrincipalType.USER, id),
deleteAclEntries({ principalType: PrincipalType.USER, principalId: objectId }),
deleteGrantsForPrincipal(PrincipalType.USER, id, { tenantId }),
]);
for (const r of cleanupResults) {
if (r.status === 'rejected') {
logger.error('[adminUsers] cascade cleanup failed for user:', id, r.reason);
}
}
return res.status(200).json({ message: result.message || 'User deleted successfully' });
} catch (error) {
logger.error('[adminUsers] deleteUser error:', error);
return res.status(500).json({ error: 'Failed to delete user' });
}
}
return {
listUsers: listUsersHandler,
searchUsers: searchUsersHandler,
deleteUser: deleteUserHandler,
};
}

View file

@ -11,6 +11,7 @@ import { generateCapabilityCheck } from './capabilities';
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
},
}));

View file

@ -191,6 +191,7 @@ export function generateCapabilityCheck(deps: CapabilityDeps): {
return;
}
logger.warn(`[requireCapability] Forbidden: user ${id} missing capability '${capability}'`);
res.status(403).json({ message: 'Forbidden' });
} catch (err) {
logger.error(`[requireCapability] Error checking capability: ${capability}`, err);

View file

@ -620,4 +620,62 @@ describe('User Methods - Database Tests', () => {
expect(found?.provider).toBe('saml');
});
});
describe('findUsers with options', () => {
beforeEach(async () => {
await User.create([
{ name: 'Alice', email: 'alice@example.com', provider: 'local' },
{ name: 'Bob', email: 'bob@example.com', provider: 'local' },
{ name: 'Charlie', email: 'charlie@example.com', provider: 'local' },
{ name: 'Diana', email: 'diana@example.com', provider: 'local' },
{ name: 'Eve', email: 'eve@example.com', provider: 'local' },
]);
});
test('limit restricts the number of returned documents', async () => {
const users = await methods.findUsers({}, null, { limit: 2 });
expect(users).toHaveLength(2);
});
test('offset skips the first N documents', async () => {
const all = await methods.findUsers({}, 'name', { sort: { name: 1 } });
const skipped = await methods.findUsers({}, 'name', { offset: 2, sort: { name: 1 } });
expect(skipped).toHaveLength(3);
expect(skipped[0].name).toBe(all[2].name);
});
test('sort orders results by the specified field', async () => {
const asc = await methods.findUsers({}, 'name', { sort: { name: 1 } });
const desc = await methods.findUsers({}, 'name', { sort: { name: -1 } });
expect(asc[0].name).toBe('Alice');
expect(asc[4].name).toBe('Eve');
expect(desc[0].name).toBe('Eve');
expect(desc[4].name).toBe('Alice');
});
test('limit + offset returns the correct page', async () => {
const sorted = await methods.findUsers({}, 'name', { sort: { name: 1 } });
const page2 = await methods.findUsers({}, 'name', {
limit: 2,
offset: 2,
sort: { name: 1 },
});
expect(page2).toHaveLength(2);
expect(page2[0].name).toBe(sorted[2].name);
expect(page2[1].name).toBe(sorted[3].name);
});
test('limit of 0 does not restrict results', async () => {
const users = await methods.findUsers({}, null, { limit: 0 });
expect(users).toHaveLength(5);
});
test('returns all documents when no options provided', async () => {
const users = await methods.findUsers({});
expect(users).toHaveLength(5);
});
});
});

View file

@ -47,6 +47,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
async function findUsers(
searchCriteria: FilterQuery<IUser>,
fieldsToSelect?: string | string[] | null,
options?: { limit?: number; offset?: number; sort?: Record<string, 1 | -1> },
): Promise<IUser[]> {
const User = mongoose.models.User as mongoose.Model<IUser>;
const normalizedCriteria = normalizeEmailInCriteria(searchCriteria);
@ -54,7 +55,16 @@ export function createUserMethods(mongoose: typeof import('mongoose')) {
if (fieldsToSelect) {
query.select(fieldsToSelect);
}
return await query.lean();
if (options?.sort != null) {
query.sort(options.sort);
}
if (options?.offset != null) {
query.skip(options.offset);
}
if (options?.limit != null && options.limit > 0) {
query.limit(options.limit);
}
return (await query.lean()) as IUser[];
}
/**

View file

@ -111,10 +111,24 @@ export type AdminMember = {
joinedAt?: string;
};
/** Full user info returned by the admin user list endpoint. */
export type AdminUserListItem = {
id: string;
name: string;
username: string;
email: string;
avatar: string;
role: string;
provider: string;
createdAt?: string;
updatedAt?: string;
};
/** Minimal user info returned by user search endpoints. */
export type AdminUserSearchResult = {
userId: string;
id: string;
name: string;
email: string;
username?: string;
avatarUrl?: string;
};