feat: workflow agent node permissions tab (#16092)

This commit is contained in:
Abdul Rahman
2025-11-28 02:57:33 +05:30
committed by GitHub
parent 41a07006ef
commit a343bc1aee
32 changed files with 2191 additions and 420 deletions
@@ -17,11 +17,13 @@ const StyledWorkflowStepBody = styled.div`
export const WorkflowStepBody = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<StyledWorkflowStepBody>
<StyledWorkflowStepBody className={className}>
<AppErrorBoundary
resetOnLocationChange={true}
FallbackComponent={({
@@ -8,13 +8,23 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { useDuplicateStep } from '@/workflow/workflow-steps/hooks/useDuplicateStep';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { useLingui } from '@lingui/react/macro';
import { useId } from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { IconCopyPlus, IconPencil, IconTrash } from 'twenty-ui/display';
import {
IconCopyPlus,
IconPencil,
IconRobot,
IconTrash,
IconUsers,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const WorkflowStepFooter = ({
stepId,
@@ -33,12 +43,22 @@ export const WorkflowStepFooter = ({
openWorkflowTriggerTypeInCommandMenu,
} = useWorkflowCommandMenu();
const { deleteStep } = useDeleteStep();
const navigateSettings = useNavigateSettings();
const workflowAiAgentActionAgent = useRecoilValue(
workflowAiAgentActionAgentState,
);
const shouldPinDeleteButton =
!isDefined(additionalActions) || additionalActions.length === 0;
const agentId = workflowAiAgentActionAgent?.id;
const hasViewAgentOption = isDefined(agentId);
const hasViewRoleOption = isDefined(workflowAiAgentActionAgent?.roleId);
const selectableItemIdArray = [
'change-node-type',
...(stepId !== TRIGGER_STEP_ID ? ['duplicate'] : []),
...(hasViewAgentOption ? ['view-agent'] : []),
...(hasViewRoleOption ? ['view-role'] : []),
...(!shouldPinDeleteButton ? ['delete'] : []),
];
@@ -62,6 +82,22 @@ export const WorkflowStepFooter = ({
deleteStep(stepId);
};
const handleViewAgent = () => {
closeDropdown(dropdownId);
if (isDefined(agentId)) {
navigateSettings(SettingsPath.AIAgentDetail, { agentId });
}
};
const handleViewRole = () => {
closeDropdown(dropdownId);
if (isDefined(workflowAiAgentActionAgent?.roleId)) {
navigateSettings(SettingsPath.RoleDetail, {
roleId: workflowAiAgentActionAgent.roleId,
});
}
};
const selectedItemId = useRecoilComponentValue(
selectedItemIdComponentState,
dropdownId,
@@ -94,6 +130,26 @@ export const WorkflowStepFooter = ({
/>
</SelectableListItem>
)}
{hasViewAgentOption && (
<SelectableListItem itemId="view-agent" onEnter={handleViewAgent}>
<MenuItem
focused={selectedItemId === 'view-agent'}
onClick={handleViewAgent}
text={t`View Agent`}
LeftIcon={IconRobot}
/>
</SelectableListItem>
)}
{hasViewRoleOption && (
<SelectableListItem itemId="view-role" onEnter={handleViewRole}>
<MenuItem
focused={selectedItemId === 'view-role'}
onClick={handleViewRole}
text={t`View Role`}
LeftIcon={IconUsers}
/>
</SelectableListItem>
)}
{!shouldPinDeleteButton && (
<SelectableListItem itemId="delete" onEnter={handleDeleteNode}>
<MenuItem
@@ -1,5 +1,9 @@
import React from 'react';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { renderHook } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
const mockDeleteWorkflowVersionStep = jest.fn();
const mockGetUpdatableWorkflowVersion = jest.fn();
@@ -33,6 +37,36 @@ jest.mock('@/command-menu/hooks/useCommandMenu', () => ({
}),
}));
jest.mock('@/workflow/hooks/useWorkflowWithCurrentVersion', () => ({
useWorkflowWithCurrentVersion: () => undefined,
}));
jest.mock(
'@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose',
() => ({
useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose: () => ({
resetPermissionState: jest.fn(),
}),
}),
);
const wrapper = ({ children }: { children: React.ReactNode }) => {
const workflowVisualizerComponentInstanceId =
'workflow-visualizer-instance-id';
return (
<RecoilRoot>
<WorkflowVisualizerComponentInstanceContext.Provider
value={{
instanceId: workflowVisualizerComponentInstanceId,
}}
>
{children}
</WorkflowVisualizerComponentInstanceContext.Provider>
</RecoilRoot>
);
};
describe('useDeleteStep', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -55,7 +89,9 @@ describe('useDeleteStep', () => {
},
});
const { result } = renderHook(() => useDeleteStep());
const { result } = renderHook(() => useDeleteStep(), {
wrapper,
});
await result.current.deleteStep(mockStepId);
expect(mockGetUpdatableWorkflowVersion).toHaveBeenCalled();
@@ -1,19 +1,36 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useDeleteWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep';
import { useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose';
import { isDefined } from 'twenty-shared/utils';
export const useDeleteStep = () => {
const { resetPermissionState } =
useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose();
const { deleteWorkflowVersionStep } = useDeleteWorkflowVersionStep();
const { deleteStepsOutputSchema } = useStepsOutputSchema();
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow();
const { closeCommandMenu } = useCommandMenu();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
const deleteStep = async (stepId: string) => {
const workflowVersionId = await getUpdatableWorkflowVersion();
const isAiAgentStep =
isDefined(workflow?.currentVersion?.steps) &&
workflow.currentVersion.steps.some(
(step) => step.id === stepId && step.type === 'AI_AGENT',
);
await deleteWorkflowVersionStep({
workflowVersionId,
stepId,
@@ -25,6 +42,10 @@ export const useDeleteStep = () => {
stepIds: [stepId],
workflowVersionId,
});
if (isAiAgentStep) {
resetPermissionState();
}
};
return {
@@ -0,0 +1,120 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig';
import { t } from '@lingui/core/macro';
import { type ObjectPermission } from '~/generated/graphql';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
import { CRUD_PERMISSIONS } from '../constants/WorkflowAiAgentCrudPermissions';
import { WorkflowAiAgentPermissionsPermissionRow } from './WorkflowAiAgentPermissionsPermissionRow';
import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles';
const CRUD_PERMISSION_ORDER = CRUD_PERMISSIONS.reduce<
Record<SettingsRoleObjectPermissionKey, number>
>(
(orderMap, permission, index) => {
orderMap[permission.key] = index;
return orderMap;
},
{} as Record<SettingsRoleObjectPermissionKey, number>,
);
type WorkflowAiAgentPermissionListProps = {
readonly: boolean;
objectPermissions: ObjectPermission[];
onDeletePermission: (
objectMetadataId: string,
permissionKey: SettingsRoleObjectPermissionKey,
) => void;
searchQuery: string;
};
export const WorkflowAiAgentPermissionList = ({
readonly,
objectPermissions,
onDeletePermission,
searchQuery,
}: WorkflowAiAgentPermissionListProps) => {
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
useFilteredObjectMetadataItems();
const sortedExistingPermissions = objectPermissions
.reduce<
Array<{
objectMetadataId: string;
objectLabel: string;
permissionKey: SettingsRoleObjectPermissionKey;
permissionLabel: string;
}>
>((permissions, objectPermission) => {
const objectMetadata = objectMetadataItems.find(
(item) => item.id === objectPermission.objectMetadataId,
);
if (!objectMetadata) {
return permissions;
}
CRUD_PERMISSIONS.forEach((crud) => {
if (objectPermission[crud.key] === true) {
permissions.push({
objectMetadataId: objectPermission.objectMetadataId,
objectLabel: objectMetadata.labelPlural,
permissionKey: crud.key,
permissionLabel: crud.label(objectMetadata.labelPlural),
});
}
});
return permissions;
}, [])
.sort((permissionA, permissionB) => {
if (
permissionA.objectMetadataId === permissionB.objectMetadataId &&
permissionA.permissionKey !== permissionB.permissionKey
) {
return (
CRUD_PERMISSION_ORDER[permissionA.permissionKey] -
CRUD_PERMISSION_ORDER[permissionB.permissionKey]
);
}
return permissionA.objectLabel.localeCompare(permissionB.objectLabel);
});
const filteredPermissions = filterBySearchQuery({
items: sortedExistingPermissions,
searchQuery,
getSearchableValues: (permission) => [
permission.permissionLabel,
permission.objectLabel,
],
});
if (filteredPermissions.length === 0) {
return null;
}
return (
<div>
<StyledLabel>{t`CRUD`}</StyledLabel>
<StyledList>
{filteredPermissions.map((permission) => (
<WorkflowAiAgentPermissionsPermissionRow
key={`${permission.objectMetadataId}-${permission.permissionKey}`}
permission={{
key: permission.permissionKey,
label: permission.permissionLabel,
}}
isEnabled={true}
readonly={readonly}
onDelete={() =>
onDeletePermission(
permission.objectMetadataId,
permission.permissionKey,
)
}
/>
))}
</StyledList>
</div>
);
};
@@ -0,0 +1,49 @@
import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig';
import { t } from '@lingui/core/macro';
import { type ObjectPermission } from '~/generated-metadata/graphql';
import { WorkflowAiAgentPermissionsPermissionRow } from './WorkflowAiAgentPermissionsPermissionRow';
import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles';
type WorkflowAiAgentPermissionsCrudListProps = {
permissions: Array<{
key: SettingsRoleObjectPermissionKey;
label: string;
}>;
objectPermissions?: ObjectPermission;
readonly: boolean;
onAddPermission: (
objectMetadataId: string,
permissionKey: SettingsRoleObjectPermissionKey,
) => void;
objectMetadataId: string;
};
export const WorkflowAiAgentPermissionsCrudList = ({
permissions,
objectPermissions,
readonly,
onAddPermission,
objectMetadataId,
}: WorkflowAiAgentPermissionsCrudListProps) => {
return (
<div>
<StyledLabel>{t`CRUD`}</StyledLabel>
<StyledList>
{permissions.map((permission) => {
const isEnabled = Boolean(objectPermissions?.[permission.key]);
return (
<WorkflowAiAgentPermissionsPermissionRow
key={permission.key}
permission={permission}
isEnabled={isEnabled}
readonly={readonly}
showDeleteButton={false}
alwaysShowGranted={true}
onAdd={() => onAddPermission(objectMetadataId, permission.key)}
/>
);
})}
</StyledList>
</div>
);
};
@@ -0,0 +1,61 @@
import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission';
import { type PermissionFlagType } from '~/generated-metadata/graphql';
import { WorkflowAiAgentPermissionsFlagRow } from './WorkflowAiAgentPermissionsFlagRow';
import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles';
type WorkflowAiAgentPermissionsFlagListProps = {
title: string;
permissions: SettingsRolePermissionsSettingPermission[];
enabledPermissionFlagKeys: PermissionFlagType[];
readonly: boolean;
showDeleteButton?: boolean;
onAddPermissionFlag?: (permissionKey: PermissionFlagType) => void;
onDeletePermissionFlag?: (permissionKey: PermissionFlagType) => void;
};
export const WorkflowAiAgentPermissionsFlagList = ({
title,
permissions,
enabledPermissionFlagKeys,
readonly,
showDeleteButton = false,
onAddPermissionFlag,
onDeletePermissionFlag,
}: WorkflowAiAgentPermissionsFlagListProps) => {
const hasPermissions = permissions.length > 0;
if (!hasPermissions) {
return null;
}
return (
<div>
<StyledLabel>{title}</StyledLabel>
<StyledList>
{permissions.map((permission) => {
const isEnabled = enabledPermissionFlagKeys.includes(permission.key);
return (
<WorkflowAiAgentPermissionsFlagRow
key={permission.key}
permission={permission}
isEnabled={isEnabled}
readonly={readonly}
showDeleteButton={showDeleteButton}
onAdd={
isEnabled
? undefined
: () => onAddPermissionFlag?.(permission.key)
}
onDelete={
showDeleteButton
? () => onDeletePermissionFlag?.(permission.key)
: undefined
}
/>
);
})}
</StyledList>
</div>
);
};
@@ -0,0 +1,60 @@
import { type SettingsRolePermissionsSettingPermission } from '@/settings/roles/role-permissions/permission-flags/types/SettingsRolePermissionsSettingPermission';
import { useTheme } from '@emotion/react';
import { IconTrash } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
StyledDeleteButton,
StyledIconContainer,
StyledRow,
StyledRowLeftContent,
StyledText,
} from './WorkflowAiAgentPermissionsStyles';
type WorkflowAiAgentPermissionsFlagRowProps = {
permission: SettingsRolePermissionsSettingPermission;
isEnabled: boolean;
readonly: boolean;
showDeleteButton?: boolean;
onAdd?: () => void;
onDelete?: () => void;
};
export const WorkflowAiAgentPermissionsFlagRow = ({
permission,
isEnabled,
readonly,
showDeleteButton = false,
onAdd,
onDelete,
}: WorkflowAiAgentPermissionsFlagRowProps) => {
const theme = useTheme();
const isClickable = !readonly && !isEnabled && Boolean(onAdd);
const isDisabled = isEnabled && !showDeleteButton;
return (
<StyledRow
onClick={isClickable ? onAdd : undefined}
isDisabled={isDisabled}
>
<StyledRowLeftContent>
<StyledIconContainer>
<permission.Icon size={theme.icon.size.sm} />
</StyledIconContainer>
<StyledText>{permission.name}</StyledText>
</StyledRowLeftContent>
{isEnabled && showDeleteButton && (
<StyledDeleteButton data-delete-button>
<IconButton
Icon={IconTrash}
variant="tertiary"
size="small"
onClick={(event) => {
event.stopPropagation();
onDelete?.();
}}
/>
</StyledDeleteButton>
)}
</StyledRow>
);
};
@@ -0,0 +1,44 @@
import { useTheme } from '@emotion/react';
import { useIcons } from 'twenty-ui/display';
import {
StyledIconChevronRight,
StyledIconContainer,
StyledRow,
StyledRowLeftContent,
StyledText,
} from './WorkflowAiAgentPermissionsStyles';
type WorkflowAiAgentPermissionsObjectRowProps = {
objectMetadata: {
id: string;
icon?: string | null;
labelPlural: string;
};
onClick?: () => void;
readonly: boolean;
};
export const WorkflowAiAgentPermissionsObjectRow = ({
objectMetadata,
onClick,
readonly,
}: WorkflowAiAgentPermissionsObjectRowProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const IconComponent = getIcon(objectMetadata.icon);
return (
<StyledRow
key={objectMetadata.id}
onClick={!readonly ? onClick : undefined}
>
<StyledRowLeftContent>
<StyledIconContainer>
<IconComponent size={theme.icon.size.sm} />
</StyledIconContainer>
<StyledText>{objectMetadata.labelPlural}</StyledText>
</StyledRowLeftContent>
<StyledIconChevronRight size={theme.icon.size.sm} />
</StyledRow>
);
};
@@ -0,0 +1,34 @@
import { WorkflowAiAgentPermissionsObjectRow } from './WorkflowAiAgentPermissionsObjectRow';
import { StyledLabel, StyledList } from './WorkflowAiAgentPermissionsStyles';
type WorkflowAiAgentPermissionsObjectsListProps = {
objects: Array<{
id: string;
icon?: string | null;
labelPlural: string;
}>;
onObjectClick: (objectId: string) => void;
readonly: boolean;
};
export const WorkflowAiAgentPermissionsObjectsList = ({
objects,
onObjectClick,
readonly,
}: WorkflowAiAgentPermissionsObjectsListProps) => {
return (
<div>
<StyledLabel>Objects</StyledLabel>
<StyledList>
{objects.map((objectMetadata) => (
<WorkflowAiAgentPermissionsObjectRow
key={objectMetadata.id}
objectMetadata={objectMetadata}
onClick={() => onObjectClick(objectMetadata.id)}
readonly={readonly}
/>
))}
</StyledList>
</div>
);
};
@@ -0,0 +1,67 @@
import { PermissionIcon } from '@/settings/roles/role-permissions/objects-permissions/components/PermissionIcon';
import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig';
import { IconTrash } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
StyledDeleteButton,
StyledIconContainer,
StyledRow,
StyledRowLeftContent,
StyledText,
} from './WorkflowAiAgentPermissionsStyles';
type WorkflowAiAgentPermissionsPermissionRowProps = {
permission: {
key: SettingsRoleObjectPermissionKey;
label: string;
};
isEnabled: boolean;
readonly: boolean;
showDeleteButton?: boolean;
alwaysShowGranted?: boolean;
onAdd?: () => void;
onDelete?: () => void;
};
export const WorkflowAiAgentPermissionsPermissionRow = ({
permission,
isEnabled,
readonly,
showDeleteButton = true,
alwaysShowGranted = false,
onAdd,
onDelete,
}: WorkflowAiAgentPermissionsPermissionRowProps) => {
const isClickable = !readonly && !isEnabled;
const isDisabled = isEnabled && !showDeleteButton;
return (
<StyledRow
onClick={isClickable ? onAdd : undefined}
isDisabled={isDisabled}
>
<StyledRowLeftContent>
<StyledIconContainer>
<PermissionIcon
permission={permission.key}
state={alwaysShowGranted || isEnabled ? 'granted' : 'revoked'}
/>
</StyledIconContainer>
<StyledText>{permission.label}</StyledText>
</StyledRowLeftContent>
{isEnabled && showDeleteButton && (
<StyledDeleteButton data-delete-button>
<IconButton
Icon={IconTrash}
variant="tertiary"
size="small"
onClick={(e) => {
e.stopPropagation();
onDelete?.();
}}
/>
</StyledDeleteButton>
)}
</StyledRow>
);
};
@@ -0,0 +1,64 @@
import styled from '@emotion/styled';
import { IconChevronRight, Label } from 'twenty-ui/display';
export const StyledText = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
`;
export const StyledLabel = styled(Label)`
margin: ${({ theme }) => theme.spacing(3, 3, 0)};
`;
export const StyledRow = styled.div<{ isDisabled?: boolean }>`
align-items: center;
display: flex;
justify-content: space-between;
width: 100%;
padding: ${({ theme }) => theme.spacing(1)};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.spacing(1)};
cursor: ${({ isDisabled }) => (isDisabled ? 'not-allowed' : 'pointer')};
opacity: ${({ isDisabled }) => (isDisabled ? 0.5 : 1)};
pointer-events: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')};
:hover {
background-color: ${({ theme, isDisabled }) =>
isDisabled ? 'inherit' : theme.background.transparent.light};
}
:hover [data-delete-button] {
opacity: 1;
}
`;
export const StyledDeleteButton = styled.div`
opacity: 0;
transition: opacity ${({ theme }) => theme.animation.duration.fast}ms;
`;
export const StyledRowLeftContent = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
export const StyledList = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const StyledIconContainer = styled.div`
background-color: ${({ theme }) => theme.background.tertiary};
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
`;
export const StyledIconChevronRight = styled(IconChevronRight)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
@@ -0,0 +1,264 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useActionRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig';
import { TextInput } from '@/ui/input/components/TextInput';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { useWorkflowAiAgentPermissionActions } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useWorkflowAiAgentPermissionActions';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { type Agent, useGetRolesQuery } from '~/generated-metadata/graphql';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey';
import { WorkflowAiAgentPermissionList } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionList';
import { CRUD_PERMISSIONS } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions';
import { WorkflowAiAgentPermissionsCrudList } from './WorkflowAiAgentPermissionsCrudList';
import { WorkflowAiAgentPermissionsFlagList } from './WorkflowAiAgentPermissionsFlagList';
import { WorkflowAiAgentPermissionsObjectsList } from './WorkflowAiAgentPermissionsObjectsList';
import { getFilteredPermissions } from './workflowAiAgentPermissions.utils';
const StyledSearchInput = styled(TextInput)`
width: 100%;
height: 40px;
border-block: 1px solid ${({ theme }) => theme.border.color.medium};
input {
height: 40px;
line-height: 40px;
border: none;
border-radius: 0;
width: 100%;
}
`;
const StyledBackButtonText = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
`;
const StyledBackButton = styled.button`
width: 100%;
align-items: center;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(3)};
text-align: left;
&:hover {
color: ${({ theme }) => theme.font.color.primary};
}
`;
const StyledContainer = styled.div`
width: 100%;
`;
type WorkflowAiAgentPermissionsTabProps = {
action: WorkflowAiAgentAction;
readonly: boolean;
isAgentLoading: boolean;
refetchAgent: () => Promise<{ data?: { findOneAgent?: Agent } }>;
};
export const WorkflowAiAgentPermissionsTab = ({
readonly,
isAgentLoading,
refetchAgent,
}: WorkflowAiAgentPermissionsTabProps) => {
const workflowAiAgentActionAgent = useRecoilValue(
workflowAiAgentActionAgentState,
);
const [
workflowAiAgentPermissionsSelectedObjectId,
setWorkflowAiAgentPermissionsSelectedObjectId,
] = useRecoilState(workflowAiAgentPermissionsSelectedObjectIdState);
const [
workflowAiAgentPermissionsIsAddingPermission,
setWorkflowAiAgentPermissionsIsAddingPermission,
] = useRecoilState(workflowAiAgentPermissionsIsAddingPermissionState);
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
useFilteredObjectMetadataItems();
const {
data: rolesData,
loading: rolesLoading,
refetch: refetchRoles,
} = useGetRolesQuery();
const [searchQuery, setSearchQuery] = useState('');
const role = rolesData?.getRoles.find(
(item) => item.id === workflowAiAgentActionAgent?.roleId,
);
const objectPermissions = role?.objectPermissions || [];
const permissionFlagKeys =
role?.permissionFlags?.map((permissionFlag) => permissionFlag.flag) ?? [];
const hasRoleWithPermissions =
isDefined(workflowAiAgentActionAgent?.roleId) &&
(objectPermissions.length > 0 || permissionFlagKeys.length > 0);
const actionPermissionsConfig = useActionRolePermissionFlagConfig({
assignmentCapabilities: { canBeAssignedToAgents: true },
});
type ObjectMetadataListItem = (typeof objectMetadataItems)[number];
const filteredObjects = filterBySearchQuery<ObjectMetadataListItem>({
items: objectMetadataItems,
searchQuery,
getSearchableValues: (item) => [item.labelSingular, item.labelPlural],
});
const {
filteredPermissions: filteredActionPermissions,
filteredEnabledPermissions: filteredEnabledActionPermissions,
} = getFilteredPermissions({
permissions: actionPermissionsConfig,
permissionFlagKeys,
searchQuery,
});
const refetchAgentAndRoles = async () => {
await refetchRoles();
const result = await refetchAgent();
return {
refetchedAgent: result?.data?.findOneAgent,
};
};
const {
handleAddPermission,
handleDeletePermission,
handleAddPermissionFlag,
handleDeletePermissionFlag,
} = useWorkflowAiAgentPermissionActions({
readonly,
objectPermissions,
permissionFlagKeys,
refetchAgentAndRoles,
});
if (isAgentLoading || rolesLoading) {
return <RightDrawerSkeletonLoader />;
}
if (!isDefined(workflowAiAgentActionAgent)) {
return null;
}
const selectedObject = isDefined(workflowAiAgentPermissionsSelectedObjectId)
? objectMetadataItems.find(
(item) => item.id === workflowAiAgentPermissionsSelectedObjectId,
)
: undefined;
const objectPermissionForSelected = isDefined(selectedObject)
? objectPermissions.find(
(permission) => permission.objectMetadataId === selectedObject.id,
)
: undefined;
const handleBack = () => {
isDefined(workflowAiAgentPermissionsSelectedObjectId) &&
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
!isDefined(workflowAiAgentPermissionsSelectedObjectId) &&
setWorkflowAiAgentPermissionsIsAddingPermission(false);
};
const handleObjectClick = (objectId: string) => {
setWorkflowAiAgentPermissionsSelectedObjectId(objectId);
};
const shouldShowBackButton =
isDefined(workflowAiAgentPermissionsSelectedObjectId) ||
workflowAiAgentPermissionsIsAddingPermission;
const shouldShowCrudList = isDefined(selectedObject);
const shouldShowSelectionLists =
(!hasRoleWithPermissions || workflowAiAgentPermissionsIsAddingPermission) &&
!isDefined(selectedObject);
const shouldShowExistingPermissions =
hasRoleWithPermissions && !workflowAiAgentPermissionsIsAddingPermission;
return (
<StyledContainer>
{shouldShowBackButton && (
<StyledBackButton onClick={handleBack}>
<IconButton Icon={IconChevronLeft} variant="tertiary" size="small" />
<StyledBackButtonText>{t`Add permission`}</StyledBackButtonText>
</StyledBackButton>
)}
<StyledSearchInput
value={searchQuery}
onChange={(value: string) => setSearchQuery(value)}
placeholder={t`Type anything...`}
onKeyDown={(event) => {
if (isNonTextWritingKey(event.key)) {
event.stopPropagation();
}
}}
/>
{shouldShowCrudList && (
<WorkflowAiAgentPermissionsCrudList
permissions={filterBySearchQuery({
items: CRUD_PERMISSIONS.map((p) => ({
key: p.key,
label: p.label(selectedObject.labelPlural),
})),
searchQuery,
getSearchableValues: (permission) => [permission.label],
})}
objectPermissions={objectPermissionForSelected}
readonly={readonly}
onAddPermission={handleAddPermission}
objectMetadataId={selectedObject.id}
/>
)}
{shouldShowSelectionLists && (
<>
<WorkflowAiAgentPermissionsObjectsList
objects={filteredObjects}
onObjectClick={handleObjectClick}
readonly={readonly}
/>
<WorkflowAiAgentPermissionsFlagList
title={t`Actions`}
permissions={filteredActionPermissions}
enabledPermissionFlagKeys={permissionFlagKeys}
readonly={readonly}
onAddPermissionFlag={handleAddPermissionFlag}
/>
</>
)}
{shouldShowExistingPermissions && (
<>
<WorkflowAiAgentPermissionList
readonly={readonly}
objectPermissions={objectPermissions}
onDeletePermission={handleDeletePermission}
searchQuery={searchQuery}
/>
<WorkflowAiAgentPermissionsFlagList
title={t`Actions`}
permissions={filteredEnabledActionPermissions}
enabledPermissionFlagKeys={permissionFlagKeys}
readonly={readonly}
showDeleteButton={!readonly}
onDeletePermissionFlag={handleDeletePermissionFlag}
/>
</>
)}
</StyledContainer>
);
};
@@ -0,0 +1,84 @@
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import {
type AgentResponseSchema,
type ModelConfiguration,
} from 'twenty-shared/ai';
import { type SelectOption } from 'twenty-ui/input';
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
type WorkflowAiAgentPromptTabProps = {
readonly: boolean;
aiModelOptions: SelectOption[];
onPromptChange: (value: string) => void;
onModelChange: (modelId: string) => void;
onModelConfigurationChange: (configuration: ModelConfiguration) => void;
onResponseFormatChange: (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => void;
};
export const WorkflowAiAgentPromptTab = ({
readonly,
aiModelOptions,
onPromptChange,
onModelChange,
onModelConfigurationChange,
onResponseFormatChange,
}: WorkflowAiAgentPromptTabProps) => {
const workflowAiAgentActionAgent = useRecoilValue(
workflowAiAgentActionAgentState,
);
return (
<>
<FormTextFieldInput
multiline
VariablePicker={WorkflowVariablePicker}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
defaultValue={workflowAiAgentActionAgent?.prompt || ''}
onChange={onPromptChange}
readonly={readonly}
/>
{workflowAiAgentActionAgent ? (
<>
<Select
dropdownId="select-agent-model"
label={t`AI Model`}
options={aiModelOptions}
value={workflowAiAgentActionAgent.modelId}
onChange={onModelChange}
disabled={readonly}
/>
<SettingsAgentModelCapabilities
selectedModelId={workflowAiAgentActionAgent.modelId}
modelConfiguration={
workflowAiAgentActionAgent.modelConfiguration || {}
}
onConfigurationChange={onModelConfigurationChange}
disabled={readonly}
/>
<SettingsAgentResponseFormat
responseFormat={
workflowAiAgentActionAgent.responseFormat || {
type: 'text',
schema: {},
}
}
onResponseFormatChange={onResponseFormatChange}
disabled={readonly}
/>
</>
) : null}
</>
);
};
@@ -1,26 +1,42 @@
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { type SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { t } from '@lingui/core/macro';
import { WorkflowAiAgentPermissionsTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowAiAgentPermissionsTab';
import { WORKFLOW_AI_AGENT_TABS } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentTabs';
import { useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import {
type AgentResponseSchema,
type ModelConfiguration,
} from 'twenty-shared/ai';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLock, IconSparkles } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import {
useFindOneAgentQuery,
useGetRolesQuery,
useUpdateOneAgentMutation,
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
import { WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID } from '../constants/WorkflowAiAgentTabListComponentId';
import { WorkflowAiAgentPromptTab } from './WorkflowAiAgentPromptTab';
export type WorkflowAiAgentTabId =
(typeof WORKFLOW_AI_AGENT_TABS)[keyof typeof WORKFLOW_AI_AGENT_TABS];
type WorkflowEditActionAiAgentProps = {
action: WorkflowAiAgentAction;
@@ -32,72 +48,110 @@ type WorkflowEditActionAiAgentProps = {
};
};
const StyledTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledPermissionsStepBody = styled(WorkflowStepBody)`
padding-block: 0;
padding-inline: 0;
`;
export const WorkflowEditActionAiAgent = ({
action,
actionOptions,
}: WorkflowEditActionAiAgentProps) => {
const { t } = useLingui();
const componentInstanceId = `${WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}-${action.id}`;
const agentId = action.settings.input.agentId;
const { data: agentData, loading: agentLoading } = useFindOneAgentQuery({
variables: { id: agentId || '' },
skip: !agentId,
});
const [workflowAiAgentActionAgent, setWorkflowAiAgentActionAgent] =
useRecoilState(workflowAiAgentActionAgentState);
const { loading: agentLoading, refetch: refetchAgent } = useFindOneAgentQuery(
{
variables: { id: agentId || '' },
skip: !agentId,
onCompleted: (data) => {
setWorkflowAiAgentActionAgent(data.findOneAgent);
},
},
);
useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose();
const [updateAgent] = useUpdateOneAgentMutation();
const aiModelOptions = useAiModelOptions();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const flow = useFlowOrThrow();
const agent = agentData?.findOneAgent;
const handleAgentPromptChange = useDebouncedCallback(
async (newPrompt: string) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
await updateAgent({
const response = await updateAgent({
variables: {
input: {
id: agent.id,
id: workflowAiAgentActionAgent.id,
prompt: newPrompt,
},
},
refetchQueries: ['FindOneAgent'],
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
},
500,
);
const handleAgentModelChange = async (modelId: string) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
await updateAgent({
const response = await updateAgent({
variables: {
input: {
id: agent.id,
id: workflowAiAgentActionAgent.id,
modelId,
},
},
refetchQueries: ['FindOneAgent'],
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
};
const handleModelConfigurationChange = async (
configuration: ModelConfiguration,
) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
await updateAgent({
const response = await updateAgent({
variables: {
input: {
id: agent.id,
id: workflowAiAgentActionAgent.id,
modelConfiguration: configuration,
},
},
refetchQueries: ['FindOneAgent'],
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
};
@@ -105,18 +159,25 @@ export const WorkflowEditActionAiAgent = ({
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
if (
actionOptions.readonly === true ||
!isDefined(workflowAiAgentActionAgent)
) {
return;
}
await updateAgent({
const response = await updateAgent({
variables: {
input: {
id: agent.id,
id: workflowAiAgentActionAgent.id,
responseFormat: format,
},
},
refetchQueries: ['FindOneAgent'],
});
setWorkflowAiAgentActionAgent({
...workflowAiAgentActionAgent,
...response.data?.updateOneAgent,
});
await updateWorkflowVersionStep({
@@ -130,11 +191,43 @@ export const WorkflowEditActionAiAgent = ({
300,
);
const tabs: SingleTabProps[] = [
{
id: WORKFLOW_AI_AGENT_TABS.PROMPT,
title: t`Prompt`,
Icon: IconSparkles,
},
{
id: WORKFLOW_AI_AGENT_TABS.PERMISSIONS,
title: t`Permissions`,
Icon: IconLock,
},
];
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
componentInstanceId,
);
const currentTabId =
(activeTabId as WorkflowAiAgentTabId) ?? WORKFLOW_AI_AGENT_TABS.PROMPT;
const navigateSettings = useNavigateSettings();
const { data: rolesData } = useGetRolesQuery();
const [
workflowAiAgentPermissionsIsAddingPermission,
setWorkflowAiAgentPermissionsIsAddingPermission,
] = useRecoilState(workflowAiAgentPermissionsIsAddingPermissionState);
const role = rolesData?.getRoles.find(
(item) => item.id === workflowAiAgentActionAgent?.roleId,
);
const handleAgentResponseFormatChange = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (format.type !== agent?.responseFormat?.type) {
if (format.type !== workflowAiAgentActionAgent?.responseFormat?.type) {
debouncedUpdateAgentResponseFormat.cancel();
void updateAgentResponseFormat(format);
} else {
@@ -142,50 +235,77 @@ export const WorkflowEditActionAiAgent = ({
}
};
const handleViewRole = () => {
if (isDefined(role?.id)) {
navigateSettings(SettingsPath.RoleDetail, { roleId: role.id });
}
};
const getFooterActions = () => {
if (currentTabId !== WORKFLOW_AI_AGENT_TABS.PERMISSIONS) {
return [];
}
if (workflowAiAgentPermissionsIsAddingPermission) {
return [
<CmdEnterActionButton
key="view-role"
title={t`View role`}
onClick={handleViewRole}
disabled={!isDefined(role?.id)}
/>,
];
}
if (isDefined(actionOptions.readonly)) {
return [];
}
return [
<CmdEnterActionButton
key="add-permission"
title={t`Add permission`}
onClick={() => setWorkflowAiAgentPermissionsIsAddingPermission(true)}
/>,
];
};
return agentLoading ? (
<RightDrawerSkeletonLoader />
) : (
<>
<WorkflowStepBody>
<FormTextFieldInput
multiline
VariablePicker={WorkflowVariablePicker}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
defaultValue={agent?.prompt || ''}
onChange={handleAgentPromptChange}
readonly={actionOptions.readonly}
<StyledTabList
tabs={tabs}
componentInstanceId={componentInstanceId}
behaveAsLinks={false}
/>
{currentTabId === WORKFLOW_AI_AGENT_TABS.PERMISSIONS ? (
<StyledPermissionsStepBody>
<WorkflowAiAgentPermissionsTab
action={action}
readonly={actionOptions.readonly === true}
isAgentLoading={agentLoading}
refetchAgent={refetchAgent}
/>
</StyledPermissionsStepBody>
) : (
<WorkflowStepBody>
<WorkflowAiAgentPromptTab
readonly={actionOptions.readonly === true}
aiModelOptions={aiModelOptions}
onPromptChange={handleAgentPromptChange}
onModelChange={handleAgentModelChange}
onModelConfigurationChange={handleModelConfigurationChange}
onResponseFormatChange={handleAgentResponseFormatChange}
/>
</WorkflowStepBody>
)}
{!actionOptions.readonly && (
<WorkflowStepFooter
additionalActions={getFooterActions()}
stepId={action.id}
/>
{isDefined(agent) && (
<>
<Select
dropdownId="select-agent-model"
label={t`AI Model`}
options={aiModelOptions}
value={agent.modelId}
onChange={handleAgentModelChange}
disabled={actionOptions.readonly}
/>
<SettingsAgentModelCapabilities
selectedModelId={agent.modelId}
modelConfiguration={agent.modelConfiguration || {}}
onConfigurationChange={handleModelConfigurationChange}
disabled={actionOptions.readonly}
/>
<SettingsAgentResponseFormat
responseFormat={
agent.responseFormat || { type: 'text', schema: {} }
}
onResponseFormatChange={handleAgentResponseFormatChange}
disabled={actionOptions.readonly}
/>
</>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
)}
</>
);
};
@@ -0,0 +1,40 @@
import type { PermissionFlagType } from '~/generated-metadata/graphql';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export type FilteredPermissionsResult<T> = {
filteredPermissions: T[];
filteredEnabledPermissions: T[];
};
export const getFilteredPermissions = <
T extends { key: PermissionFlagType; name: string },
>({
permissions,
permissionFlagKeys,
searchQuery,
}: {
permissions: T[];
permissionFlagKeys: PermissionFlagType[];
searchQuery: string;
}): FilteredPermissionsResult<T> => {
const filteredPermissions = filterBySearchQuery<T>({
items: permissions,
searchQuery,
getSearchableValues: (permission) => [permission.name],
});
const enabledPermissions = permissions.filter((permission) =>
permissionFlagKeys.includes(permission.key),
);
const filteredEnabledPermissions = filterBySearchQuery<T>({
items: enabledPermissions,
searchQuery,
getSearchableValues: (permission) => [permission.name],
});
return {
filteredPermissions,
filteredEnabledPermissions,
};
};
@@ -0,0 +1,23 @@
import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig';
export const CRUD_PERMISSIONS: Array<{
key: SettingsRoleObjectPermissionKey;
label: (objectLabel: string) => string;
}> = [
{
key: 'canReadObjectRecords',
label: (objectLabel: string) => `See ${objectLabel}`,
},
{
key: 'canUpdateObjectRecords',
label: (objectLabel: string) => `Edit ${objectLabel}`,
},
{
key: 'canSoftDeleteObjectRecords',
label: (objectLabel: string) => `Delete ${objectLabel}`,
},
{
key: 'canDestroyObjectRecords',
label: (objectLabel: string) => `Destroy ${objectLabel}`,
},
];
@@ -0,0 +1,2 @@
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =
'workflow-ai-agent-tab-list';
@@ -0,0 +1,4 @@
export const WORKFLOW_AI_AGENT_TABS = {
PROMPT: 'prompt',
PERMISSIONS: 'permissions',
} as const;
@@ -0,0 +1,27 @@
import { useListenToSidePanelClosing } from '@/ui/layout/right-drawer/hooks/useListenToSidePanelClosing';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState';
import { useResetRecoilState, useSetRecoilState } from 'recoil';
export const useResetWorkflowAiAgentPermissionsStateOnCommandMenuClose = () => {
const resetSelectedObjectId = useResetRecoilState(
workflowAiAgentPermissionsSelectedObjectIdState,
);
const setIsAddingPermission = useSetRecoilState(
workflowAiAgentPermissionsIsAddingPermissionState,
);
const resetAgentState = useResetRecoilState(workflowAiAgentActionAgentState);
const resetPermissionState = () => {
resetSelectedObjectId();
setIsAddingPermission(false);
resetAgentState();
};
useListenToSidePanelClosing(resetPermissionState);
return {
resetPermissionState,
};
};
@@ -0,0 +1,365 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type SettingsRoleObjectPermissionKey } from '@/settings/roles/role-permissions/objects-permissions/constants/SettingsRoleObjectPermissionIconConfig';
import { useActionRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig';
import { useSettingsRolePermissionFlagConfig } from '@/settings/roles/role-permissions/permission-flags/hooks/useSettingsRolePermissionFlagConfig';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CRUD_PERMISSIONS } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/WorkflowAiAgentCrudPermissions';
import { workflowAiAgentActionAgentState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentActionAgentState';
import { workflowAiAgentPermissionsIsAddingPermissionState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsIsAddingPermissionState';
import { workflowAiAgentPermissionsSelectedObjectIdState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentPermissionsSelectedObjectIdState';
import { t } from '@lingui/core/macro';
import { useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
useAssignRoleToAgentMutation,
useCreateOneRoleMutation,
useUpsertObjectPermissionsMutation,
useUpsertPermissionFlagsMutation,
type Agent,
type ObjectPermission,
type PermissionFlagType,
} from '~/generated-metadata/graphql';
type UseWorkflowAiAgentPermissionActionsParams = {
readonly: boolean;
objectPermissions: ObjectPermission[];
permissionFlagKeys: PermissionFlagType[];
refetchAgentAndRoles: () => Promise<{ refetchedAgent?: Agent }>;
};
export const useWorkflowAiAgentPermissionActions = ({
readonly,
objectPermissions,
permissionFlagKeys,
refetchAgentAndRoles,
}: UseWorkflowAiAgentPermissionActionsParams) => {
const { enqueueSuccessSnackBar } = useSnackBar();
const [workflowAiAgentActionAgent, setWorkflowAiAgentActionAgent] =
useRecoilState(workflowAiAgentActionAgentState);
const { alphaSortedActiveNonSystemObjectMetadataItems: objectMetadataItems } =
useFilteredObjectMetadataItems();
const settingsPermissionsConfig = useSettingsRolePermissionFlagConfig({
assignmentCapabilities: { canBeAssignedToAgents: true },
});
const actionPermissionsConfig = useActionRolePermissionFlagConfig({
assignmentCapabilities: { canBeAssignedToAgents: true },
});
const [, setWorkflowAiAgentPermissionsSelectedObjectId] = useRecoilState(
workflowAiAgentPermissionsSelectedObjectIdState,
);
const [, setWorkflowAiAgentPermissionsIsAddingPermission] = useRecoilState(
workflowAiAgentPermissionsIsAddingPermissionState,
);
const [createRole] = useCreateOneRoleMutation();
const [assignRoleToAgent] = useAssignRoleToAgentMutation();
const [upsertObjectPermissions] = useUpsertObjectPermissionsMutation();
const [upsertPermissionFlags] = useUpsertPermissionFlagsMutation();
const roleId = workflowAiAgentActionAgent?.roleId;
const permissionFlagLabelMap = useMemo(
() =>
[...settingsPermissionsConfig, ...actionPermissionsConfig].reduce<
Partial<Record<PermissionFlagType, string>>
>((acc, permission) => {
acc[permission.key] = permission.name;
return acc;
}, {}),
[settingsPermissionsConfig, actionPermissionsConfig],
);
const ensureRoleId = async (): Promise<string | undefined> => {
if (isDefined(roleId)) {
return roleId;
}
if (!isDefined(workflowAiAgentActionAgent) || readonly) {
return undefined;
}
const agentDisplayName =
workflowAiAgentActionAgent.label ??
workflowAiAgentActionAgent.name ??
t`Agent`;
const roleName = `${agentDisplayName} role (${workflowAiAgentActionAgent.id.substring(0, 8)})`;
const generatedRoleId = v4();
await createRole({
variables: {
createRoleInput: {
id: generatedRoleId,
label: roleName,
description: t`Auto-generated role for ${agentDisplayName}`,
icon: 'IconRobot',
canAccessAllTools: false,
canUpdateAllSettings: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToAgents: true,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
},
},
});
await assignRoleToAgent({
variables: {
agentId: workflowAiAgentActionAgent.id,
roleId: generatedRoleId,
},
});
await refetchAgentAndRoles();
return generatedRoleId;
};
const handleAddPermission = async (
objectMetadataId: string,
permissionKey: SettingsRoleObjectPermissionKey,
) => {
const ensuredRoleId = await ensureRoleId();
if (!ensuredRoleId) {
return;
}
const objectPermission = objectPermissions.find(
(permission) => permission.objectMetadataId === objectMetadataId,
);
const updatedPermission: ObjectPermission = {
objectMetadataId,
canReadObjectRecords:
permissionKey === 'canReadObjectRecords'
? true
: (objectPermission?.canReadObjectRecords ??
(permissionKey === 'canUpdateObjectRecords' ||
permissionKey === 'canSoftDeleteObjectRecords' ||
permissionKey === 'canDestroyObjectRecords'
? true
: false)),
canUpdateObjectRecords:
permissionKey === 'canUpdateObjectRecords'
? true
: (objectPermission?.canUpdateObjectRecords ?? false),
canSoftDeleteObjectRecords:
permissionKey === 'canSoftDeleteObjectRecords'
? true
: (objectPermission?.canSoftDeleteObjectRecords ?? false),
canDestroyObjectRecords:
permissionKey === 'canDestroyObjectRecords'
? true
: (objectPermission?.canDestroyObjectRecords ?? false),
};
const allObjectPermissions = objectPermissions
.filter((permission) => permission.objectMetadataId !== objectMetadataId)
.map((permission) => ({
objectMetadataId: permission.objectMetadataId,
canReadObjectRecords: permission.canReadObjectRecords ?? false,
canUpdateObjectRecords: permission.canUpdateObjectRecords ?? false,
canSoftDeleteObjectRecords:
permission.canSoftDeleteObjectRecords ?? false,
canDestroyObjectRecords: permission.canDestroyObjectRecords ?? false,
}));
allObjectPermissions.push({
objectMetadataId: updatedPermission.objectMetadataId,
canReadObjectRecords: updatedPermission.canReadObjectRecords ?? false,
canUpdateObjectRecords: updatedPermission.canUpdateObjectRecords ?? false,
canSoftDeleteObjectRecords:
updatedPermission.canSoftDeleteObjectRecords ?? false,
canDestroyObjectRecords:
updatedPermission.canDestroyObjectRecords ?? false,
});
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: ensuredRoleId,
objectPermissions: allObjectPermissions,
},
},
});
await refetchAgentAndRoles();
setWorkflowAiAgentPermissionsIsAddingPermission(false);
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
};
const handleDeletePermission = async (
objectMetadataId: string,
permissionKey: SettingsRoleObjectPermissionKey,
) => {
if (!isDefined(roleId) || readonly) {
return;
}
const objectPermission = objectPermissions.find(
(permission) => permission.objectMetadataId === objectMetadataId,
);
if (!isDefined(objectPermission)) {
return;
}
const objectMetadata = objectMetadataItems.find(
(item) => item.id === objectMetadataId,
);
if (!isDefined(objectMetadata)) {
return;
}
const updatedPermission: ObjectPermission = {
objectMetadataId,
canReadObjectRecords:
permissionKey === 'canReadObjectRecords'
? false
: (objectPermission.canReadObjectRecords ?? false),
canUpdateObjectRecords:
permissionKey === 'canUpdateObjectRecords'
? false
: (objectPermission.canUpdateObjectRecords ?? false),
canSoftDeleteObjectRecords:
permissionKey === 'canSoftDeleteObjectRecords'
? false
: (objectPermission.canSoftDeleteObjectRecords ?? false),
canDestroyObjectRecords:
permissionKey === 'canDestroyObjectRecords'
? false
: (objectPermission.canDestroyObjectRecords ?? false),
};
if (permissionKey === 'canReadObjectRecords') {
updatedPermission.canUpdateObjectRecords = false;
updatedPermission.canSoftDeleteObjectRecords = false;
updatedPermission.canDestroyObjectRecords = false;
}
const allObjectPermissions = objectPermissions
.filter((permission) => permission.objectMetadataId !== objectMetadataId)
.map((permission) => ({
objectMetadataId: permission.objectMetadataId,
canReadObjectRecords: permission.canReadObjectRecords ?? false,
canUpdateObjectRecords: permission.canUpdateObjectRecords ?? false,
canSoftDeleteObjectRecords:
permission.canSoftDeleteObjectRecords ?? false,
canDestroyObjectRecords: permission.canDestroyObjectRecords ?? false,
}));
allObjectPermissions.push({
objectMetadataId: updatedPermission.objectMetadataId,
canReadObjectRecords: updatedPermission.canReadObjectRecords ?? false,
canUpdateObjectRecords: updatedPermission.canUpdateObjectRecords ?? false,
canSoftDeleteObjectRecords:
updatedPermission.canSoftDeleteObjectRecords ?? false,
canDestroyObjectRecords:
updatedPermission.canDestroyObjectRecords ?? false,
});
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId,
objectPermissions: allObjectPermissions,
},
},
});
await refetchAgentAndRoles();
const permissionLabel = CRUD_PERMISSIONS.find(
(permission) => permission.key === permissionKey,
)?.label(objectMetadata.labelPlural);
if (isDefined(permissionLabel)) {
enqueueSuccessSnackBar({
message: t`${permissionLabel} Permission removed`,
});
}
};
const handleAddPermissionFlag = async (
permissionFlagKey: PermissionFlagType,
) => {
if (readonly) {
return;
}
const hadRoleBefore = isDefined(roleId);
const ensuredRoleId = await ensureRoleId();
if (!ensuredRoleId || permissionFlagKeys.includes(permissionFlagKey)) {
return;
}
await upsertPermissionFlags({
variables: {
upsertPermissionFlagsInput: {
roleId: ensuredRoleId,
permissionFlagKeys: [...permissionFlagKeys, permissionFlagKey],
},
},
});
const { refetchedAgent } = await refetchAgentAndRoles();
if (
!hadRoleBefore &&
isDefined(refetchedAgent) &&
isDefined(setWorkflowAiAgentActionAgent)
) {
setWorkflowAiAgentActionAgent(refetchedAgent);
}
setWorkflowAiAgentPermissionsIsAddingPermission(false);
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
};
const handleDeletePermissionFlag = async (
permissionFlagKey: PermissionFlagType,
) => {
if (!isDefined(roleId) || readonly) {
return;
}
if (!permissionFlagKeys.includes(permissionFlagKey)) {
return;
}
const permissionFlagKeysAfterRemoval = permissionFlagKeys.filter(
(flag) => flag !== permissionFlagKey,
);
await upsertPermissionFlags({
variables: {
upsertPermissionFlagsInput: {
roleId,
permissionFlagKeys: permissionFlagKeysAfterRemoval,
},
},
});
await refetchAgentAndRoles();
const permissionLabel = permissionFlagLabelMap[permissionFlagKey];
if (isDefined(permissionLabel)) {
enqueueSuccessSnackBar({
message: t`${permissionLabel} permission removed`,
});
}
};
return {
handleAddPermission,
handleDeletePermission,
handleAddPermissionFlag,
handleDeletePermissionFlag,
};
};
@@ -0,0 +1,7 @@
import { atom } from 'recoil';
import { type Agent } from '~/generated-metadata/graphql';
export const workflowAiAgentActionAgentState = atom<Agent | undefined>({
key: 'workflowAiAgentActionAgentState',
default: undefined,
});
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const workflowAiAgentPermissionsIsAddingPermissionState = atom<boolean>({
key: 'workflowAiAgentPermissionsIsAddingPermissionState',
default: false,
});
@@ -0,0 +1,8 @@
import { atom } from 'recoil';
export const workflowAiAgentPermissionsSelectedObjectIdState = atom<
string | undefined
>({
key: 'workflowAiAgentPermissionsSelectedObjectIdState',
default: undefined,
});