🧬 feat: Allow Agent Editors to Duplicate Agents (#12041)

* feat: allow editors to duplicate agents

* fix: Update permissions for duplicating agents and enhance visibility in AgentFooter

- Changed required permission for duplicating agents from VIEW to EDIT in the API route.
- Updated AgentFooter component to display the duplicate button for admins and users with EDIT permission, improving access control.
- Added tests to ensure the duplicate button visibility logic works correctly based on user roles and permissions.

* test: Update AgentFooter tests to reflect permission changes

- Adjusted tests in AgentFooter.spec.tsx to verify UI behavior based on user permissions.
- Updated expectations for the visibility of the grant access dialog and duplicate button, ensuring they align with the new permission logic.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Peter Nancarrow 2026-03-03 19:45:02 -06:00 committed by GitHub
parent d3622844ad
commit 14bcab60b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 92 additions and 5 deletions

View file

@ -117,7 +117,7 @@ router.post(
'/:id/duplicate',
checkAgentCreate,
canAccessAgentResource({
requiredPermission: PermissionBits.VIEW,
requiredPermission: PermissionBits.EDIT,
resourceIdParam: 'id',
}),
v1.duplicateAgent,

View file

@ -57,6 +57,7 @@ export default function AgentFooter({
useResourcePermissions(ResourceType.REMOTE_AGENT, agent?._id || '');
const canShareThisAgent = hasPermission(PermissionBits.SHARE);
const canEditThisAgent = hasPermission(PermissionBits.EDIT);
const canDeleteThisAgent = hasPermission(PermissionBits.DELETE);
const canShareRemoteAgent = hasRemoteAgentPermission(PermissionBits.SHARE);
const isSaving = createMutation.isLoading || updateMutation.isLoading || isAvatarUploading;
@ -118,7 +119,8 @@ export default function AgentFooter({
</button>
</GenericGrantAccessDialog>
)}
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canEditThisAgent) &&
!permissionsLoading && <DuplicateAgent agent_id={agent_id} />}
{/* Submit Button */}
<button
className="btn btn-primary focus:shadow-outline flex h-9 w-full items-center justify-center px-4 py-2 font-semibold text-white hover:bg-green-600 focus:border-green-500"

View file

@ -88,6 +88,7 @@ export default function DeleteButton({
size="sm"
variant="outline"
aria-label={localize('com_ui_delete_agent')}
title={localize('com_ui_delete_agent')}
type="button"
>
<div className="flex w-full items-center justify-center gap-2 text-red-500">

View file

@ -37,6 +37,7 @@ export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
size="sm"
variant="outline"
aria-label={localize('com_ui_duplicate_agent')}
title={localize('com_ui_duplicate_agent')}
type="button"
onClick={handleDuplicate}
>

View file

@ -362,9 +362,15 @@ describe('AgentFooter', () => {
}
return undefined;
});
mockUseHasAccess.mockReturnValue(true);
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false,
isLoading: false,
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('grant-access-dialog-agent')).toBeInTheDocument(); // Still shows because hasAccess is true
expect(screen.queryByTestId('duplicate-agent')).not.toBeInTheDocument(); // Should not show for different author
expect(screen.queryByTestId('grant-access-dialog-agent')).not.toBeInTheDocument(); // No share permission
expect(screen.queryByTestId('duplicate-button')).not.toBeInTheDocument(); // No edit permission
});
test('adjusts UI based on permissions', () => {
@ -420,7 +426,84 @@ describe('AgentFooter', () => {
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('delete-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('grant-access-dialog-agent')).not.toBeInTheDocument();
// Duplicate button should still show as it doesn't depend on permissions loading
expect(screen.queryByTestId('duplicate-button')).not.toBeInTheDocument();
});
test('shows duplicate button for non-owner with EDIT permission', () => {
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.different));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
mockUseResourcePermissions.mockReturnValue({
hasPermission: (bit: number) => bit === 2,
isLoading: false,
permissionBits: 2,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByTestId('duplicate-button')).toBeInTheDocument();
});
test('hides duplicate button for non-owner with only VIEW permission', () => {
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.different));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false,
isLoading: false,
permissionBits: 1,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.queryByTestId('duplicate-button')).not.toBeInTheDocument();
});
test('shows duplicate button for admin who is not the author', () => {
mockUseAuthContext.mockReturnValue(createAuthContext(mockUsers.admin));
mockUseWatch.mockImplementation(({ name }) => {
if (name === 'agent') {
return {
_id: 'agent-db-123',
name: 'Test Agent',
author: 'user-123',
projectIds: ['project-1'],
isCollaborative: false,
};
}
if (name === 'id') {
return 'agent-123';
}
return undefined;
});
mockUseResourcePermissions.mockReturnValue({
hasPermission: () => false,
isLoading: false,
permissionBits: 0,
});
render(<AgentFooter {...defaultProps} />);
expect(screen.getByTestId('duplicate-button')).toBeInTheDocument();
});
});