🔨 fix: Custom Role Permissions (#12528)

* fix: Resolve custom role permissions not loading in frontend

Users assigned to custom roles (non-USER/ADMIN) had all permission
checks fail because AuthContext only fetched system role permissions.
The roles map keyed by USER/ADMIN never contained the custom role name,
so useHasAccess returned false for every feature gate.

- Fetch the user's custom role in AuthContext and include it in the
  roles map so useHasAccess can resolve permissions correctly
- Use encodeURIComponent instead of toLowerCase for role name URLs
  to preserve custom role casing through the API roundtrip
- Only uppercase system role names on the backend GET route; pass
  custom role names through as-is for exact DB lookup
- Allow users to fetch their own assigned role without READ_ROLES
  capability

* refactor: Normalize all role names to uppercase

Custom role names were stored in original casing, causing case-sensitivity
bugs across the stack — URL lowercasing, route uppercasing, and
case-sensitive DB lookups all conflicted for mixed-case custom roles.

Enforce uppercase normalization at every boundary:
- createRoleByName trims and uppercases the name before storage
- createRoleHandler uppercases before passing to createRoleByName
- All admin route handlers (get, update, delete, members, permissions)
  uppercase the :name URL param before DB lookups
- addRoleMemberHandler uppercases before setting user.role
- Startup migration (normalizeRoleNames) finds non-uppercase custom
  roles, renames them, and updates affected user.role values with
  collision detection

Legacy GET /api/roles/:roleName retains always-uppercase behavior.
Tests updated to expect uppercase role names throughout.

* fix: Use case-preserved role names with strict equality

Remove uppercase normalization — custom role names are stored and
compared exactly as the user sets them, with only trimming applied.
USER and ADMIN remain reserved case-insensitively via isSystemRoleName.

- Remove toUpperCase from createRoleByName, createRoleHandler, and
  all admin route handlers (get, update, delete, members, permissions)
- Remove toUpperCase from legacy GET and PUT routes in roles.js;
  the frontend now sends exact casing via encodeURIComponent
- Remove normalizeRoleNames startup migration
- Revert test expectations to original casing

* fix: Format useMemo dependency array for Prettier

* feat: Add custom role support to admin settings + review fixes

- Add backend tests for isOwnRole authorization gate on GET /api/roles/:roleName
- Add frontend tests for custom role detection and fetching in AuthContext
- Fix transient null permission flash by only spreading custom role once loaded
- Add isSystemRoleName helper to data-provider for case-insensitive system role detection
- Use sentinel value in useGetRole to avoid ghost cache entry from empty string
- Add useListRoles hook and listRoles data service for fetching all roles
- Update AdminSettingsDialog and PeoplePickerAdminSettings to dynamically
  list custom roles in the role dropdown, with proper fallback defaults

* fix: Address review findings for custom role permissions

- Add assertions to AuthContext test verifying custom role in roles map
- Fix empty array bypassing nullish coalescing fallback in role dropdowns
- Add null/undefined guard to isSystemRoleName helper
- Memoize role dropdown items to avoid unnecessary re-renders
- Apply sentinel pattern to useGetRole in admin settings for consistency
- Mark ListRolesResponse description as required to match schema

* fix: Prevent prototype pollution in role authorization gate

- Replace roleDefaults[roleName] with Object.hasOwn to prevent
  prototype chain bypass for names like constructor or __proto__
- Add dedicated rolesList query key to avoid cache collision when
  a custom role is named 'list'
- Add regression test for prototype property name authorization

* fix: Resolve Prettier formatting and unused variable lint errors

* fix: Address review findings for custom role permissions

- Add ADMIN self-read test documenting isOwnRole bypass behavior
- Guard save button while custom role data loads to prevent data loss
- Extract useRoleSelector hook eliminating ~55 lines of duplication
- Unify defaultValues/useEffect permission resolution (fixes inconsistency)
- Make ListRolesResponse.description and _id optional to match schema
- Fix vacuous test assertions to verify sentinel calls exist
- Only fetch userRole when user.role === USER (avoid unnecessary requests)
- Remove redundant empty string guard in custom role detection

* fix: Revert USER role fetch restriction to preserve admin settings

Admins need the USER role loaded in AuthContext.roles so the admin
settings dialog shows persisted USER permissions instead of defaults.

* fix: Remove unused useEffect import from useRoleSelector

* fix: Clean up useRoleSelector hook

- Use existing isCustom variable instead of re-calling isSystemRoleName
- Remove unused roles and availableRoleNames from return object

* fix: Address review findings for custom role permissions

- Use Set-based isSystemRoleName to auto-expand with future SystemRoles
- Add isCustomRoleError handling: guard useEffect reset and disable Save
- Remove resolvePermissions from hook return; use defaultValues in useEffect
  to eliminate redundant computation and stale-closure reset race
- Rename customRoleName to userRoleName in AuthContext for clarity

* fix: Request server-max roles for admin dropdown

listRoles now passes limit=200 (the server's MAX_PAGE_LIMIT) so the
admin role selector shows all roles instead of silently truncating
at the default page size of 50.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2026-04-03 10:24:11 -07:00 committed by GitHub
parent 936936596b
commit 261941c05f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 462 additions and 82 deletions

View file

@ -0,0 +1,155 @@
const express = require('express');
const request = require('supertest');
const { SystemRoles, roleDefaults } = require('librechat-data-provider');
const mockGetRoleByName = jest.fn();
const mockHasCapability = jest.fn();
jest.mock('~/server/middleware', () => ({
requireJwtAuth: (_req, _res, next) => next(),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: (...args) => mockHasCapability(...args),
requireCapability: () => (_req, _res, next) => next(),
}));
jest.mock('~/models', () => ({
getRoleByName: (...args) => mockGetRoleByName(...args),
updateRoleByName: jest.fn(),
}));
const rolesRouter = require('../roles');
function createApp(user) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = user;
next();
});
app.use('/api/roles', rolesRouter);
return app;
}
const staffRole = {
name: 'STAFF',
permissions: {
PROMPTS: { USE: true, CREATE: false },
},
};
const userRole = roleDefaults[SystemRoles.USER];
const adminRole = roleDefaults[SystemRoles.ADMIN];
beforeEach(() => {
jest.clearAllMocks();
mockHasCapability.mockResolvedValue(false);
mockGetRoleByName.mockResolvedValue(null);
});
describe('GET /api/roles/:roleName — isOwnRole authorization', () => {
it('allows a custom role user to fetch their own role', async () => {
mockGetRoleByName.mockResolvedValue(staffRole);
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/STAFF');
expect(res.status).toBe(200);
expect(res.body.name).toBe('STAFF');
expect(mockGetRoleByName).toHaveBeenCalledWith('STAFF', '-_id -__v');
});
it('returns 403 when a custom role user requests a different custom role', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/MANAGER');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
});
it('returns 403 when a custom role user requests ADMIN', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/ADMIN');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
});
it('allows USER to fetch the USER role (roleDefaults key)', async () => {
mockGetRoleByName.mockResolvedValue(userRole);
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
expect(res.status).toBe(200);
});
it('returns 403 when USER requests the ADMIN role', async () => {
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
expect(res.status).toBe(403);
});
it('allows ADMIN user to fetch their own ADMIN role via isOwnRole', async () => {
mockHasCapability.mockResolvedValue(false);
mockGetRoleByName.mockResolvedValue(adminRole);
const app = createApp({ id: 'u1', role: SystemRoles.ADMIN });
const res = await request(app).get(`/api/roles/${SystemRoles.ADMIN}`);
expect(res.status).toBe(200);
});
it('allows any user with READ_ROLES capability to fetch any role', async () => {
mockHasCapability.mockResolvedValue(true);
mockGetRoleByName.mockResolvedValue(staffRole);
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get('/api/roles/STAFF');
expect(res.status).toBe(200);
expect(res.body.name).toBe('STAFF');
});
it('returns 404 when the requested role does not exist', async () => {
mockGetRoleByName.mockResolvedValue(null);
const app = createApp({ id: 'u1', role: 'GHOST' });
const res = await request(app).get('/api/roles/GHOST');
expect(res.status).toBe(404);
});
it('returns 500 when getRoleByName throws', async () => {
mockGetRoleByName.mockRejectedValue(new Error('db error'));
const app = createApp({ id: 'u1', role: SystemRoles.USER });
const res = await request(app).get(`/api/roles/${SystemRoles.USER}`);
expect(res.status).toBe(500);
});
it('returns 403 for prototype property names like constructor (no prototype pollution)', async () => {
const app = createApp({ id: 'u1', role: 'STAFF' });
const res = await request(app).get('/api/roles/constructor');
expect(res.status).toBe(403);
expect(mockGetRoleByName).not.toHaveBeenCalled();
});
it('treats hasCapability failure as no capability (does not 500)', async () => {
mockHasCapability.mockRejectedValue(new Error('capability check failed'));
const app = createApp({ id: 'u1', role: 'STAFF' });
mockGetRoleByName.mockResolvedValue(staffRole);
const res = await request(app).get('/api/roles/STAFF');
expect(res.status).toBe(200);
});
});

View file

@ -71,9 +71,7 @@ const createPermissionUpdateHandler = (permissionKey) => {
const config = permissionConfigs[permissionKey];
return async (req, res) => {
const { roleName: _r } = req.params;
// TODO: TEMP, use a better parsing for roleName
const roleName = _r.toUpperCase();
const { roleName } = req.params;
const updates = req.body;
try {
@ -110,9 +108,7 @@ const createPermissionUpdateHandler = (permissionKey) => {
* Get a specific role by name
*/
router.get('/:roleName', async (req, res) => {
const { roleName: _r } = req.params;
// TODO: TEMP, use a better parsing for roleName
const roleName = _r.toUpperCase();
const { roleName } = req.params;
try {
let hasReadRoles = false;
@ -121,7 +117,9 @@ router.get('/:roleName', async (req, res) => {
} catch (err) {
logger.warn(`[GET /roles/:roleName] capability check failed: ${err.message}`);
}
if (!hasReadRoles && (roleName === SystemRoles.ADMIN || !roleDefaults[roleName])) {
const isOwnRole = req.user?.role === roleName;
const isDefaultRole = Object.hasOwn(roleDefaults, roleName);
if (!hasReadRoles && !isOwnRole && (roleName === SystemRoles.ADMIN || !isDefaultRole)) {
return res.status(403).send({ message: 'Unauthorized' });
}

View file

@ -1,8 +1,8 @@
import { useMemo, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import { Permissions, SystemRoles, PermissionTypes } from 'librechat-data-provider';
import {
Button,
Switch,
@ -15,7 +15,7 @@ import {
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { useUpdatePeoplePickerPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize, useAuthContext, useRoleSelector } from '~/hooks';
type FormValues = {
[Permissions.VIEW_USERS]: boolean;
@ -70,7 +70,7 @@ const LabelController: React.FC<LabelControllerProps> = ({
const PeoplePickerAdminSettings = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { user, roles } = useAuthContext();
const { user } = useAuthContext();
const { mutate, isLoading } = useUpdatePeoplePickerPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
@ -81,15 +81,14 @@ const PeoplePickerAdminSettings = () => {
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
const rolePerms = roles?.[selectedRole]?.permissions;
if (rolePerms) {
return rolePerms[PermissionTypes.PEOPLE_PICKER];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.PEOPLE_PICKER];
}, [roles, selectedRole]);
const {
selectedRole,
isSelectedCustomRole,
isCustomRoleLoading,
isCustomRoleError,
defaultValues,
roleDropdownItems,
} = useRoleSelector(PermissionTypes.PEOPLE_PICKER);
const {
reset,
@ -100,17 +99,15 @@ const PeoplePickerAdminSettings = () => {
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
defaultValues: defaultValues as FormValues,
});
useEffect(() => {
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.PEOPLE_PICKER];
if (value) {
reset(value);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.PEOPLE_PICKER]);
if (isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError)) {
return;
}
}, [roles, selectedRole, reset]);
reset(defaultValues as FormValues);
}, [isSelectedCustomRole, isCustomRoleLoading, isCustomRoleError, defaultValues, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
@ -138,21 +135,6 @@ const PeoplePickerAdminSettings = () => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
return (
<OGDialog>
<OGDialogTrigger asChild>
@ -179,7 +161,7 @@ const PeoplePickerAdminSettings = () => {
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
<Ariakit.MenuButton className="inline-flex min-w-[6rem] items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
@ -207,7 +189,11 @@ const PeoplePickerAdminSettings = () => {
<button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
disabled={
isSubmitting ||
isLoading ||
(isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError))
}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}

View file

@ -1,8 +1,8 @@
import { useMemo, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import { Permissions, SystemRoles } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTitle,
@ -13,8 +13,9 @@ import {
DropdownPopup,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import type { PermissionTypes } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks/useLocalize';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize, useAuthContext, useRoleSelector } from '~/hooks';
type FormValues = Record<Permissions, boolean>;
@ -34,7 +35,7 @@ export interface AdminSettingsDialogProps {
menuId: string;
/** Mutation function and loading state from the permission update hook */
mutation: {
mutate: (data: { roleName: SystemRoles; updates: Record<Permissions, boolean> }) => void;
mutate: (data: { roleName: string; updates: Record<Permissions, boolean> }) => void;
isLoading: boolean;
};
/** Whether to show the admin access warning when ADMIN role and USE permission is displayed (default: true) */
@ -108,18 +109,18 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
extraContent,
}) => {
const localize = useLocalize();
const { user, roles } = useAuthContext();
const { user } = useAuthContext();
const { mutate, isLoading } = mutation;
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]?.permissions) {
return roles[selectedRole]?.permissions[permissionType];
}
return roleDefaults[selectedRole].permissions[permissionType];
}, [roles, selectedRole, permissionType]);
const {
selectedRole,
isSelectedCustomRole,
isCustomRoleLoading,
isCustomRoleError,
defaultValues,
roleDropdownItems,
} = useRoleSelector(permissionType);
const {
reset,
@ -134,12 +135,11 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
});
useEffect(() => {
if (roles?.[selectedRole]?.permissions?.[permissionType]) {
reset(roles[selectedRole]?.permissions[permissionType]);
} else {
reset(roleDefaults[selectedRole].permissions[permissionType]);
if (isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError)) {
return;
}
}, [roles, selectedRole, reset, permissionType]);
reset(defaultValues);
}, [isSelectedCustomRole, isCustomRoleLoading, isCustomRoleError, defaultValues, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
@ -149,21 +149,6 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
const defaultTrigger = (
<Button
size="sm"
@ -199,7 +184,7 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
<Ariakit.MenuButton className="inline-flex min-w-[6rem] items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
@ -257,7 +242,11 @@ const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
<Button
type="submit"
variant="submit"
disabled={isSubmitting || isLoading}
disabled={
isSubmitting ||
isLoading ||
(isSelectedCustomRole && (isCustomRoleLoading || isCustomRoleError))
}
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}

View file

@ -29,6 +29,18 @@ export const useGetRole = (
});
};
export const useListRoles = (
config?: UseQueryOptions<t.ListRolesResponse>,
): QueryObserverResult<t.ListRolesResponse> => {
return useQuery<t.ListRolesResponse>([QueryKeys.rolesList], () => dataService.listRoles(), {
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
});
};
export const useUpdatePromptPermissionsMutation = (
options?: t.UpdatePromptPermOptions,
): UseMutationResult<

View file

@ -14,6 +14,7 @@ import {
apiBaseUrl,
SystemRoles,
setTokenHeader,
isSystemRoleName,
buildLoginRedirectUrl,
} from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
@ -47,12 +48,18 @@ const AuthContextProvider = ({
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const setQueriesEnabled = useSetRecoilState<boolean>(store.queriesEnabled);
const userRoleName = user?.role ?? '';
const isCustomRole = isAuthenticated && !!user?.role && !isSystemRoleName(user.role);
const { data: userRole = null } = useGetRole(SystemRoles.USER, {
enabled: !!(isAuthenticated && (user?.role ?? '')),
});
const { data: adminRole = null } = useGetRole(SystemRoles.ADMIN, {
enabled: !!(isAuthenticated && user?.role === SystemRoles.ADMIN),
});
const { data: customRole = null } = useGetRole(isCustomRole ? userRoleName : '_', {
enabled: isCustomRole,
});
const navigate = useNavigate();
@ -267,11 +274,22 @@ const AuthContextProvider = ({
roles: {
[SystemRoles.USER]: userRole,
[SystemRoles.ADMIN]: adminRole,
...(isCustomRole && customRole ? { [userRoleName]: customRole } : {}),
},
isAuthenticated,
}),
[user, error, isAuthenticated, token, userRole, adminRole],
[
user,
error,
isAuthenticated,
token,
userRole,
adminRole,
isCustomRole,
userRoleName,
customRole,
],
);
return <AuthContext.Provider value={memoedValue}>{children}</AuthContext.Provider>;

View file

@ -64,13 +64,20 @@ jest.mock('~/data-provider', () => ({
error: null,
})),
useGetRole: jest.fn(() => ({ data: null })),
useListRoles: jest.fn(() => ({ data: undefined })),
}));
const authConfig: TAuthConfig = { loginRedirect: '/login', test: true };
function TestConsumer() {
const ctx = useAuthContext();
return <div data-testid="consumer" data-authenticated={ctx.isAuthenticated} />;
return (
<div
data-testid="consumer"
data-authenticated={ctx.isAuthenticated}
data-roles={JSON.stringify(ctx.roles ?? {})}
/>
);
}
function renderProvider() {
@ -445,3 +452,130 @@ describe('AuthContextProvider — logout error handling', () => {
jest.useRealTimers();
});
});
describe('AuthContextProvider — custom role detection and fetching', () => {
const mockUseGetRole = jest.requireMock('~/data-provider').useGetRole;
const staffPermissions = {
name: 'STAFF',
permissions: { PROMPTS: { USE: true, CREATE: false } },
};
beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
window.history.replaceState({}, '', '/');
});
it('calls useGetRole with the custom role name and enabled: true for custom role users', () => {
jest.useFakeTimers();
renderProviderLive();
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
unknown,
{ onSuccess: (data: unknown) => void },
];
act(() => {
refreshOptions.onSuccess({ user: { id: '1', role: 'STAFF' }, token: 'tok' });
});
act(() => {
jest.advanceTimersByTime(100);
});
const staffCalls = mockUseGetRole.mock.calls.filter(([name]: [string]) => name === 'STAFF');
expect(staffCalls.length).toBeGreaterThan(0);
const lastStaffCall = staffCalls[staffCalls.length - 1];
expect(lastStaffCall[1]).toEqual(expect.objectContaining({ enabled: true }));
jest.useRealTimers();
});
it('calls useGetRole with enabled: false for USER role users', () => {
jest.useFakeTimers();
renderProviderLive();
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
unknown,
{ onSuccess: (data: unknown) => void },
];
act(() => {
refreshOptions.onSuccess({ user: { id: '1', role: 'USER' }, token: 'tok' });
});
act(() => {
jest.advanceTimersByTime(100);
});
const sentinelCalls = mockUseGetRole.mock.calls.filter(([name]: [string]) => name === '_');
expect(sentinelCalls.length).toBeGreaterThan(0);
for (const call of sentinelCalls) {
expect(call[1]).toEqual(expect.objectContaining({ enabled: false }));
}
jest.useRealTimers();
});
it('calls useGetRole with enabled: false for ADMIN role users', () => {
jest.useFakeTimers();
renderProviderLive();
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
unknown,
{ onSuccess: (data: unknown) => void },
];
act(() => {
refreshOptions.onSuccess({ user: { id: '1', role: 'ADMIN' }, token: 'tok' });
});
act(() => {
jest.advanceTimersByTime(100);
});
const sentinelCalls = mockUseGetRole.mock.calls.filter(([name]: [string]) => name === '_');
expect(sentinelCalls.length).toBeGreaterThan(0);
for (const call of sentinelCalls) {
expect(call[1]).toEqual(expect.objectContaining({ enabled: false }));
}
jest.useRealTimers();
});
it('includes custom role data in the roles context map when loaded', () => {
jest.useFakeTimers();
mockUseGetRole.mockImplementation((name: string, opts?: { enabled?: boolean }) => {
if (name === 'STAFF' && opts?.enabled) {
return { data: staffPermissions };
}
return { data: null };
});
const { getByTestId } = renderProviderLive();
const [, refreshOptions] = mockRefreshMutate.mock.calls[0] as [
unknown,
{ onSuccess: (data: unknown) => void },
];
act(() => {
refreshOptions.onSuccess({ user: { id: '1', role: 'STAFF' }, token: 'tok' });
});
act(() => {
jest.advanceTimersByTime(100);
});
const rolesAttr = getByTestId('consumer').getAttribute('data-roles') ?? '{}';
const roles = JSON.parse(rolesAttr);
expect(roles).toHaveProperty('STAFF');
expect(roles.STAFF).toEqual(staffPermissions);
mockUseGetRole.mockReturnValue({ data: null });
jest.useRealTimers();
});
});

View file

@ -37,3 +37,4 @@ export { default as useTextToSpeech } from './Input/useTextToSpeech';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';
export { default as useLocalizedConfig } from './useLocalizedConfig';
export { default as useResourcePermissions } from './useResourcePermissions';
export { useRoleSelector } from './useRoleSelector';

View file

@ -0,0 +1,64 @@
import { useMemo, useState, useCallback } from 'react';
import { SystemRoles, roleDefaults, isSystemRoleName } from 'librechat-data-provider';
import type { PermissionTypes, TRole } from 'librechat-data-provider';
import { useGetRole, useListRoles } from '~/data-provider';
import { useAuthContext } from './AuthContext';
export function useRoleSelector(permissionType: PermissionTypes) {
const { user, roles } = useAuthContext();
const [selectedRole, setSelectedRole] = useState<string>(SystemRoles.USER);
const { data: roleList } = useListRoles({
enabled: user?.role === SystemRoles.ADMIN,
});
const isSelectedCustomRole = !isSystemRoleName(selectedRole);
const {
data: customRoleData = null,
isLoading: isCustomRoleLoading,
isError: isCustomRoleError,
} = useGetRole(isSelectedCustomRole ? selectedRole : '_', { enabled: isSelectedCustomRole });
const resolvePermissions = useCallback(
(role: string, customData: TRole | null) => {
const isCustom = !isSystemRoleName(role);
if (isCustom && customData?.permissions?.[permissionType]) {
return customData.permissions[permissionType];
}
if (!isCustom && roles?.[role]?.permissions?.[permissionType]) {
return roles[role]?.permissions[permissionType];
}
const defaults = !isCustom
? roleDefaults[role as SystemRoles]
: roleDefaults[SystemRoles.USER];
return defaults.permissions[permissionType];
},
[roles, permissionType],
);
const defaultValues = useMemo(
() => resolvePermissions(selectedRole, customRoleData),
[resolvePermissions, selectedRole, customRoleData],
);
const availableRoleNames = useMemo(() => {
const names = roleList?.roles?.map((r) => r.name);
return names?.length ? names : [SystemRoles.USER, SystemRoles.ADMIN];
}, [roleList]);
const roleDropdownItems = useMemo(
() => availableRoleNames.map((role) => ({ label: role, onClick: () => setSelectedRole(role) })),
[availableRoleNames],
);
return {
selectedRole,
setSelectedRole,
isSelectedCustomRole,
isCustomRoleLoading,
isCustomRoleError,
defaultValues,
roleDropdownItems,
};
}

View file

@ -361,7 +361,8 @@ export const getAllPromptGroups = () => `${prompts()}/all`;
/* Roles */
export const roles = () => `${BASE_URL}/api/roles`;
export const getRole = (roleName: string) => `${roles()}/${roleName.toLowerCase()}`;
export const adminRoles = () => `${BASE_URL}/api/admin/roles`;
export const getRole = (roleName: string) => `${roles()}/${encodeURIComponent(roleName)}`;
export const updatePromptPermissions = (roleName: string) => `${getRole(roleName)}/prompts`;
export const updateMemoryPermissions = (roleName: string) => `${getRole(roleName)}/memories`;
export const updateAgentPermissions = (roleName: string) => `${getRole(roleName)}/agents`;

View file

@ -866,6 +866,10 @@ export function getRandomPrompts(
}
/* Roles */
export function listRoles(): Promise<q.ListRolesResponse> {
return request.get(`${endpoints.adminRoles()}?limit=200`);
}
export function getRole(roleName: string): Promise<r.TRole> {
return request.get(endpoints.getRole(roleName));
}

View file

@ -46,6 +46,7 @@ export enum QueryKeys {
agentCategories = 'agentCategories',
marketplaceAgents = 'marketplaceAgents',
roles = 'roles',
rolesList = 'rolesList',
conversationTags = 'conversationTags',
health = 'health',
userTerms = 'userTerms',

View file

@ -111,6 +111,16 @@ const defaultRolesSchema = z.object({
}),
});
const systemRoleSet = new Set(Object.values(SystemRoles).map((r) => r.toUpperCase()));
/** Case-insensitive check for reserved system role names. */
export function isSystemRoleName(name: string | undefined | null): boolean {
if (!name) {
return false;
}
return systemRoleSet.has(name.toUpperCase());
}
export const roleDefaults = defaultRolesSchema.parse({
[SystemRoles.ADMIN]: {
name: SystemRoles.ADMIN,

View file

@ -172,6 +172,13 @@ export type AccessRole = {
export type AccessRolesResponse = AccessRole[];
export type ListRolesResponse = {
roles: Array<{ _id?: string; name: string; description?: string }>;
total: number;
limit: number;
offset?: number;
};
export interface MCPServerStatus {
requiresOAuth: boolean;
connectionState: 'disconnected' | 'connecting' | 'connected' | 'error';