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

@ -7,8 +7,9 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@librechat/client';
import { PERMISSION_BITS } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks';
import { useLocalize, useSubmitMessage, useCustomLink, useResourcePermissions } from '~/hooks';
import VariableDialog from '~/components/Prompts/Groups/VariableDialog';
import PreviewPrompt from '~/components/Prompts/PreviewPrompt';
import ListCard from '~/components/Prompts/Groups/ListCard';
@ -22,7 +23,6 @@ function ChatGroupItem({
instanceProjectId?: string;
}) {
const localize = useLocalize();
const { user } = useAuthContext();
const { submitPrompt } = useSubmitMessage();
const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false);
const [isVariableDialogOpen, setVariableDialogOpen] = useState(false);
@ -32,7 +32,10 @@ function ChatGroupItem({
() => instanceProjectId != null && group.projectIds?.includes(instanceProjectId),
[group, instanceProjectId],
);
const isOwner = useMemo(() => user?.id === group.author, [user, group]);
// Check permissions for the promptGroup
const { hasPermission } = useResourcePermissions('promptGroup', group._id || '');
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
const onCardClick: React.MouseEventHandler<HTMLButtonElement> = () => {
const text = group.productionPrompt?.prompt;
@ -108,10 +111,10 @@ function ChatGroupItem({
<TextSearch className="mr-2 h-4 w-4" aria-hidden="true" />
<span>{localize('com_ui_preview')}</span>
</DropdownMenuItem>
{isOwner && (
{canEdit && (
<DropdownMenuGroup>
<DropdownMenuItem
disabled={!isOwner}
disabled={!canEdit}
className="cursor-pointer rounded-lg text-text-secondary hover:bg-surface-hover focus:bg-surface-hover disabled:cursor-not-allowed"
onClick={(e) => {
e.stopPropagation();

View file

@ -1,7 +1,7 @@
import { memo, useState, useRef, useMemo, useCallback, KeyboardEvent } from 'react';
import { EarthIcon, Pen } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { SystemRoles, type TPromptGroup } from 'librechat-data-provider';
import { PERMISSION_BITS, type TPromptGroup } from 'librechat-data-provider';
import {
Input,
Label,
@ -13,7 +13,7 @@ import {
} from '@librechat/client';
import { useDeletePromptGroup, useUpdatePromptGroup } from '~/data-provider';
import CategoryIcon from '~/components/Prompts/Groups/CategoryIcon';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize, useResourcePermissions } from '~/hooks';
import { cn } from '~/utils';
interface DashGroupItemProps {
@ -25,12 +25,14 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
const params = useParams();
const navigate = useNavigate();
const localize = useLocalize();
const { user } = useAuthContext();
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [nameInputValue, setNameInputValue] = useState(group.name);
const isOwner = useMemo(() => user?.id === group.author, [user?.id, group.author]);
const { hasPermission } = useResourcePermissions('promptGroup', group._id || '');
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
const canDelete = hasPermission(PERMISSION_BITS.DELETE);
const isGlobalGroup = useMemo(
() => instanceProjectId && group.projectIds?.includes(instanceProjectId),
[group.projectIds, instanceProjectId],
@ -105,78 +107,78 @@ function DashGroupItemComponent({ group, instanceProjectId }: DashGroupItemProps
aria-label={localize('com_ui_global_group')}
/>
)}
{(isOwner || user?.role === SystemRoles.ADMIN) && (
<>
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="ghost"
onClick={(e) => e.stopPropagation()}
className="h-8 w-8 p-0 hover:bg-surface-hover"
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
>
<Pen className="icon-sm text-text-primary" aria-hidden="true" />
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_rename_prompt')}
className="w-11/12 max-w-lg"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Input
value={nameInputValue}
onChange={(e) => setNameInputValue(e.target.value)}
className="w-full"
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
/>
</div>
{canEdit && (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="ghost"
onClick={(e) => e.stopPropagation()}
className="h-8 w-8 p-0 hover:bg-surface-hover"
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
>
<Pen className="icon-sm text-text-primary" aria-hidden="true" />
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_rename_prompt')}
className="w-11/12 max-w-lg"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Input
value={nameInputValue}
onChange={(e) => setNameInputValue(e.target.value)}
className="w-full"
aria-label={localize('com_ui_rename_prompt') + ' ' + group.name}
/>
</div>
}
selection={{
selectHandler: handleSaveRename,
selectClasses:
'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit',
selectText: localize('com_ui_save'),
isLoading,
}}
/>
</OGDialog>
</div>
}
selection={{
selectHandler: handleSaveRename,
selectClasses:
'bg-surface-submit hover:bg-surface-submit-hover text-white disabled:hover:bg-surface-submit',
selectText: localize('com_ui_save'),
isLoading,
}}
/>
</OGDialog>
)}
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={(e) => e.stopPropagation()}
aria-label={localize('com_ui_delete_prompt') + ' ' + group.name}
>
<TrashIcon className="icon-sm text-text-primary" aria-hidden="true" />
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-lg"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{group.name}</strong>
</Label>
</div>
{canDelete && (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={(e) => e.stopPropagation()}
aria-label={localize('com_ui_delete_prompt') + ' ' + group.name}
>
<TrashIcon className="icon-sm text-text-primary" aria-hidden="true" />
</Button>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_prompt')}
className="w-11/12 max-w-lg"
main={
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="confirm-delete" className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} <strong>{group.name}</strong>
</Label>
</div>
}
selection={{
selectHandler: triggerDelete,
selectClasses:
'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</>
</div>
}
selection={{
selectHandler: triggerDelete,
selectClasses:
'bg-red-600 dark:bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
)}
</div>
</div>

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>

View file

@ -1,90 +1,57 @@
import React, { useEffect, useMemo } from 'react';
import React from 'react';
import { Share2Icon } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type {
TPromptGroup,
TStartupConfig,
TUpdatePromptGroupPayload,
SystemRoles,
Permissions,
PermissionTypes,
PERMISSION_BITS,
} from 'librechat-data-provider';
import { useUpdatePromptGroup, useGetStartupConfig } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { Button } from '@librechat/client';
import type { TPromptGroup } from 'librechat-data-provider';
import { useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
import { GenericGrantAccessDialog } from '~/components/Sharing';
type FormValues = {
[Permissions.SHARED_GLOBAL]: boolean;
};
const SharePrompt = React.memo(
({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => {
const { user } = useAuthContext();
const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: boolean }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateGroup = useUpdatePromptGroup();
const { data: startupConfig = {} as TStartupConfig, isFetching } = useGetStartupConfig();
const { instanceProjectId } = startupConfig;
const groupIsGlobal = useMemo(
() => ((group?.projectIds ?? []) as string[]).includes(instanceProjectId as string),
[group, instanceProjectId],
);
const {
control,
setValue,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: {
[Permissions.SHARED_GLOBAL]: groupIsGlobal,
},
});
useEffect(() => {
setValue(Permissions.SHARED_GLOBAL, groupIsGlobal);
}, [groupIsGlobal, setValue]);
if (group == null || !instanceProjectId) {
return null;
}
const onSubmit = (data: FormValues) => {
const groupId = group._id ?? '';
if (groupId === '' || !instanceProjectId) {
return;
}
if (data[Permissions.SHARED_GLOBAL] === true && groupIsGlobal) {
showToast({
message: localize('com_ui_prompt_already_shared_to_all'),
status: 'info',
});
return;
}
const payload = {} as TUpdatePromptGroupPayload;
if (data[Permissions.SHARED_GLOBAL] === true) {
payload.projectIds = [startupConfig.instanceProjectId];
} else {
payload.removeProjectIds = [startupConfig.instanceProjectId];
}
updateGroup.mutate({
id: groupId,
payload,
// Check if user has permission to share prompts globally
const hasAccessToSharePrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.SHARED_GLOBAL,
});
};
return (
<OGDialog>
<OGDialogTrigger asChild>
// Check user's permissions on this specific promptGroup
// The query will be disabled if groupId is empty
const groupId = group?._id || '';
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'promptGroup',
groupId,
);
// Early return if no group
if (!group || !groupId) {
return null;
}
const canShareThisPrompt = hasPermission(PERMISSION_BITS.SHARE);
const shouldShowShareButton =
(group.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisPrompt) &&
hasAccessToSharePrompts &&
!permissionsLoading;
if (!shouldShowShareButton) {
return null;
}
return (
<GenericGrantAccessDialog
resourceDbId={groupId}
resourceName={group.name}
resourceType="promptGroup"
disabled={disabled}
>
<Button
variant="default"
size="sm"
@ -94,50 +61,11 @@ const SharePrompt = ({ group, disabled }: { group?: TPromptGroup; disabled: bool
>
<Share2Icon className="size-5 cursor-pointer text-white" />
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-lg" role="dialog" aria-labelledby="dialog-title">
<OGDialogTitle id="dialog-title" className="truncate pr-2" title={group.name}>
{localize('com_ui_share_var', { 0: `"${group.name}"` })}
</OGDialogTitle>
<form className="p-2" onSubmit={handleSubmit(onSubmit)} aria-describedby="form-description">
<div id="form-description" className="sr-only">
{localize('com_ui_share_form_description')}
</div>
<div className="mb-4 flex items-center justify-between gap-2 py-4">
<div className="flex items-center" id="share-to-all-users">
{localize('com_ui_share_to_all_users')}
</div>
<Controller
name={Permissions.SHARED_GLOBAL}
control={control}
disabled={isFetching === true || updateGroup.isLoading || !instanceProjectId}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-labelledby="share-to-all-users"
/>
)}
/>
</div>
<div className="flex justify-end">
<OGDialogClose asChild>
<Button
type="submit"
disabled={isSubmitting || isFetching}
variant="submit"
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</OGDialogClose>
</div>
</form>
</OGDialogContent>
</OGDialog>
);
};
</GenericGrantAccessDialog>
);
},
);
SharePrompt.displayName = 'SharePrompt';
export default SharePrompt;

View file

@ -0,0 +1,261 @@
import React, { useState } from 'react';
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { TPrincipal } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { usePeoplePickerPermissions, useResourcePermissionState } from '~/hooks/Sharing';
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
import PeoplePicker from '../SidePanel/Agents/Sharing/PeoplePicker/PeoplePicker';
import AccessRolesPicker from '../SidePanel/Agents/Sharing/AccessRolesPicker';
import PublicSharingToggle from './PublicSharingToggle';
import { cn, removeFocusOutlines } from '~/utils';
export default function GenericGrantAccessDialog({
resourceName,
resourceDbId,
resourceId,
resourceType,
onGrantAccess,
disabled = false,
children,
}: {
resourceDbId?: string | null;
resourceId?: string | null;
resourceName?: string;
resourceType: string;
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
disabled?: boolean;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCopying, setIsCopying] = useState(false);
// Use shared hooks
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
const {
config,
updatePermissionsMutation,
currentShares,
currentIsPublic,
currentPublicRole,
isPublic,
setIsPublic,
publicRole,
setPublicRole,
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
config?.defaultViewerRoleId ?? '',
);
const resourceUrl = config?.getResourceUrl ? config?.getResourceUrl(resourceId || '') : '';
const copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
if (!resourceDbId) {
return null;
}
if (!config) {
console.error(`Unsupported resource type: ${resourceType}`);
return null;
}
const handleGrantAccess = async () => {
try {
const sharesToAdd = newShares.map((share) => ({
...share,
accessRoleId: defaultPermissionId,
}));
const allShares = [...currentShares, ...sharesToAdd];
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated: sharesToAdd,
removed: [],
public: isPublic,
publicAccessRoleId: isPublic ? publicRole : undefined,
},
});
if (onGrantAccess) {
onGrantAccess(allShares, isPublic, publicRole);
}
showToast({
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
status: 'success',
});
setNewShares([]);
setDefaultPermissionId(config?.defaultViewerRoleId);
setIsPublic(false);
setPublicRole(config?.defaultViewerRoleId);
setIsModalOpen(false);
} catch (error) {
console.error('Error granting access:', error);
showToast({
message: 'Failed to grant access. Please try again.',
status: 'error',
});
}
};
const handleCancel = () => {
setNewShares([]);
setDefaultPermissionId(config?.defaultViewerRoleId);
setIsPublic(false);
setPublicRole(config?.defaultViewerRoleId);
setIsModalOpen(false);
};
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
const submitButtonActive =
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
const TriggerComponent = children ? (
children
) : (
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={localize('com_ui_share_var', {
0: config?.getShareMessage(resourceName),
})}
type="button"
disabled={disabled}
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Share2Icon className="icon-md h-4 w-4" />
{totalCurrentShares > 0 && (
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
{totalCurrentShares}
</span>
)}
</div>
</button>
);
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Users className="h-5 w-5" />
{localize('com_ui_share_var', {
0: config?.getShareMessage(resourceName),
})}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
{hasPeoplePickerAccess && (
<>
<PeoplePicker
onSelectionChange={setNewShares}
placeholder={localize('com_ui_search_people_placeholder')}
typeFilter={peoplePickerTypeFilter}
/>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_permission_level')}
</label>
</div>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={defaultPermissionId}
onRoleChange={setDefaultPermissionId}
/>
</div>
</>
)}
<PublicSharingToggle
isPublic={isPublic}
publicRole={publicRole}
onPublicToggle={setIsPublic}
onPublicRoleChange={setPublicRole}
resourceType={resourceType}
/>
<div className="flex justify-between border-t pt-4">
<div className="flex gap-2">
{hasPeoplePickerAccess && (
<GenericManagePermissionsDialog
resourceDbId={resourceDbId}
resourceName={resourceName}
resourceType={resourceType}
/>
)}
{resourceId && resourceUrl && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (isCopying) return;
copyResourceUrl(setIsCopying);
showToast({
message: localize('com_ui_agent_url_copied'),
status: 'success',
});
}}
disabled={isCopying}
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_url_to_clipboard')}
title={
isCopying
? config?.getCopyUrlMessage()
: localize('com_ui_copy_url_to_clipboard')
}
>
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Link className="h-4 w-4" />}
</Button>
)}
</div>
<div className="flex gap-3">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleGrantAccess}
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_granting')}
</div>
) : (
localize('com_ui_grant_access')
)}
</Button>
</div>
</div>
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -0,0 +1,345 @@
import React, { useState, useEffect } from 'react';
import { TPrincipal } from 'librechat-data-provider';
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import {
Button,
OGDialog,
OGDialogTitle,
OGDialogClose,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import SelectedPrincipalsList from '../SidePanel/Agents/Sharing/PeoplePicker/SelectedPrincipalsList';
import { useResourcePermissionState } from '~/hooks/Sharing';
import PublicSharingToggle from './PublicSharingToggle';
import { cn, removeFocusOutlines } from '~/utils';
import { useLocalize } from '~/hooks';
export default function GenericManagePermissionsDialog({
resourceDbId,
resourceName,
resourceType,
onUpdatePermissions,
children,
}: {
resourceDbId: string;
resourceName?: string;
resourceType: string;
onUpdatePermissions?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
children?: React.ReactNode;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [isModalOpen, setIsModalOpen] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const {
config,
permissionsData,
isLoadingPermissions,
permissionsError,
updatePermissionsMutation,
currentShares,
currentIsPublic,
currentPublicRole,
isPublic: managedIsPublic,
setIsPublic: setManagedIsPublic,
publicRole: managedPublicRole,
setPublicRole: setManagedPublicRole,
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
const { data: accessRoles } = useGetAccessRolesQuery(resourceType);
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
useEffect(() => {
if (permissionsData && isModalOpen) {
const shares = permissionsData.principals || [];
setManagedShares(shares);
setHasChanges(false);
}
}, [permissionsData, isModalOpen]);
if (!resourceDbId) {
return null;
}
if (!config) {
console.error(`Unsupported resource type: ${resourceType}`);
return null;
}
if (permissionsError) {
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
}
const handleRemoveShare = (idOnTheSource: string) => {
setManagedShares(managedShares.filter((s) => s.idOnTheSource !== idOnTheSource));
setHasChanges(true);
};
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
setManagedShares(
managedShares.map((s) =>
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
),
);
setHasChanges(true);
};
const handleSaveChanges = async () => {
try {
const originalSharesMap = new Map(
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const managedSharesMap = new Map(
managedShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
);
const updated = managedShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
const original = originalSharesMap.get(key);
return !original || original.accessRoleId !== share.accessRoleId;
});
const removed = currentShares.filter((share) => {
const key = `${share.type}-${share.idOnTheSource}`;
return !managedSharesMap.has(key);
});
await updatePermissionsMutation.mutateAsync({
resourceType,
resourceId: resourceDbId,
data: {
updated,
removed,
public: managedIsPublic,
publicAccessRoleId: managedIsPublic ? managedPublicRole : undefined,
},
});
if (onUpdatePermissions) {
onUpdatePermissions(managedShares, managedIsPublic, managedPublicRole);
}
showToast({
message: localize('com_ui_permissions_updated_success'),
status: 'success',
});
setIsModalOpen(false);
} catch (error) {
console.error('Error updating permissions:', error);
showToast({
message: localize('com_ui_permissions_failed_update'),
status: 'error',
});
}
};
const handleCancel = () => {
setManagedShares(currentShares);
setManagedIsPublic(currentIsPublic);
setManagedPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
setIsModalOpen(false);
};
const handleRevokeAll = () => {
setManagedShares([]);
setManagedIsPublic(false);
setHasChanges(true);
};
const handlePublicToggle = (isPublic: boolean) => {
setManagedIsPublic(isPublic);
setHasChanges(true);
if (!isPublic) {
setManagedPublicRole(config?.defaultViewerRoleId);
}
};
const handlePublicRoleChange = (role: string) => {
setManagedPublicRole(role);
setHasChanges(true);
};
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
const originalTotalShares = currentShares.length + (currentIsPublic ? 1 : 0);
/** Check if there's at least one owner (user, group, or public with owner role) */
const hasAtLeastOneOwner =
managedShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
(managedIsPublic && managedPublicRole === config?.defaultOwnerRoleId);
let peopleLabel = localize('com_ui_people');
if (managedShares.length === 1) {
peopleLabel = localize('com_ui_person');
}
const buttonAriaLabel = config?.getManageMessage(resourceName);
const dialogTitle = config?.getManageMessage(resourceName);
let publicSuffix = '';
if (managedIsPublic) {
publicSuffix = localize('com_ui_and_public');
}
const TriggerComponent = children ? (
children
) : (
<button
className={cn(
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
removeFocusOutlines,
)}
aria-label={buttonAriaLabel}
type="button"
>
<div className="flex items-center justify-center gap-2 text-blue-500">
<Settings className="icon-md h-4 w-4" />
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
{originalTotalShares > 0 && `(${originalTotalShares})`}
</div>
</button>
);
return (
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
<OGDialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
{dialogTitle}
</div>
</OGDialogTitle>
<div className="space-y-6 p-2">
<div className="rounded-lg bg-surface-tertiary p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-text-primary">
{localize('com_ui_current_access')}
</h3>
<p className="text-xs text-text-secondary">
{(() => {
if (totalShares === 0) {
return localize('com_ui_no_users_groups_access');
}
return localize('com_ui_shared_with_count', {
0: managedShares.length,
1: peopleLabel,
2: publicSuffix,
});
})()}
</p>
</div>
{(managedShares.length > 0 || managedIsPublic) && (
<Button
variant="outline"
size="sm"
onClick={handleRevokeAll}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="mr-2 h-4 w-4" />
{localize('com_ui_revoke_all')}
</Button>
)}
</div>
</div>
{(() => {
if (isLoadingPermissions) {
return (
<div className="flex items-center justify-center p-8">
<Loader className="h-6 w-6 animate-spin" />
<span className="ml-2 text-sm text-text-secondary">
{localize('com_ui_loading_permissions')}
</span>
</div>
);
}
if (managedShares.length > 0) {
return (
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-text-primary">
<UserCheck className="h-4 w-4" />
{localize('com_ui_user_group_permissions')} ({managedShares.length})
</h3>
<SelectedPrincipalsList
principles={managedShares}
onRemoveHandler={handleRemoveShare}
availableRoles={accessRoles || []}
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
/>
</div>
);
}
return (
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
<Users className="mx-auto h-8 w-8 text-text-secondary" />
<p className="mt-2 text-sm text-text-secondary">
{localize('com_ui_no_individual_access')}
</p>
</div>
);
})()}
<div>
<h3 className="mb-3 text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h3>
<PublicSharingToggle
isPublic={managedIsPublic}
publicRole={managedPublicRole}
onPublicToggle={handlePublicToggle}
onPublicRoleChange={handlePublicRoleChange}
resourceType={resourceType}
/>
</div>
<div className="flex justify-end gap-3 border-t pt-4">
<OGDialogClose asChild>
<Button variant="outline" onClick={handleCancel}>
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
onClick={handleSaveChanges}
disabled={
updatePermissionsMutation.isLoading ||
!hasChanges ||
isLoadingPermissions ||
!hasAtLeastOneOwner
}
className="min-w-[120px]"
>
{updatePermissionsMutation.isLoading ? (
<div className="flex items-center gap-2">
<Loader className="h-4 w-4 animate-spin" />
{localize('com_ui_saving')}
</div>
) : (
localize('com_ui_save_changes')
)}
</Button>
</div>
{hasChanges && (
<div className="text-xs text-orange-600 dark:text-orange-400">
* {localize('com_ui_unsaved_changes')}
</div>
)}
{!hasAtLeastOneOwner && hasChanges && (
<div className="text-xs text-red-600 dark:text-red-400">
* {localize('com_ui_at_least_one_owner_required')}
</div>
)}
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -0,0 +1,60 @@
import React from 'react';
import { Globe, Shield } from 'lucide-react';
import { Switch } from '@librechat/client';
import AccessRolesPicker from '../SidePanel/Agents/Sharing/AccessRolesPicker';
import { useLocalize } from '~/hooks';
export default function PublicSharingToggle({
isPublic,
publicRole,
onPublicToggle,
onPublicRoleChange,
resourceType = 'agent',
}: {
isPublic: boolean;
publicRole: string;
onPublicToggle: (isPublic: boolean) => void;
onPublicRoleChange: (role: string) => void;
resourceType?: string;
}) {
const localize = useLocalize();
return (
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-start gap-3">
<Globe className="mt-0.5 h-5 w-5 text-blue-500" />
<div>
<h4 className="text-sm font-medium text-text-primary">
{localize('com_ui_public_access')}
</h4>
<p className="text-xs text-text-secondary">
{localize('com_ui_public_access_description')}
</p>
</div>
</div>
<Switch
checked={isPublic}
onCheckedChange={onPublicToggle}
aria-labelledby="public-access-toggle"
/>
</div>
{isPublic && (
<div className="ml-8 space-y-3">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-text-secondary" />
<label className="text-sm font-medium text-text-primary">
{localize('com_ui_public_permission_level')}
</label>
</div>
<AccessRolesPicker
resourceType={resourceType}
selectedRoleId={publicRole}
onRoleChange={onPublicRoleChange}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,3 @@
export { default as GenericGrantAccessDialog } from './GenericGrantAccessDialog';
export { default as GenericManagePermissionsDialog } from './GenericManagePermissionsDialog';
export { default as PublicSharingToggle } from './PublicSharingToggle';

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,

View file

@ -139,6 +139,37 @@ export const useCreatePrompt = (
});
};
export const useAddPromptToGroup = (
options?: t.CreatePromptOptions,
): UseMutationResult<
t.TCreatePromptResponse,
unknown,
t.TCreatePrompt & { groupId: string },
unknown
> => {
const queryClient = useQueryClient();
const { onSuccess, ...rest } = options || {};
return useMutation({
mutationFn: ({ groupId, ...payload }: t.TCreatePrompt & { groupId: string }) =>
dataService.addPromptToGroup(groupId, payload),
...rest,
onSuccess: (response, variables, context) => {
const { prompt } = response;
queryClient.setQueryData(
[QueryKeys.prompts, variables.prompt.groupId],
(oldData: t.TPrompt[] | undefined) => {
return [prompt, ...(oldData ?? [])];
},
);
if (onSuccess) {
onSuccess(response, variables, context);
}
},
});
};
export const useDeletePrompt = (
options?: t.DeletePromptOptions,
): UseMutationResult<t.TDeletePromptResponse, unknown, t.TDeletePromptVariables, unknown> => {

View file

@ -0,0 +1,2 @@
export { usePeoplePickerPermissions } from './usePeoplePickerPermissions';
export { useResourcePermissionState } from './useResourcePermissionState';

View file

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { useHasAccess } from '~/hooks';
/**
* Hook to check people picker permissions and return the appropriate type filter
* @returns Object with permission states and type filter
*/
export const usePeoplePickerPermissions = () => {
const canViewUsers = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_USERS,
});
const canViewGroups = useHasAccess({
permissionType: PermissionTypes.PEOPLE_PICKER,
permission: Permissions.VIEW_GROUPS,
});
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
const peoplePickerTypeFilter = useMemo(() => {
if (canViewUsers && canViewGroups) {
return null; // Both types allowed
} else if (canViewUsers) {
return 'user' as const;
} else if (canViewGroups) {
return 'group' as const;
}
return null;
}, [canViewUsers, canViewGroups]);
return {
canViewUsers,
canViewGroups,
hasPeoplePickerAccess,
peoplePickerTypeFilter,
};
};

View file

@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';
import {
useGetResourcePermissionsQuery,
useUpdateResourcePermissionsMutation,
} from 'librechat-data-provider/react-query';
import type { TPrincipal } from 'librechat-data-provider';
import { getResourceConfig } from '~/utils';
/**
* Hook to manage resource permission state including current shares, public access, and mutations
* @param resourceType - Type of resource (e.g., 'agent', 'promptGroup')
* @param resourceDbId - Database ID of the resource
* @param isModalOpen - Whether the modal is open (for effect dependencies)
* @returns Object with permission state and update mutation
*/
export const useResourcePermissionState = (
resourceType: string,
resourceDbId: string | null | undefined,
isModalOpen: boolean = false,
) => {
const config = getResourceConfig(resourceType);
// Only enable the query if we have a valid resourceDbId
const isValidResourceId = !!resourceDbId && resourceDbId.trim() !== '';
const {
data: permissionsData,
isLoading: isLoadingPermissions,
error: permissionsError,
} = useGetResourcePermissionsQuery(resourceType, resourceDbId || '', {
enabled: isValidResourceId,
});
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
// Extract current shares from permissions data
const currentShares: TPrincipal[] =
permissionsData?.principals?.map((principal) => ({
type: principal.type,
id: principal.id,
name: principal.name,
email: principal.email,
source: principal.source,
avatar: principal.avatar,
description: principal.description,
accessRoleId: principal.accessRoleId,
idOnTheSource: principal.idOnTheSource,
})) || [];
const currentIsPublic = permissionsData?.public ?? false;
const currentPublicRole = permissionsData?.publicAccessRoleId || config?.defaultViewerRoleId;
// State for managing public access
const [isPublic, setIsPublic] = useState(false);
const [publicRole, setPublicRole] = useState<string>(config?.defaultViewerRoleId ?? '');
// Sync state with permissions data when modal opens
useEffect(() => {
if (permissionsData && isModalOpen) {
setIsPublic(currentIsPublic ?? false);
setPublicRole(currentPublicRole ?? '');
}
}, [permissionsData, isModalOpen, currentIsPublic, currentPublicRole]);
return {
config,
permissionsData,
isLoadingPermissions,
permissionsError,
updatePermissionsMutation,
currentShares,
currentIsPublic,
currentPublicRole,
isPublic,
setIsPublic,
publicRole,
setPublicRole,
};
};

View file

@ -1188,11 +1188,13 @@
"com_ui_user_group_permissions": "User & Group Permissions",
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
"com_ui_public_access": "Public Access",
"com_ui_public_access_description": "Anyone can access this resource publicly",
"com_ui_save_changes": "Save Changes",
"com_ui_unsaved_changes": "You have unsaved changes",
"com_ui_share_with_everyone": "Share with everyone",
"com_ui_make_agent_available_all_users": "Make this agent available to all LibreChat users",
"com_ui_public_access_level": "Public access level",
"com_ui_public_permission_level": "Public permission level",
"com_ui_at_least_one_owner_required": "At least one owner is required",
"com_agents_marketplace": "Agent Marketplace",
"com_agents_all": "All Agents",

View file

@ -14,6 +14,8 @@ export * from './textarea';
export * from './messages';
export * from './languages';
export * from './endpoints';
export * from './resources';
export * from './roles';
export * from './localStorage';
export * from './promptGroups';
export { default as cn } from './cn';

View file

@ -0,0 +1,43 @@
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
export interface ResourceConfig {
resourceType: string;
defaultViewerRoleId: string;
defaultEditorRoleId: string;
defaultOwnerRoleId: string;
getResourceUrl?: (resourceId: string) => string;
getResourceName: (resourceName?: string) => string;
getShareMessage: (resourceName?: string) => string;
getManageMessage: (resourceName?: string) => string;
getCopyUrlMessage: () => string;
}
export const RESOURCE_CONFIGS: Record<string, ResourceConfig> = {
agent: {
resourceType: 'agent',
defaultViewerRoleId: ACCESS_ROLE_IDS.AGENT_VIEWER,
defaultEditorRoleId: ACCESS_ROLE_IDS.AGENT_EDITOR,
defaultOwnerRoleId: ACCESS_ROLE_IDS.AGENT_OWNER,
getResourceUrl: (agentId: string) => `${window.location.origin}/c/new?agent_id=${agentId}`,
getResourceName: (name?: string) => (name && name !== '' ? `"${name}"` : 'agent'),
getShareMessage: (name?: string) => (name && name !== '' ? `"${name}"` : 'agent'),
getManageMessage: (name?: string) =>
`Manage permissions for ${name && name !== '' ? `"${name}"` : 'agent'}`,
getCopyUrlMessage: () => 'Agent URL copied',
},
promptGroup: {
resourceType: 'promptGroup',
defaultViewerRoleId: ACCESS_ROLE_IDS.PROMPTGROUP_VIEWER,
defaultEditorRoleId: ACCESS_ROLE_IDS.PROMPTGROUP_EDITOR,
defaultOwnerRoleId: ACCESS_ROLE_IDS.PROMPTGROUP_OWNER,
getResourceName: (name?: string) => (name && name !== '' ? `"${name}"` : 'prompt'),
getShareMessage: (name?: string) => (name && name !== '' ? `"${name}"` : 'prompt'),
getManageMessage: (name?: string) =>
`Manage permissions for ${name && name !== '' ? `"${name}"` : 'prompt'}`,
getCopyUrlMessage: () => 'Prompt URL copied',
},
};
export const getResourceConfig = (resourceType: string): ResourceConfig | undefined => {
return RESOURCE_CONFIGS[resourceType];
};

52
client/src/utils/roles.ts Normal file
View file

@ -0,0 +1,52 @@
import type { ACCESS_ROLE_IDS } from 'librechat-data-provider';
import type { TranslationKeys } from '~/hooks/useLocalize';
/**
* Centralized mapping for role localizations
* Maps role IDs to their localization keys
*/
export const ROLE_LOCALIZATIONS = {
agent_viewer: {
name: 'com_ui_role_viewer' as const,
description: 'com_ui_role_viewer_desc' as const,
} as const,
agent_editor: {
name: 'com_ui_role_editor' as const,
description: 'com_ui_role_editor_desc' as const,
} as const,
agent_manager: {
name: 'com_ui_role_manager' as const,
description: 'com_ui_role_manager_desc' as const,
} as const,
agent_owner: {
name: 'com_ui_role_owner' as const,
description: 'com_ui_role_owner_desc' as const,
} as const,
// PromptGroup roles
promptGroup_viewer: {
name: 'com_ui_role_viewer' as const,
description: 'com_ui_role_viewer_desc' as const,
} as const,
promptGroup_editor: {
name: 'com_ui_role_editor' as const,
description: 'com_ui_role_editor_desc' as const,
} as const,
promptGroup_owner: {
name: 'com_ui_role_owner' as const,
description: 'com_ui_role_owner_desc' as const,
} as const,
};
/**
* Get localization keys for a given role ID
* @param roleId - The role ID to get localization keys for
* @returns Object with name and description localization keys, or unknown keys if not found
*/
export const getRoleLocalizationKeys = (
roleId: ACCESS_ROLE_IDS,
): {
name: TranslationKeys;
description: TranslationKeys;
} => {
return ROLE_LOCALIZATIONS[roleId] || { name: 'com_ui_unknown', description: 'com_ui_unknown' };
};