mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-05 15:27:20 +02:00
🔨 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:
parent
936936596b
commit
261941c05f
14 changed files with 462 additions and 82 deletions
155
api/server/routes/__tests__/roles.spec.js
Normal file
155
api/server/routes/__tests__/roles.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
64
client/src/hooks/useRoleSelector.ts
Normal file
64
client/src/hooks/useRoleSelector.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export enum QueryKeys {
|
|||
agentCategories = 'agentCategories',
|
||||
marketplaceAgents = 'marketplaceAgents',
|
||||
roles = 'roles',
|
||||
rolesList = 'rolesList',
|
||||
conversationTags = 'conversationTags',
|
||||
health = 'health',
|
||||
userTerms = 'userTerms',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue