🗨️ 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

@ -6,16 +6,16 @@ import { Menu, Rocket } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import { useParams, useOutletContext } from 'react-router-dom';
import { Button, Skeleton, useToastContext } from '@librechat/client';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import { Permissions, PermissionTypes, PERMISSION_BITS } from 'librechat-data-provider';
import type { TCreatePrompt, TPrompt, TPromptGroup } from 'librechat-data-provider';
import {
useGetPrompts,
useCreatePrompt,
useGetPromptGroup,
useAddPromptToGroup,
useUpdatePromptGroup,
useMakePromptProduction,
} from '~/data-provider';
import { useAuthContext, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
import { useResourcePermissions, usePromptGroupsNav, useHasAccess, useLocalize } from '~/hooks';
import CategorySelector from './Groups/CategorySelector';
import NoPromptGroup from './Groups/NoPromptGroup';
import PromptVariables from './PromptVariables';
@ -39,6 +39,7 @@ interface RightPanelProps {
selectionIndex: number;
selectedPromptId?: string;
isLoadingPrompts: boolean;
canEdit: boolean;
setSelectionIndex: React.Dispatch<React.SetStateAction<number>>;
}
@ -49,6 +50,7 @@ const RightPanel = React.memo(
selectedPrompt,
selectedPromptId,
isLoadingPrompts,
canEdit,
selectionIndex,
setSelectionIndex,
}: RightPanelProps) => {
@ -84,16 +86,19 @@ const RightPanel = React.memo(
<div className="mb-2 flex flex-col lg:flex-row lg:items-center lg:justify-center lg:gap-x-2 xl:flex-row xl:space-y-0">
<CategorySelector
currentCategory={groupCategory}
onValueChange={(value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
onValueChange={
canEdit
? (value) =>
updateGroupMutation.mutate({
id: groupId,
payload: { name: groupName, category: value },
})
: undefined
}
/>
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
{editorMode === PromptsEditorMode.ADVANCED && (
{editorMode === PromptsEditorMode.ADVANCED && canEdit && (
<Button
variant="submit"
size="sm"
@ -115,7 +120,8 @@ const RightPanel = React.memo(
isLoadingGroup ||
!selectedPrompt ||
selectedPrompt._id === group?.productionId ||
makeProductionMutation.isLoading
makeProductionMutation.isLoading ||
!canEdit
}
>
<Rocket className="size-5 cursor-pointer text-white" />
@ -154,7 +160,6 @@ RightPanel.displayName = 'RightPanel';
const PromptForm = () => {
const params = useParams();
const localize = useLocalize();
const { user } = useAuthContext();
const { showToast } = useToastContext();
const alwaysMakeProd = useRecoilValue(store.alwaysMakeProd);
const promptId = params.promptId || '';
@ -175,7 +180,14 @@ const PromptForm = () => {
{ enabled: !!promptId },
);
const isOwner = useMemo(() => (user && group ? user.id === group.author : false), [user, group]);
// Check permissions for the promptGroup
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'promptGroup',
group?._id || '',
);
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
const canView = hasPermission(PERMISSION_BITS.VIEW);
const methods = useForm({
defaultValues: {
@ -206,13 +218,12 @@ const PromptForm = () => {
});
const makeProductionMutation = useMakePromptProduction();
const createPromptMutation = useCreatePrompt({
const addPromptToGroupMutation = useAddPromptToGroup({
onMutate: (variables) => {
reset(
{
prompt: variables.prompt.prompt,
category: variables.group ? variables.group.category : '',
category: group?.category || '',
},
{ keepDirtyValues: true },
);
@ -228,14 +239,17 @@ const PromptForm = () => {
reset({
prompt: data.prompt.prompt,
promptName: data.group ? data.group.name : '',
category: data.group ? data.group.category : '',
promptName: group?.name || '',
category: group?.category || '',
});
},
});
const onSave = useCallback(
(value: string) => {
if (!canEdit) {
return;
}
if (!value) {
// TODO: show toast, cannot be empty.
return;
@ -243,10 +257,17 @@ const PromptForm = () => {
if (!selectedPrompt) {
return;
}
const groupId = selectedPrompt.groupId || group?._id;
if (!groupId) {
console.error('No groupId available');
return;
}
const tempPrompt: TCreatePrompt = {
prompt: {
type: selectedPrompt.type ?? 'text',
groupId: selectedPrompt.groupId ?? '',
groupId: groupId,
prompt: value,
},
};
@ -255,9 +276,10 @@ const PromptForm = () => {
return;
}
createPromptMutation.mutate(tempPrompt);
// We're adding to an existing group, so use the addPromptToGroup mutation
addPromptToGroupMutation.mutate({ ...tempPrompt, groupId });
},
[selectedPrompt, createPromptMutation],
[selectedPrompt, group, addPromptToGroupMutation, canEdit],
);
const handleLoadingComplete = useCallback(() => {
@ -268,11 +290,11 @@ const PromptForm = () => {
}, [isLoadingGroup, isLoadingPrompts]);
useEffect(() => {
if (prevIsEditingRef.current && !isEditing) {
if (prevIsEditingRef.current && !isEditing && canEdit) {
handleSubmit((data) => onSave(data.prompt))();
}
prevIsEditingRef.current = isEditing;
}, [isEditing, onSave, handleSubmit]);
}, [isEditing, onSave, handleSubmit, canEdit]);
useEffect(() => {
handleLoadingComplete();
@ -334,16 +356,19 @@ const PromptForm = () => {
return <SkeletonForm />;
}
if (!isOwner && groupsQuery.data && user?.role !== SystemRoles.ADMIN) {
// Show read-only view if user doesn't have edit permission
if (!canEdit && !permissionsLoading && groupsQuery.data) {
const fetchedPrompt = findPromptGroup(
groupsQuery.data,
(group) => group._id === params.promptId,
);
if (!fetchedPrompt) {
if (!fetchedPrompt && !canView) {
return <NoPromptGroup />;
}
return <PromptDetails group={fetchedPrompt} />;
if (fetchedPrompt || group) {
return <PromptDetails group={fetchedPrompt || group} />;
}
}
if (!group || group._id == null) {
@ -373,10 +398,13 @@ const PromptForm = () => {
<PromptName
name={groupName}
onSave={(value) => {
if (!group._id) {
if (!canEdit || !group._id) {
return;
}
updateGroupMutation.mutate({ id: group._id, payload: { name: value } });
updateGroupMutation.mutate({
id: group._id,
payload: { name: value },
});
}}
/>
<div className="flex-1" />
@ -398,6 +426,7 @@ const PromptForm = () => {
selectionIndex={selectionIndex}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
)}
@ -409,15 +438,21 @@ const PromptForm = () => {
<Skeleton className="h-96" aria-live="polite" />
) : (
<div className="mb-2 flex h-full flex-col gap-4">
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
<PromptEditor
name="prompt"
isEditing={isEditing}
setIsEditing={(value) => canEdit && setIsEditing(value)}
/>
<PromptVariables promptText={promptText} />
<Description
initialValue={group.oneliner ?? ''}
onValueChange={handleUpdateOneliner}
onValueChange={canEdit ? handleUpdateOneliner : undefined}
disabled={!canEdit}
/>
<Command
initialValue={group.command ?? ''}
onValueChange={handleUpdateCommand}
onValueChange={canEdit ? handleUpdateCommand : undefined}
disabled={!canEdit}
/>
</div>
)}
@ -432,6 +467,7 @@ const PromptForm = () => {
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>
@ -471,6 +507,7 @@ const PromptForm = () => {
selectedPrompt={selectedPrompt}
selectedPromptId={selectedPromptId}
isLoadingPrompts={isLoadingPrompts}
canEdit={canEdit}
setSelectionIndex={setSelectionIndex}
/>
</div>