2026-03-24 23:54:32 -07:00
|
|
|
import { SystemRoles } from 'librechat-data-provider';
|
2026-03-26 17:05:01 -07:00
|
|
|
import { logger, isValidObjectIdString, RoleConflictError } from '@librechat/data-schemas';
|
2026-03-26 15:54:14 -07:00
|
|
|
import type { IRole, IUser, AdminMember } from '@librechat/data-schemas';
|
2026-03-24 23:40:12 -07:00
|
|
|
import type { FilterQuery } from 'mongoose';
|
|
|
|
|
import type { Response } from 'express';
|
2026-03-26 15:30:33 -07:00
|
|
|
import type { ServerRequest } from '~/types/http';
|
|
|
|
|
|
|
|
|
|
const MAX_NAME_LENGTH = 500;
|
2026-03-26 16:25:14 -07:00
|
|
|
const MAX_DESCRIPTION_LENGTH = 2000;
|
2026-03-24 23:40:12 -07:00
|
|
|
|
|
|
|
|
interface RoleNameParams {
|
|
|
|
|
name: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface RoleMemberParams extends RoleNameParams {
|
|
|
|
|
userId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AdminRolesDeps {
|
|
|
|
|
listRoles: () => Promise<IRole[]>;
|
2026-03-24 23:54:32 -07:00
|
|
|
getRoleByName: (name: string, fields?: string | string[] | null) => Promise<IRole | null>;
|
2026-03-25 08:56:38 -07:00
|
|
|
createRoleByName: (roleData: Partial<IRole>) => Promise<IRole>;
|
2026-03-24 23:40:12 -07:00
|
|
|
updateRoleByName: (name: string, updates: Partial<IRole>) => Promise<IRole | null>;
|
|
|
|
|
updateAccessPermissions: (
|
|
|
|
|
name: string,
|
|
|
|
|
perms: Record<string, Record<string, boolean>>,
|
|
|
|
|
roleData?: IRole,
|
|
|
|
|
) => Promise<void>;
|
2026-03-25 08:56:38 -07:00
|
|
|
deleteRoleByName: (name: string) => Promise<IRole | null>;
|
2026-03-24 23:40:12 -07:00
|
|
|
findUser: (
|
|
|
|
|
criteria: FilterQuery<IUser>,
|
|
|
|
|
fields?: string | string[] | null,
|
|
|
|
|
) => Promise<IUser | null>;
|
|
|
|
|
updateUser: (userId: string, data: Partial<IUser>) => Promise<IUser | null>;
|
2026-03-26 15:30:33 -07:00
|
|
|
updateUsersByRole: (oldRole: string, newRole: string) => Promise<void>;
|
|
|
|
|
listUsersByRole: (
|
|
|
|
|
roleName: string,
|
|
|
|
|
options?: { limit?: number; offset?: number },
|
|
|
|
|
) => Promise<IUser[]>;
|
|
|
|
|
countUsersByRole: (roleName: string) => Promise<number>;
|
2026-03-24 23:40:12 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createAdminRolesHandlers(deps: AdminRolesDeps) {
|
|
|
|
|
const {
|
|
|
|
|
listRoles,
|
|
|
|
|
getRoleByName,
|
2026-03-25 08:56:38 -07:00
|
|
|
createRoleByName,
|
2026-03-24 23:40:12 -07:00
|
|
|
updateRoleByName,
|
|
|
|
|
updateAccessPermissions,
|
2026-03-25 08:56:38 -07:00
|
|
|
deleteRoleByName,
|
2026-03-24 23:40:12 -07:00
|
|
|
findUser,
|
|
|
|
|
updateUser,
|
2026-03-26 15:30:33 -07:00
|
|
|
updateUsersByRole,
|
2026-03-24 23:54:32 -07:00
|
|
|
listUsersByRole,
|
2026-03-26 15:30:33 -07:00
|
|
|
countUsersByRole,
|
2026-03-24 23:40:12 -07:00
|
|
|
} = deps;
|
|
|
|
|
|
|
|
|
|
async function listRolesHandler(_req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const roles = await listRoles();
|
|
|
|
|
return res.status(200).json({ roles });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] listRoles error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to list roles' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getRoleHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const { name } = req.params as RoleNameParams;
|
|
|
|
|
const role = await getRoleByName(name);
|
|
|
|
|
if (!role) {
|
|
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
return res.status(200).json({ role });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] getRole error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to get role' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createRoleHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
2026-03-25 10:51:09 -07:00
|
|
|
const { name, description, permissions } = req.body as {
|
2026-03-24 23:54:32 -07:00
|
|
|
name?: string;
|
2026-03-25 10:51:09 -07:00
|
|
|
description?: string;
|
2026-03-24 23:54:32 -07:00
|
|
|
permissions?: IRole['permissions'];
|
|
|
|
|
};
|
2026-03-24 23:40:12 -07:00
|
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
|
|
|
return res.status(400).json({ error: 'name is required' });
|
|
|
|
|
}
|
2026-03-26 15:30:33 -07:00
|
|
|
if (name.trim().length > MAX_NAME_LENGTH) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: `name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
|
|
|
}
|
2026-03-26 16:25:14 -07:00
|
|
|
if (description !== undefined && typeof description !== 'string') {
|
|
|
|
|
return res.status(400).json({ error: 'description must be a string' });
|
|
|
|
|
}
|
|
|
|
|
if (description && description.length > MAX_DESCRIPTION_LENGTH) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: `description must not exceed ${MAX_DESCRIPTION_LENGTH} characters` });
|
|
|
|
|
}
|
2026-03-26 17:05:01 -07:00
|
|
|
if (
|
|
|
|
|
permissions !== undefined &&
|
|
|
|
|
(typeof permissions !== 'object' || Array.isArray(permissions))
|
|
|
|
|
) {
|
|
|
|
|
return res.status(400).json({ error: 'permissions must be an object' });
|
|
|
|
|
}
|
2026-03-25 08:56:38 -07:00
|
|
|
const role = await createRoleByName({
|
2026-03-24 23:40:12 -07:00
|
|
|
name: name.trim(),
|
2026-03-25 10:51:09 -07:00
|
|
|
description: description ?? '',
|
2026-03-26 15:30:33 -07:00
|
|
|
permissions: permissions ?? {},
|
2026-03-24 23:40:12 -07:00
|
|
|
});
|
|
|
|
|
return res.status(201).json({ role });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] createRole error:', error);
|
2026-03-26 17:05:01 -07:00
|
|
|
if (error instanceof RoleConflictError) {
|
|
|
|
|
return res.status(409).json({ error: error.message });
|
|
|
|
|
}
|
|
|
|
|
return res.status(500).json({ error: 'Failed to create role' });
|
2026-03-24 23:40:12 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateRoleHandler(req: ServerRequest, res: Response) {
|
2026-03-26 16:25:14 -07:00
|
|
|
const { name } = req.params as RoleNameParams;
|
2026-03-26 17:05:01 -07:00
|
|
|
const body = req.body as { name?: string; description?: string };
|
2026-03-26 16:25:14 -07:00
|
|
|
let isRename = false;
|
|
|
|
|
let trimmedName = '';
|
2026-03-26 16:38:11 -07:00
|
|
|
let migrationRan = false;
|
2026-03-24 23:40:12 -07:00
|
|
|
try {
|
2026-03-25 11:22:25 -07:00
|
|
|
if (
|
|
|
|
|
body.name !== undefined &&
|
|
|
|
|
(!body.name || typeof body.name !== 'string' || !body.name.trim())
|
|
|
|
|
) {
|
|
|
|
|
return res.status(400).json({ error: 'name must be a non-empty string' });
|
|
|
|
|
}
|
2026-03-26 15:30:33 -07:00
|
|
|
if (body.name !== undefined && body.name.trim().length > MAX_NAME_LENGTH) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: `name must not exceed ${MAX_NAME_LENGTH} characters` });
|
|
|
|
|
}
|
2026-03-26 16:38:11 -07:00
|
|
|
if (body.description !== undefined && typeof body.description !== 'string') {
|
|
|
|
|
return res.status(400).json({ error: 'description must be a string' });
|
|
|
|
|
}
|
|
|
|
|
if (body.description && body.description.length > MAX_DESCRIPTION_LENGTH) {
|
|
|
|
|
return res
|
|
|
|
|
.status(400)
|
|
|
|
|
.json({ error: `description must not exceed ${MAX_DESCRIPTION_LENGTH} characters` });
|
|
|
|
|
}
|
2026-03-26 15:30:33 -07:00
|
|
|
|
2026-03-26 16:25:14 -07:00
|
|
|
trimmedName = body.name?.trim() ?? '';
|
|
|
|
|
isRename = trimmedName !== '' && trimmedName !== name;
|
2026-03-26 15:30:33 -07:00
|
|
|
|
|
|
|
|
if (isRename && SystemRoles[name as keyof typeof SystemRoles]) {
|
|
|
|
|
return res.status(403).json({ error: 'Cannot rename system role' });
|
|
|
|
|
}
|
|
|
|
|
if (isRename && SystemRoles[trimmedName as keyof typeof SystemRoles]) {
|
2026-03-25 11:22:25 -07:00
|
|
|
return res.status(409).json({ error: 'Cannot rename to a reserved system role name' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 23:40:12 -07:00
|
|
|
const existing = await getRoleByName(name);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 15:30:33 -07:00
|
|
|
if (isRename) {
|
|
|
|
|
const duplicate = await getRoleByName(trimmedName);
|
|
|
|
|
if (duplicate) {
|
|
|
|
|
return res.status(409).json({ error: `Role "${trimmedName}" already exists` });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 23:40:12 -07:00
|
|
|
const updates: Partial<IRole> = {};
|
2026-03-26 16:25:14 -07:00
|
|
|
if (isRename) {
|
2026-03-26 15:30:33 -07:00
|
|
|
updates.name = trimmedName;
|
2026-03-24 23:40:12 -07:00
|
|
|
}
|
2026-03-25 10:51:09 -07:00
|
|
|
if (body.description !== undefined) {
|
|
|
|
|
updates.description = body.description;
|
|
|
|
|
}
|
2026-03-24 23:40:12 -07:00
|
|
|
|
2026-03-26 16:38:11 -07:00
|
|
|
if (Object.keys(updates).length === 0) {
|
|
|
|
|
return res.status(200).json({ role: existing });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 15:54:14 -07:00
|
|
|
if (isRename) {
|
|
|
|
|
await updateUsersByRole(name, trimmedName);
|
2026-03-26 16:38:11 -07:00
|
|
|
migrationRan = true;
|
2026-03-26 15:54:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 23:40:12 -07:00
|
|
|
const role = await updateRoleByName(name, updates);
|
2026-03-26 15:30:33 -07:00
|
|
|
if (!role) {
|
2026-03-26 16:38:11 -07:00
|
|
|
if (migrationRan) {
|
2026-03-26 17:05:01 -07:00
|
|
|
try {
|
|
|
|
|
await updateUsersByRole(trimmedName, name);
|
|
|
|
|
} catch (rollbackError) {
|
|
|
|
|
logger.error('[adminRoles] rollback failed (role not found path):', rollbackError);
|
|
|
|
|
}
|
2026-03-26 16:12:33 -07:00
|
|
|
}
|
2026-03-26 15:30:33 -07:00
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 23:40:12 -07:00
|
|
|
return res.status(200).json({ role });
|
|
|
|
|
} catch (error) {
|
2026-03-26 16:38:11 -07:00
|
|
|
if (migrationRan) {
|
2026-03-26 16:25:14 -07:00
|
|
|
try {
|
|
|
|
|
await updateUsersByRole(trimmedName, name);
|
|
|
|
|
} catch (rollbackError) {
|
|
|
|
|
logger.error('[adminRoles] rollback failed after updateRole error:', rollbackError);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-24 23:40:12 -07:00
|
|
|
logger.error('[adminRoles] updateRole error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to update role' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateRolePermissionsHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const { name } = req.params as RoleNameParams;
|
|
|
|
|
const { permissions } = req.body as {
|
|
|
|
|
permissions: Record<string, Record<string, boolean>>;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-26 15:30:33 -07:00
|
|
|
if (!permissions || typeof permissions !== 'object' || Array.isArray(permissions)) {
|
2026-03-24 23:40:12 -07:00
|
|
|
return res.status(400).json({ error: 'permissions object is required' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const existing = await getRoleByName(name);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await updateAccessPermissions(name, permissions, existing);
|
|
|
|
|
const updated = await getRoleByName(name);
|
|
|
|
|
return res.status(200).json({ role: updated });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] updateRolePermissions error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to update role permissions' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteRoleHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const { name } = req.params as RoleNameParams;
|
|
|
|
|
if (SystemRoles[name as keyof typeof SystemRoles]) {
|
|
|
|
|
return res.status(403).json({ error: 'Cannot delete system role' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 16:25:14 -07:00
|
|
|
const deleted = await deleteRoleByName(name);
|
|
|
|
|
if (!deleted) {
|
2026-03-24 23:40:12 -07:00
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
return res.status(200).json({ success: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] deleteRole error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to delete role' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getRoleMembersHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const { name } = req.params as RoleNameParams;
|
|
|
|
|
const existing = await getRoleByName(name);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 15:30:33 -07:00
|
|
|
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
|
|
|
|
|
const offset = Math.max(Number(req.query.offset) || 0, 0);
|
|
|
|
|
|
|
|
|
|
const [users, total] = await Promise.all([
|
|
|
|
|
listUsersByRole(name, { limit, offset }),
|
|
|
|
|
countUsersByRole(name),
|
|
|
|
|
]);
|
2026-03-24 23:54:32 -07:00
|
|
|
const members: AdminMember[] = users.map((u) => ({
|
|
|
|
|
userId: u._id?.toString() ?? '',
|
|
|
|
|
name: u.name ?? u._id?.toString() ?? '',
|
|
|
|
|
email: u.email ?? '',
|
|
|
|
|
avatarUrl: u.avatar,
|
|
|
|
|
}));
|
2026-03-26 15:30:33 -07:00
|
|
|
return res.status(200).json({ members, total, limit, offset });
|
2026-03-24 23:40:12 -07:00
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] getRoleMembers error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to get role members' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function addRoleMemberHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const { name } = req.params as RoleNameParams;
|
|
|
|
|
const { userId } = req.body as { userId: string };
|
|
|
|
|
|
|
|
|
|
if (!userId || typeof userId !== 'string') {
|
|
|
|
|
return res.status(400).json({ error: 'userId is required' });
|
|
|
|
|
}
|
2026-03-25 11:22:25 -07:00
|
|
|
if (!isValidObjectIdString(userId)) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid user ID format' });
|
|
|
|
|
}
|
2026-03-24 23:40:12 -07:00
|
|
|
|
|
|
|
|
const existing = await getRoleByName(name);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await findUser({ _id: userId });
|
|
|
|
|
if (!user) {
|
|
|
|
|
return res.status(404).json({ error: 'User not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await updateUser(userId, { role: name });
|
|
|
|
|
return res.status(200).json({ success: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] addRoleMember error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to add role member' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeRoleMemberHandler(req: ServerRequest, res: Response) {
|
|
|
|
|
try {
|
|
|
|
|
const { name, userId } = req.params as RoleMemberParams;
|
2026-03-25 11:22:25 -07:00
|
|
|
if (!isValidObjectIdString(userId)) {
|
|
|
|
|
return res.status(400).json({ error: 'Invalid user ID format' });
|
|
|
|
|
}
|
2026-03-24 23:40:12 -07:00
|
|
|
|
2026-03-26 15:30:33 -07:00
|
|
|
const existing = await getRoleByName(name);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
return res.status(404).json({ error: 'Role not found' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 23:40:12 -07:00
|
|
|
const user = await findUser({ _id: userId });
|
|
|
|
|
if (!user) {
|
|
|
|
|
return res.status(404).json({ error: 'User not found' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (user.role !== name) {
|
|
|
|
|
return res.status(400).json({ error: 'User is not a member of this role' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 17:05:43 -07:00
|
|
|
if (name === SystemRoles.ADMIN) {
|
|
|
|
|
const adminCount = await countUsersByRole(SystemRoles.ADMIN);
|
|
|
|
|
if (adminCount <= 1) {
|
|
|
|
|
return res.status(400).json({ error: 'Cannot remove the last admin user' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 23:40:12 -07:00
|
|
|
await updateUser(userId, { role: SystemRoles.USER });
|
|
|
|
|
return res.status(200).json({ success: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error('[adminRoles] removeRoleMember error:', error);
|
|
|
|
|
return res.status(500).json({ error: 'Failed to remove role member' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
listRoles: listRolesHandler,
|
|
|
|
|
getRole: getRoleHandler,
|
|
|
|
|
createRole: createRoleHandler,
|
|
|
|
|
updateRole: updateRoleHandler,
|
|
|
|
|
updateRolePermissions: updateRolePermissionsHandler,
|
|
|
|
|
deleteRole: deleteRoleHandler,
|
|
|
|
|
getRoleMembers: getRoleMembersHandler,
|
|
|
|
|
addRoleMember: addRoleMemberHandler,
|
|
|
|
|
removeRoleMember: removeRoleMemberHandler,
|
|
|
|
|
};
|
|
|
|
|
}
|