🗨️ feat: Granular Prompt Permissions via ACL and Permission Bits

feat: Implement prompt permissions management and access control middleware

fix: agent deletion process to remove associated permissions and ACL entries

fix: Import Permissions for enhanced access control in GrantAccessDialog

feat: use PromptGroup for access control

- Added migration script for PromptGroup permissions, categorizing groups into global view access and private groups.
- Created unit tests for the migration script to ensure correct categorization and permission granting.
- Introduced middleware for checking access permissions on PromptGroups and prompts via their groups.
- Updated routes to utilize new access control middleware for PromptGroups.
- Enhanced access role definitions to include roles specific to PromptGroups.
- Modified ACL entry schema and types to accommodate PromptGroup resource type.
- Updated data provider to include new access role identifiers for PromptGroups.

feat: add generic access management dialogs and hooks for resource permissions

fix: remove duplicate imports in FileContext component

fix: remove duplicate mongoose dependency in package.json

feat: add access permissions handling for dynamic resource types and add promptGroup roles

feat: implement centralized role localization and update access role types

refactor: simplify author handling in prompt group routes and enhance ACL checks

feat: implement addPromptToGroup functionality and update PromptForm to use it

feat: enhance permission handling in ChatGroupItem, DashGroupItem, and PromptForm components

chore: rename migration script for prompt group permissions and update package.json scripts

chore: update prompt tests
This commit is contained in:
Danny Avila 2025-07-26 12:28:31 -04:00
parent 7e7e75714e
commit ae732b2ebc
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
46 changed files with 3505 additions and 408 deletions

View file

@ -8,7 +8,7 @@ import {
} from 'librechat-data-provider';
import type { AgentForm, AgentPanelProps } from '~/common';
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
import GrantAccessDialog from './Sharing/GrantAccessDialog';
import { GenericGrantAccessDialog } from '~/components/Sharing';
import { useUpdateAgentMutation } from '~/data-provider';
import AdvancedButton from './Advanced/AdvancedButton';
import VersionButton from './Version/VersionButton';
@ -80,10 +80,11 @@ export default function AgentFooter({
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisAgent) &&
hasAccessToShareAgents &&
!permissionsLoading && (
<GrantAccessDialog
agentDbId={agent?._id}
agentId={agent_id}
agentName={agent?.name ?? ''}
<GenericGrantAccessDialog
resourceDbId={agent?._id}
resourceId={agent_id}
resourceName={agent?.name ?? ''}
resourceType="agent"
/>
)}
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}

View file

@ -12,8 +12,6 @@ import {
DropdownPopup,
AttachmentIcon,
CircleHelpIcon,
AttachmentIcon,
CircleHelpIcon,
SharePointIcon,
HoverCardPortal,
HoverCardContent,

View file

@ -6,13 +6,13 @@ import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import type { AccessRole } from 'librechat-data-provider';
import type * as t from '~/common';
import { cn, getRoleLocalizationKeys } from '~/utils';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface AccessRolesPickerProps {
resourceType?: string;
selectedRoleId?: string;
onRoleChange: (roleId: string) => void;
selectedRoleId?: ACCESS_ROLE_IDS;
onRoleChange: (roleId: ACCESS_ROLE_IDS) => void;
className?: string;
}
@ -24,42 +24,17 @@ export default function AccessRolesPicker({
}: AccessRolesPickerProps) {
const localize = useLocalize();
const [isOpen, setIsOpen] = React.useState(false);
// Fetch access roles from API
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
// Helper function to get localized role name and description
const getLocalizedRoleInfo = (roleId: string) => {
switch (roleId) {
case 'agent_viewer':
return {
name: localize('com_ui_role_viewer'),
description: localize('com_ui_role_viewer_desc'),
};
case 'agent_editor':
return {
name: localize('com_ui_role_editor'),
description: localize('com_ui_role_editor_desc'),
};
case 'agent_manager':
return {
name: localize('com_ui_role_manager'),
description: localize('com_ui_role_manager_desc'),
};
case 'agent_owner':
return {
name: localize('com_ui_role_owner'),
description: localize('com_ui_role_owner_desc'),
};
default:
return {
name: localize('com_ui_unknown'),
description: localize('com_ui_unknown'),
};
}
/** Helper function to get localized role name and description */
const getLocalizedRoleInfo = (roleId: ACCESS_ROLE_IDS) => {
const keys = getRoleLocalizationKeys(roleId);
return {
name: localize(keys.name),
description: localize(keys.description),
};
};
// Find the currently selected role
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { ACCESS_ROLE_IDS, PermissionTypes } from 'librechat-data-provider';
import { ACCESS_ROLE_IDS, PermissionTypes, Permissions } from 'librechat-data-provider';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import {
useGetResourcePermissionsQuery,
@ -49,7 +49,7 @@ export default function GrantAccessDialog({
});
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
// Determine type filter based on permissions
/** Type filter based on permissions */
const peoplePickerTypeFilter = useMemo(() => {
if (canViewUsers && canViewGroups) {
return null; // Both types allowed

View file

@ -2,7 +2,8 @@ import React, { useState, useId } from 'react';
import * as Menu from '@ariakit/react/menu';
import { Button, DropdownPopup } from '@librechat/client';
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
import type { TPrincipal, TAccessRole } from 'librechat-data-provider';
import type { TPrincipal, TAccessRole, ACCESS_ROLE_IDS } from 'librechat-data-provider';
import { getRoleLocalizationKeys } from '~/utils';
import PrincipalAvatar from '../PrincipalAvatar';
import { useLocalize } from '~/hooks';
@ -97,8 +98,8 @@ export default function SelectedPrincipalsList({
}
interface RoleSelectorProps {
currentRole: string;
onRoleChange: (newRole: string) => void;
currentRole: ACCESS_ROLE_IDS;
onRoleChange: (newRole: ACCESS_ROLE_IDS) => void;
availableRoles: Omit<TAccessRole, 'resourceType'>[];
}
@ -107,19 +108,9 @@ function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelecto
const [isMenuOpen, setIsMenuOpen] = useState(false);
const localize = useLocalize();
const getLocalizedRoleName = (roleId: string) => {
switch (roleId) {
case 'agent_viewer':
return localize('com_ui_role_viewer');
case 'agent_editor':
return localize('com_ui_role_editor');
case 'agent_manager':
return localize('com_ui_role_manager');
case 'agent_owner':
return localize('com_ui_role_owner');
default:
return localize('com_ui_unknown');
}
const getLocalizedRoleName = (roleId: ACCESS_ROLE_IDS) => {
const keys = getRoleLocalizationKeys(roleId);
return localize(keys.name);
};
return (
@ -139,7 +130,6 @@ function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelecto
items={availableRoles?.map((role) => ({
id: role.accessRoleId,
label: getLocalizedRoleName(role.accessRoleId),
onClick: () => onRoleChange(role.accessRoleId),
}))}
menuId={menuId}

View file

@ -145,23 +145,44 @@ jest.mock('../AdminSettings', () => ({
jest.mock('../DeleteButton', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="delete-button" />),
}));
jest.mock('../Sharing/GrantAccessDialog', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="grant-access-dialog" />),
default: ({ agent_id }: { agent_id: string }) => (
<button data-testid="delete-button" data-agent-id={agent_id} title="Delete Agent" />
),
}));
jest.mock('../DuplicateAgent', () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="duplicate-agent" />),
default: ({ agent_id }: { agent_id: string }) => (
<button data-testid="duplicate-button" data-agent-id={agent_id} title="Duplicate Agent" />
),
}));
jest.mock('~/components', () => ({
Spinner: () => <div data-testid="spinner" />,
}));
jest.mock('~/components/Sharing', () => ({
GenericGrantAccessDialog: ({
resourceDbId,
resourceId,
resourceName,
resourceType,
}: {
resourceDbId: string;
resourceId: string;
resourceName: string;
resourceType: string;
}) => (
<div
data-testid="grant-access-dialog"
data-resource-db-id={resourceDbId}
data-resource-id={resourceId}
data-resource-name={resourceName}
data-resource-type={resourceType}
/>
),
}));
describe('AgentFooter', () => {
const mockUsers = {
regular: mockUser,