From 452333ee4d4788059ffa7bc3700a930687874baf Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:40:12 -0700 Subject: [PATCH] feat: add admin roles handler factory and Express routes --- api/server/index.js | 1 + api/server/routes/admin/roles.js | 54 +++++++ api/server/routes/index.js | 2 + packages/api/src/admin/index.ts | 2 + packages/api/src/admin/roles.ts | 246 +++++++++++++++++++++++++++++++ 5 files changed, 305 insertions(+) create mode 100644 api/server/routes/admin/roles.js create mode 100644 packages/api/src/admin/roles.ts diff --git a/api/server/index.js b/api/server/index.js index 4ecc966476..813b453468 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -145,6 +145,7 @@ const startServer = async () => { app.use('/api/admin', routes.adminAuth); app.use('/api/admin/config', routes.adminConfig); app.use('/api/admin/groups', routes.adminGroups); + app.use('/api/admin/roles', routes.adminRoles); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); diff --git a/api/server/routes/admin/roles.js b/api/server/routes/admin/roles.js new file mode 100644 index 0000000000..fcad24b993 --- /dev/null +++ b/api/server/routes/admin/roles.js @@ -0,0 +1,54 @@ +const express = require('express'); +const { createAdminRolesHandlers } = 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 requireReadRoles = requireCapability(SystemCapabilities.READ_ROLES); +const requireManageRoles = requireCapability(SystemCapabilities.MANAGE_ROLES); + +async function listUsersByRole(roleName) { + const mongoose = require('mongoose'); + const User = mongoose.models.User; + const users = await User.find({ role: roleName }) + .select('_id name email avatar createdAt') + .lean(); + return users.map((u) => ({ + userId: String(u._id), + name: u.name ?? String(u._id), + email: u.email ?? '', + avatarUrl: u.avatar, + joinedAt: u.createdAt ? u.createdAt.toISOString() : new Date().toISOString(), + })); +} + +const handlers = createAdminRolesHandlers({ + listRoles: db.listRoles, + getRoleByName: db.getRoleByName, + createRole: db.createRole, + updateRoleByName: db.updateRoleByName, + updateAccessPermissions: db.updateAccessPermissions, + deleteRole: db.deleteRole, + findUser: db.findUser, + updateUser: db.updateUser, + countUsers: db.countUsers, + listUsersByRole, +}); + +router.use(requireJwtAuth, requireAdminAccess); + +router.get('/', requireReadRoles, handlers.listRoles); +router.post('/', requireManageRoles, handlers.createRole); +router.get('/:name', requireReadRoles, handlers.getRole); +router.patch('/:name', requireManageRoles, handlers.updateRole); +router.delete('/:name', requireManageRoles, handlers.deleteRole); +router.patch('/:name/permissions', requireManageRoles, handlers.updateRolePermissions); +router.get('/:name/members', requireReadRoles, handlers.getRoleMembers); +router.post('/:name/members', requireManageRoles, handlers.addRoleMember); +router.delete('/:name/members/:userId', requireManageRoles, handlers.removeRoleMember); + +module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index f9a088649c..71ae041fc2 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -4,6 +4,7 @@ const categories = require('./categories'); const adminAuth = require('./admin/auth'); const adminConfig = require('./admin/config'); const adminGroups = require('./admin/groups'); +const adminRoles = require('./admin/roles'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); @@ -35,6 +36,7 @@ module.exports = { adminAuth, adminConfig, adminGroups, + adminRoles, keys, apiKeys, user, diff --git a/packages/api/src/admin/index.ts b/packages/api/src/admin/index.ts index d833c7e2b0..fe60f1d993 100644 --- a/packages/api/src/admin/index.ts +++ b/packages/api/src/admin/index.ts @@ -1,4 +1,6 @@ export { createAdminConfigHandlers } from './config'; export { createAdminGroupsHandlers } from './groups'; +export { createAdminRolesHandlers } from './roles'; export type { AdminConfigDeps } from './config'; export type { AdminGroupsDeps } from './groups'; +export type { AdminRolesDeps } from './roles'; diff --git a/packages/api/src/admin/roles.ts b/packages/api/src/admin/roles.ts new file mode 100644 index 0000000000..96594e8507 --- /dev/null +++ b/packages/api/src/admin/roles.ts @@ -0,0 +1,246 @@ +import { SystemRoles } from 'librechat-data-provider'; +import { logger } from '@librechat/data-schemas'; +import type { IRole, IUser } from '@librechat/data-schemas'; +import type { FilterQuery } from 'mongoose'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types/http'; + +interface RoleNameParams { + name: string; +} + +interface RoleMemberParams extends RoleNameParams { + userId: string; +} + +interface AdminMember { + userId: string; + name: string; + email: string; + avatarUrl?: string; + joinedAt: string; +} + +export interface AdminRolesDeps { + listRoles: () => Promise; + getRoleByName: ( + name: string, + fields?: string | string[] | null, + ) => Promise; + createRole: (roleData: Partial) => Promise; + updateRoleByName: (name: string, updates: Partial) => Promise; + updateAccessPermissions: ( + name: string, + perms: Record>, + roleData?: IRole, + ) => Promise; + deleteRole: (name: string) => Promise; + findUser: ( + criteria: FilterQuery, + fields?: string | string[] | null, + ) => Promise; + updateUser: (userId: string, data: Partial) => Promise; + countUsers: (filter?: FilterQuery) => Promise; + listUsersByRole: (roleName: string) => Promise; +} + +export function createAdminRolesHandlers(deps: AdminRolesDeps) { + const { + listRoles, + getRoleByName, + createRole, + updateRoleByName, + updateAccessPermissions, + deleteRole, + findUser, + updateUser, + countUsers, + } = 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 { + const { name, permissions } = req.body as { name?: string; permissions?: IRole['permissions'] }; + if (!name || typeof name !== 'string' || !name.trim()) { + return res.status(400).json({ error: 'name is required' }); + } + const role = await createRole({ + name: name.trim(), + permissions: permissions || {}, + }); + return res.status(201).json({ role }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to create role'; + logger.error('[adminRoles] createRole error:', error); + const status = message.includes('already exists') || message.includes('reserved') ? 409 : 500; + return res.status(status).json({ error: message }); + } + } + + async function updateRoleHandler(req: ServerRequest, res: Response) { + try { + const { name } = req.params as RoleNameParams; + const body = req.body as Partial; + + const existing = await getRoleByName(name); + if (!existing) { + return res.status(404).json({ error: 'Role not found' }); + } + + const updates: Partial = {}; + if (body.name !== undefined) { + updates.name = body.name; + } + + const role = await updateRoleByName(name, updates); + return res.status(200).json({ role }); + } catch (error) { + 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>; + }; + + if (!permissions || typeof permissions !== 'object') { + 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' }); + } + + const existing = await getRoleByName(name); + if (!existing) { + return res.status(404).json({ error: 'Role not found' }); + } + + await deleteRole(name); + 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' }); + } + + const members = await deps.listUsersByRole(name); + return res.status(200).json({ members }); + } 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' }); + } + + 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; + + 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' }); + } + + 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, + }; +}