Agent role edition (#15914)

## 🐛 Issues Fixed

### 1. Role Editing Not Working for New Agents
When creating a new agent and then creating a role for it, the role
permissions appeared as non-editable. This was caused by:
- In create mode, `agentId = ''` (empty string from `useParams`)
- Empty string is **falsy** but `isDefined('')` returns **true**
- This caused incorrect behavior in role exclusivity checks and API
calls

### 2. Missing Role Data Loading
The agent form wasn't loading role data needed for permission editing
because it was missing the `SettingsRolesQueryEffect` component.

### 3. Navigation Conflicts
The `useSaveDraftRoleToDB` hook contained navigation logic that caused
conflicts when used from different contexts (role detail page vs agent
form).

### 4. Linting Errors
Unused `useIcons` import in `SettingsAgentRoleTab.tsx`.

---

## 🔧 Changes Made

### Agent Role Tab (`SettingsAgentRoleTab.tsx`)
-  Use `isNonEmptyString(agentId)` to validate agentId (follows
codebase patterns)
-  Improved role exclusivity logic to handle both create and edit
modes:
  - **Edit mode**: Role must be assigned exclusively to this agent
  - **Create mode**: Role must not be assigned to anyone yet
-  Only call `assignRoleToAgent` when a valid agentId exists
-  Pass `undefined` instead of empty string for `fromAgentId` prop

### Role Hook (`useSaveDraftRoleToDB.ts`)
-  Removed navigation logic from the hook (better separation of
concerns)
-  Removed `useNavigateSettings` and `SettingsPath` dependencies
-  Hook now only handles data persistence, not navigation

### Role Component (`SettingsRole.tsx`)
-  Added navigation after successful role creation in create mode
-  Navigation now handled by the component using the hook

### Agent Form (`SettingsAgentForm.tsx`)
-  Added `SettingsRolesQueryEffect` to ensure role data is loaded
-  Agent form handles its own navigation after save

---

##  Testing Scenarios

All these scenarios now work correctly:
1.  Create new agent → Create role → Edit permissions → Save agent
2.  Edit existing agent → Create role → Edit permissions → Save
3.  Edit existing agent → Try to edit shared role (shows warning
message)
4.  Navigate to object-level permissions from agent form with proper
breadcrumbs

---

## 📝 Technical Details

### Root Cause Analysis
The main issue was that empty string was being treated differently in
different checks:
- `agentId &&` → evaluates to `false` (empty string is falsy)
- `isDefined(agentId)` → returns `true` (empty string is defined)

This inconsistency caused the role to appear as non-editable and
attempted invalid API calls.

### Solution
Used `isNonEmptyString()` from `@sniptt/guards` which properly checks
both that the value is defined AND not empty, following codebase
conventions.

---

## 🎯 Impact

- Fixes critical bug preventing role permission configuration for new
agents
- Improves code quality with better separation of concerns
- Makes navigation logic more predictable and maintainable
- Follows codebase patterns and guidelines
This commit is contained in:
Félix Malfait
2025-11-19 11:26:05 +01:00
committed by GitHub
parent 92fb4c708b
commit 783fe8d387
14 changed files with 730 additions and 524 deletions
@@ -6,51 +6,18 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
import { SettingsRoleDefaultRole } from '@/settings/roles/components/SettingsRolesDefaultRole';
import { SettingsRolesList } from '@/settings/roles/components/SettingsRolesList';
import { ROLES_LIST_TABS } from '@/settings/roles/constants/RolesListTabs';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { settingsRolesIsLoadingState } from '@/settings/roles/states/settingsRolesIsLoadingState';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { Trans, useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { H3Title, IconUser, IconRobot, IconKey } from 'twenty-ui/display';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated/graphql';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { H3Title } from 'twenty-ui/display';
export const SettingsRolesContainer = () => {
const { t } = useLingui();
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
ROLES_LIST_TABS.COMPONENT_INSTANCE_ID,
);
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
const settingsRolesIsLoading = useRecoilValue(settingsRolesIsLoadingState);
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const tabs = [
{
id: ROLES_LIST_TABS.TABS_IDS.USER_ROLES,
title: t`User Roles`,
Icon: IconUser,
},
...(isAiEnabled
? [
{
id: ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES,
title: t`Agent Roles`,
Icon: IconRobot,
},
]
: []),
{
id: ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES,
title: t`API Key Roles`,
Icon: IconKey,
},
];
if (settingsRolesIsLoading && !settingsAllRoles) {
return null;
@@ -68,15 +35,8 @@ export const SettingsRolesContainer = () => {
]}
>
<SettingsPageContainer>
<TabList
tabs={tabs}
className="tab-list"
componentInstanceId={ROLES_LIST_TABS.COMPONENT_INSTANCE_ID}
/>
<SettingsRolesList />
{activeTabId === ROLES_LIST_TABS.TABS_IDS.USER_ROLES && (
<SettingsRoleDefaultRole roles={settingsAllRoles} />
)}
<SettingsRoleDefaultRole roles={settingsAllRoles} />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -1,23 +1,31 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { Table } from '@/ui/layout/table/components/Table';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { SettingsRolesTableHeader } from '@/settings/roles/components/SettingsRolesTableHeader';
import { SettingsRolesTableRow } from '@/settings/roles/components/SettingsRolesTableRow';
import { ROLES_LIST_TABS } from '@/settings/roles/constants/RolesListTabs';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconPlus, IconSearch } from 'twenty-ui/display';
import {
H2Title,
IconFilter,
IconKey,
IconPlus,
IconRobot,
IconSearch,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { sortByAscString } from '~/utils/array/sortByAscString';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { useState } from 'react';
const StyledCreateRoleSection = styled(Section)`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
@@ -36,19 +44,22 @@ const StyledNoRoles = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledSearchInput = styled(SettingsTextInput)`
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
export const SettingsRolesList = () => {
const navigateSettings = useNavigateSettings();
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
ROLES_LIST_TABS.COMPONENT_INSTANCE_ID,
);
const [searchTerm, setSearchTerm] = useState('');
const [showAgentRoles, setShowAgentRoles] = useState(false);
const [showApiKeyRoles, setShowApiKeyRoles] = useState(false);
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
@@ -57,52 +68,69 @@ export const SettingsRolesList = () => {
);
const filteredRoles = sortedSettingsAllRoles.filter((role) => {
let matchesTab = false;
const matchesType =
role.canBeAssignedToUsers ||
(showAgentRoles && role.canBeAssignedToAgents) ||
(showApiKeyRoles && role.canBeAssignedToApiKeys);
switch (activeTabId) {
case ROLES_LIST_TABS.TABS_IDS.USER_ROLES:
matchesTab = role.canBeAssignedToUsers;
break;
case ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES:
matchesTab = role.canBeAssignedToAgents;
break;
case ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES:
matchesTab = role.canBeAssignedToApiKeys;
break;
default:
matchesTab = role.canBeAssignedToUsers;
}
const matchesSearch = role.label
?.toLowerCase()
.includes(searchTerm.toLowerCase());
return (
matchesTab && role.label?.toLowerCase().includes(searchTerm.toLowerCase())
);
return matchesType && matchesSearch;
});
const tabDescriptions: Record<string, string> = {
[ROLES_LIST_TABS.TABS_IDS.USER_ROLES]:
t`Assign roles to specify each member's access permissions`,
[ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES]:
t`Assign roles to specify each agent's access permissions`,
[ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES]:
t`Assign roles to specify each API key's access permissions`,
};
const description =
(activeTabId && tabDescriptions[activeTabId]) ??
t`Assign roles to specify each member's access permissions`;
return (
<Section>
<H2Title title={t`All roles`} description={description} />
<StyledSearchInput
instanceId="settings-objects-search"
LeftIcon={IconSearch}
placeholder={t`Search a role...`}
value={searchTerm}
onChange={setSearchTerm}
<H2Title
title={t`All roles`}
description={t`Assign roles to specify access permissions`}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="settings-roles-search"
LeftIcon={IconSearch}
placeholder={t`Search a role...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-roles-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconRobot}
onToggleChange={() => setShowAgentRoles(!showAgentRoles)}
toggled={showAgentRoles}
text={t`Agent roles`}
toggleSize="small"
/>
<MenuItemToggle
LeftIcon={IconKey}
onToggleChange={() => setShowApiKeyRoles(!showApiKeyRoles)}
toggled={showApiKeyRoles}
text={t`API key roles`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>
<Table>
<SettingsRolesTableHeader />
<StyledTableRows>
@@ -13,11 +13,13 @@ const StyledRolePermissionsContainer = styled.div`
type SettingsRolePermissionsProps = {
roleId: string;
isEditable: boolean;
fromAgentId?: string;
};
export const SettingsRolePermissions = ({
roleId,
isEditable,
fromAgentId,
}: SettingsRolePermissionsProps) => {
return (
<StyledRolePermissionsContainer>
@@ -27,7 +29,7 @@ export const SettingsRolePermissions = ({
/>
<SettingsRolePermissionsObjectLevelSection
roleId={roleId}
isEditable={isEditable}
fromAgentId={fromAgentId}
/>
<SettingsRolePermissionsSettingsSection
roleId={roleId}
@@ -7,6 +7,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { H2Title, IconSearch, useIcons } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
@@ -52,6 +53,8 @@ export const SettingsRolePermissionsObjectLevelObjectPicker = ({
}) => {
const theme = useTheme();
const navigate = useNavigateSettings();
const [searchParams] = useSearchParams();
const fromAgentId = searchParams.get('fromAgent');
const [searchFilter, setSearchFilter] = useState('');
const { objectMetadataItemsThatCanHavePermission } =
@@ -64,10 +67,11 @@ export const SettingsRolePermissionsObjectLevelObjectPicker = ({
};
const handleSelectObjectMetadata = (objectMetadataId: string) => {
navigate(SettingsPath.RoleObjectLevel, {
roleId,
objectMetadataId,
});
navigate(
SettingsPath.RoleObjectLevel,
{ roleId, objectMetadataId },
fromAgentId ? { fromAgent: fromAgentId } : undefined,
);
};
const { filterObjectMetadataItemsWithPermissionOverride } =
@@ -2,10 +2,12 @@ import { SettingsRolePermissionsObjectLevelTableHeader } from '@/settings/roles/
import { SettingsRolePermissionsObjectLevelTableRow } from '@/settings/roles/role-permissions/object-level-permissions/components/SettingsRolePermissionsObjectLevelTableRow';
import { useFilterObjectMetadataItemsWithPermissionOverride } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useFilterObjectWithPermissionOverride';
import { useObjectMetadataItemsThatCanHavePermission } from '@/settings/roles/role-permissions/object-level-permissions/hooks/useObjectMetadataItemsThatCanHavePermission';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
@@ -28,7 +30,7 @@ const StyledTableRows = styled.div`
type SettingsRolePermissionsObjectLevelSectionProps = {
roleId: string;
isEditable: boolean;
fromAgentId?: string;
};
const StyledNoOverride = styled(TableCell)`
@@ -37,10 +39,14 @@ const StyledNoOverride = styled(TableCell)`
export const SettingsRolePermissionsObjectLevelSection = ({
roleId,
isEditable,
fromAgentId,
}: SettingsRolePermissionsObjectLevelSectionProps) => {
const navigateSettings = useNavigateSettings();
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const { objectMetadataItemsThatCanHavePermission } =
useObjectMetadataItemsThatCanHavePermission();
@@ -59,9 +65,11 @@ export const SettingsRolePermissionsObjectLevelSection = ({
objectMetadataItemsThatCanHavePermission.length;
const handleAddRule = () => {
navigateSettings(SettingsPath.RoleAddObjectLevel, {
roleId,
});
navigateSettings(
SettingsPath.RoleAddObjectLevel,
{ roleId },
fromAgentId ? { fromAgent: fromAgentId } : undefined,
);
};
const hasObjectPermissions =
@@ -82,6 +90,7 @@ export const SettingsRolePermissionsObjectLevelSection = ({
key={objectMetadataItem.id}
objectMetadataItem={objectMetadataItem}
roleId={roleId}
fromAgentId={fromAgentId}
/>
),
)
@@ -98,7 +107,9 @@ export const SettingsRolePermissionsObjectLevelSection = ({
title={t`Add rule`}
variant="secondary"
size="small"
disabled={!isEditable || allObjectsHaveSetPermission}
disabled={
!settingsDraftRole.isEditable || allObjectsHaveSetPermission
}
onClick={handleAddRule}
/>
</StyledCreateObjectOverrideSection>
@@ -29,11 +29,13 @@ const StyledNameLabel = styled.div`
type SettingsRolePermissionsObjectLevelTableRowProps = {
objectMetadataItem: ObjectMetadataItem;
roleId: string;
fromAgentId?: string;
};
export const SettingsRolePermissionsObjectLevelTableRow = ({
objectMetadataItem,
roleId,
fromAgentId,
}: SettingsRolePermissionsObjectLevelTableRowProps) => {
const { getIcon } = useIcons();
const theme = useTheme();
@@ -42,12 +44,18 @@ export const SettingsRolePermissionsObjectLevelTableRow = ({
const objectLabelPlural = objectMetadataItem.labelPlural;
const navigationPath = getSettingsPath(SettingsPath.RoleObjectLevel, {
roleId: roleId,
objectMetadataId: objectMetadataItem.id,
});
const navigationUrl = fromAgentId
? `${navigationPath}?fromAgent=${fromAgentId}`
: navigationPath;
return (
<TableRow
to={getSettingsPath(SettingsPath.RoleObjectLevel, {
roleId: roleId,
objectMetadataId: objectMetadataItem.id,
})}
to={navigationUrl}
gridAutoColumns={OBJECT_LEVEL_PERMISSION_TABLE_GRID_AUTO_COLUMNS}
>
<StyledNameTableCell>
@@ -5,10 +5,12 @@ import { SettingsRolePermissionsObjectLevelObjectFormObjectLevel } from '@/setti
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
import { useFindOneAgentQuery } from '~/generated-metadata/graphql';
type SettingsRolePermissionsObjectLevelObjectFormProps = {
roleId: string;
@@ -19,10 +21,18 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
roleId,
objectMetadataId,
}: SettingsRolePermissionsObjectLevelObjectFormProps) => {
const [searchParams] = useSearchParams();
const fromAgentId = searchParams.get('fromAgent');
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const { data: agentData } = useFindOneAgentQuery({
variables: { id: fromAgentId || '' },
skip: !fromAgentId,
});
const objectMetadata = useObjectMetadataItemById({
objectId: objectMetadataId,
});
@@ -32,37 +42,65 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
const objectLabelSingular = objectMetadataItem.labelSingular;
const objectLabelPlural = objectMetadataItem.labelPlural;
const agent = agentData?.findOneAgent;
const breadcrumbLinks =
fromAgentId && isDefined(agent)
? [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`AI`,
href: getSettingsPath(SettingsPath.AI),
},
{
children: agent.label,
href: getSettingsPath(SettingsPath.AIAgentDetail, {
agentId: agent.id,
}),
},
{
children: t`Permissions · ${objectLabelSingular}`,
},
]
: [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Roles`,
href: getSettingsPath(SettingsPath.Roles),
},
{
children: settingsDraftRole.label,
href: getSettingsPath(SettingsPath.RoleDetail, {
roleId,
}),
},
{
children: t`Permissions · ${objectLabelSingular}`,
},
];
const finishButtonPath =
fromAgentId && isDefined(agent)
? getSettingsPath(SettingsPath.AIAgentDetail, { agentId: agent.id })
: getSettingsPath(SettingsPath.RoleDetail, { roleId });
return (
<SubMenuTopBarContainer
title={t`2. Set ${objectLabelPlural} permissions`}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`Roles`,
href: getSettingsPath(SettingsPath.Roles),
},
{
children: settingsDraftRole.label,
href: getSettingsPath(SettingsPath.RoleDetail, {
roleId,
}),
},
{
children: t`Permissions · ${objectLabelSingular}`,
},
]}
links={breadcrumbLinks}
actionButton={
<Button
title={t`Finish`}
variant="secondary"
size="small"
accent="blue"
to={getSettingsPath(SettingsPath.RoleDetail, {
roleId,
})}
to={finishButtonPath}
/>
}
>
@@ -20,7 +20,7 @@ import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined, getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { IconLockOpen, IconSettings, IconUserPlus } from 'twenty-ui/display';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -60,6 +60,11 @@ export const SettingsRole = ({ roleId, isCreateMode }: SettingsRoleProps) => {
const { saveDraftRoleToDB } = useSaveDraftRoleToDB({
isCreateMode,
roleId,
onSuccess: async (savedRoleId) => {
if (isCreateMode) {
navigateSettings(SettingsPath.RoleDetail, { roleId: savedRoleId });
}
},
});
const isRoleEditable = settingsDraftRole.isEditable;
@@ -8,7 +8,6 @@ import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDr
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { getOperationName } from '@apollo/client/utilities';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
useCreateOneRoleMutation,
@@ -18,7 +17,6 @@ import {
useUpsertPermissionFlagsMutation,
} from '~/generated-metadata/graphql';
import { type Role } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { getDirtyFields } from '~/utils/getDirtyFields';
const ROLE_BASIC_KEYS: Array<keyof Role> = [
@@ -39,9 +37,11 @@ const ROLE_BASIC_KEYS: Array<keyof Role> = [
export const useSaveDraftRoleToDB = ({
roleId,
isCreateMode,
onSuccess,
}: {
roleId: string;
isCreateMode: boolean;
onSuccess?: (savedRoleId: string) => void | Promise<void>;
}) => {
const [createRole] = useCreateOneRoleMutation();
const [updateRole] = useUpdateOneRoleMutation();
@@ -51,7 +51,6 @@ export const useSaveDraftRoleToDB = ({
const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId);
const { addAgentsToRole } = useUpdateAgentRole(roleId);
const { addApiKeysToRole } = useUpdateApiKeyRole(roleId);
const navigateSettings = useNavigateSettings();
const settingsPersistedRole = useRecoilValue(
settingsPersistedRoleFamilyState(roleId),
@@ -100,7 +99,7 @@ export const useSaveDraftRoleToDB = ({
const { removeFieldPermissionInDraftRole } =
useRemoveFieldPermissionInDraftRole();
const saveDraftRoleToDB = async () => {
const removeUselessFieldPermissions = () => {
if (
isNonEmptyArray(
fieldPermissionsThatShouldntBeCreatedBecauseTheyAreUseless,
@@ -113,215 +112,233 @@ export const useSaveDraftRoleToDB = ({
);
}
}
};
if (isCreateMode) {
const { data } = await createRole({
const createNewRole = async () => {
const { data } = await createRole({
variables: {
createRoleInput: {
id: roleId,
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canAccessAllTools: settingsDraftRole.canAccessAllTools,
canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys,
} satisfies Partial<Role>,
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
if (!data) {
return;
}
const createdRoleId = data.createOneRole.id;
await upsertRolePermissions(createdRoleId);
await assignEntitiesToRole(createdRoleId);
if (isDefined(onSuccess)) {
await onSuccess(createdRoleId);
}
};
const updateExistingRole = async () => {
if (isDefined(dirtyFields.permissionFlags)) {
await upsertPermissionFlags({
variables: {
createRoleInput: {
id: roleId,
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canAccessAllTools: settingsDraftRole.canAccessAllTools,
canReadAllObjectRecords: settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys,
} satisfies Partial<Role>,
upsertPermissionFlagsInput: {
roleId: roleId,
permissionFlagKeys:
settingsDraftRole.permissionFlags?.map(
(permissionFlag) => permissionFlag.flag,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (!data) {
return;
}
if (isDefined(dirtyFields.permissionFlags)) {
await upsertPermissionFlags({
variables: {
upsertPermissionFlagsInput: {
roleId: data.createOneRole.id,
permissionFlagKeys:
settingsDraftRole.permissionFlags?.map(
(permissionFlag) => permissionFlag.flag,
) ?? [],
if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) {
await updateRole({
variables: {
updateRoleInput: {
id: roleId,
update: {
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canAccessAllTools: settingsDraftRole.canAccessAllTools,
canReadAllObjectRecords:
settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys,
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: data.createOneRole.id,
objectPermissions:
settingsDraftRole.objectPermissions?.map(
(objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords: objectPermission.canReadObjectRecords,
canUpdateObjectRecords:
objectPermission.canUpdateObjectRecords,
canSoftDeleteObjectRecords:
objectPermission.canSoftDeleteObjectRecords,
canDestroyObjectRecords:
objectPermission.canDestroyObjectRecords,
}),
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isNonEmptyArray(fieldPermissionsToUpsert) === true) {
await upsertFieldPermissions({
variables: {
upsertFieldPermissionsInput: {
roleId: data.createOneRole.id,
fieldPermissions:
fieldPermissionsToUpsert.map((fieldPermission) => ({
objectMetadataId: fieldPermission.objectMetadataId,
fieldMetadataId: fieldPermission.fieldMetadataId,
canReadFieldValue: fieldPermission.canReadFieldValue,
canUpdateFieldValue: fieldPermission.canUpdateFieldValue,
})) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (
isDefined(dirtyFields.workspaceMembers) &&
settingsDraftRole.canBeAssignedToUsers
) {
await addWorkspaceMembersToRole({
roleId: data.createOneRole.id,
workspaceMemberIds: settingsDraftRole.workspaceMembers.map(
(member) => member.id,
),
});
}
if (
isDefined(dirtyFields.agents) &&
settingsDraftRole.canBeAssignedToAgents
) {
await addAgentsToRole({
roleId: data.createOneRole.id,
agentIds: settingsDraftRole.agents.map((agent) => agent.id),
});
}
if (
isDefined(dirtyFields.apiKeys) &&
settingsDraftRole.canBeAssignedToApiKeys
) {
await addApiKeysToRole({
roleId: data.createOneRole.id,
apiKeyIds: settingsDraftRole.apiKeys.map((apiKey) => apiKey.id),
});
}
navigateSettings(SettingsPath.RoleDetail, {
roleId: data.createOneRole.id,
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: roleId,
objectPermissions:
settingsDraftRole.objectPermissions?.map((objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords: objectPermission.canReadObjectRecords,
canUpdateObjectRecords: objectPermission.canUpdateObjectRecords,
canSoftDeleteObjectRecords:
objectPermission.canSoftDeleteObjectRecords,
canDestroyObjectRecords:
objectPermission.canDestroyObjectRecords,
})) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isNonEmptyArray(fieldPermissionsToUpsert) === true) {
await upsertFieldPermissions({
variables: {
upsertFieldPermissionsInput: {
roleId: roleId,
fieldPermissions:
fieldPermissionsToUpsert.map((fieldPermission) => ({
objectMetadataId: fieldPermission.objectMetadataId,
fieldMetadataId: fieldPermission.fieldMetadataId,
canReadFieldValue: fieldPermission.canReadFieldValue,
canUpdateFieldValue: fieldPermission.canUpdateFieldValue,
})) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
};
const upsertRolePermissions = async (targetRoleId: string) => {
if (isDefined(dirtyFields.permissionFlags)) {
await upsertPermissionFlags({
variables: {
upsertPermissionFlagsInput: {
roleId: targetRoleId,
permissionFlagKeys:
settingsDraftRole.permissionFlags?.map(
(permissionFlag) => permissionFlag.flag,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: targetRoleId,
objectPermissions:
settingsDraftRole.objectPermissions?.map((objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords: objectPermission.canReadObjectRecords,
canUpdateObjectRecords: objectPermission.canUpdateObjectRecords,
canSoftDeleteObjectRecords:
objectPermission.canSoftDeleteObjectRecords,
canDestroyObjectRecords:
objectPermission.canDestroyObjectRecords,
})) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isNonEmptyArray(fieldPermissionsToUpsert) === true) {
await upsertFieldPermissions({
variables: {
upsertFieldPermissionsInput: {
roleId: targetRoleId,
fieldPermissions:
fieldPermissionsToUpsert.map((fieldPermission) => ({
objectMetadataId: fieldPermission.objectMetadataId,
fieldMetadataId: fieldPermission.fieldMetadataId,
canReadFieldValue: fieldPermission.canReadFieldValue,
canUpdateFieldValue: fieldPermission.canUpdateFieldValue,
})) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
};
const assignEntitiesToRole = async (targetRoleId: string) => {
if (
isDefined(dirtyFields.workspaceMembers) &&
settingsDraftRole.canBeAssignedToUsers
) {
await addWorkspaceMembersToRole({
roleId: targetRoleId,
workspaceMemberIds: settingsDraftRole.workspaceMembers.map(
(member) => member.id,
),
});
}
if (
isDefined(dirtyFields.agents) &&
settingsDraftRole.canBeAssignedToAgents
) {
await addAgentsToRole({
roleId: targetRoleId,
agentIds: settingsDraftRole.agents.map((agent) => agent.id),
});
}
if (
isDefined(dirtyFields.apiKeys) &&
settingsDraftRole.canBeAssignedToApiKeys
) {
await addApiKeysToRole({
roleId: targetRoleId,
apiKeyIds: settingsDraftRole.apiKeys.map((apiKey) => apiKey.id),
});
}
if (isDefined(onSuccess)) {
await onSuccess(roleId);
}
};
const saveDraftRoleToDB = async () => {
removeUselessFieldPermissions();
if (isCreateMode) {
await createNewRole();
} else {
if (isDefined(dirtyFields.permissionFlags)) {
await upsertPermissionFlags({
variables: {
upsertPermissionFlagsInput: {
roleId: roleId,
permissionFlagKeys:
settingsDraftRole.permissionFlags?.map(
(permissionFlag) => permissionFlag.flag,
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (ROLE_BASIC_KEYS.some((key) => key in dirtyFields)) {
await updateRole({
variables: {
updateRoleInput: {
id: roleId,
update: {
label: settingsDraftRole.label,
description: settingsDraftRole.description,
icon: settingsDraftRole.icon,
canUpdateAllSettings: settingsDraftRole.canUpdateAllSettings,
canAccessAllTools: settingsDraftRole.canAccessAllTools,
canReadAllObjectRecords:
settingsDraftRole.canReadAllObjectRecords,
canUpdateAllObjectRecords:
settingsDraftRole.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords:
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys:
settingsDraftRole.canBeAssignedToApiKeys,
},
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isDefined(dirtyFields.objectPermissions)) {
await upsertObjectPermissions({
variables: {
upsertObjectPermissionsInput: {
roleId: roleId,
objectPermissions:
settingsDraftRole.objectPermissions?.map(
(objectPermission) => ({
objectMetadataId: objectPermission.objectMetadataId,
canReadObjectRecords: objectPermission.canReadObjectRecords,
canUpdateObjectRecords:
objectPermission.canUpdateObjectRecords,
canSoftDeleteObjectRecords:
objectPermission.canSoftDeleteObjectRecords,
canDestroyObjectRecords:
objectPermission.canDestroyObjectRecords,
}),
) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
if (isNonEmptyArray(fieldPermissionsToUpsert) === true) {
await upsertFieldPermissions({
variables: {
upsertFieldPermissionsInput: {
roleId: roleId,
fieldPermissions:
fieldPermissionsToUpsert.map((fieldPermission) => ({
objectMetadataId: fieldPermission.objectMetadataId,
fieldMetadataId: fieldPermission.fieldMetadataId,
canReadFieldValue: fieldPermission.canReadFieldValue,
canUpdateFieldValue: fieldPermission.canUpdateFieldValue,
})) ?? [],
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
}
await updateExistingRole();
}
};
@@ -4,6 +4,10 @@ import { useParams } from 'react-router-dom';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsRolesQueryEffect } from '@/settings/roles/components/SettingsRolesQueryEffect';
import { useSaveDraftRoleToDB } from '@/settings/roles/role/hooks/useSaveDraftRoleToDB';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { settingsPersistedRoleFamilyState } from '@/settings/roles/states/settingsPersistedRoleFamilyState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -12,7 +16,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { t } from '@lingui/core/macro';
import { AppPath, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title, IconLock, IconSettings } from 'twenty-ui/display';
import { IconLock, IconSettings } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
type CreateAgentInput,
@@ -24,6 +28,8 @@ import { useNavigateApp } from '~/hooks/useNavigateApp';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
import { SettingsAgentDetailSkeletonLoader } from './components/SettingsAgentDetailSkeletonLoader';
import { SettingsAgentRoleTab } from './components/SettingsAgentRoleTab';
import { SettingsAgentSettingsTab } from './components/SettingsAgentSettingsTab';
@@ -107,6 +113,22 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
const agent = data?.findOneAgent;
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(formValues.role || ''),
);
const settingsPersistedRole = useRecoilValue(
settingsPersistedRoleFamilyState(formValues.role || ''),
);
const { saveDraftRoleToDB } = useSaveDraftRoleToDB({
roleId: formValues.role || '',
isCreateMode: false,
});
const isRoleDirty =
isDefined(formValues.role) &&
!isDeeplyEqual(settingsDraftRole, settingsPersistedRole);
if (!isCreateMode && !loading && !agent) {
return null;
}
@@ -138,6 +160,26 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
setIsSubmitting(true);
try {
if (isRoleDirty && isDefined(formValues.role)) {
try {
await saveDraftRoleToDB();
} catch (error) {
if (error instanceof ApolloError) {
enqueueErrorSnackBar({
apolloError: error,
});
} else {
const errorMessage =
error instanceof Error ? error.message : String(error);
enqueueErrorSnackBar({
message: t`Failed to save role permissions: ${errorMessage}`,
});
}
setIsSubmitting(false);
return;
}
}
if (isCreateMode) {
const input: CreateAgentInput = {
name: formValues.name,
@@ -187,15 +229,21 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
}
};
const handleCancel = () => {
resetForm();
if (isRoleDirty && isDefined(settingsPersistedRole)) {
setSettingsDraftRole(settingsPersistedRole);
}
navigate(SettingsPath.AI);
};
const title = !isCreateMode
? loading
? t`Agent`
: agent?.label
: t`New Agent`;
const pageTitle = !isCreateMode ? t`Edit Agent` : t`New Agent`;
const pageDescription = !isCreateMode
? t`Update agent information`
: t`Create a new AI agent`;
const breadcrumbText = !isCreateMode
? loading
? t`Agent`
@@ -210,6 +258,8 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
formValues={formValues}
onFieldChange={handleFieldChange}
disabled={isReadonlyMode || (isEditMode ? !agent?.isCustom : false)}
agentId={agentId}
agentLabel={formValues.label}
/>
);
@@ -229,13 +279,14 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
return (
<>
<SettingsRolesQueryEffect />
<SubMenuTopBarContainer
title={title}
actionButton={
isCreateMode || (isEditMode && agent?.isCustom) ? (
<SaveAndCancelButtons
onSave={handleSave}
onCancel={() => navigate(SettingsPath.AI)}
onCancel={handleCancel}
isSaveDisabled={!canSave}
isLoading={isSubmitting}
isCancelDisabled={isSubmitting}
@@ -253,7 +304,6 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
>
<SettingsPageContainer>
<Section>
<H2Title title={pageTitle} description={pageDescription} />
{isEditMode && loading ? (
<SettingsAgentDetailSkeletonLoader />
) : (
@@ -1,33 +1,29 @@
import { getOperationName } from '@apollo/client/utilities';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { v4 } from 'uuid';
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { SettingsRolePermissions } from '@/settings/roles/role-permissions/components/SettingsRolePermissions';
import { Select } from '@/ui/input/components/Select';
import { SettingsPath } from 'twenty-shared/types';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
H1Title,
H1TitleFontColor,
H2Title,
IconArrowUpRight,
IconUser,
useIcons,
} from 'twenty-ui/display';
import { H2Title, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { useGetRolesQuery } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import {
useAssignRoleToAgentMutation,
useCreateOneRoleMutation,
useGetRolesQuery,
} from '~/generated-metadata/graphql';
import { type SettingsAIAgentFormValues } from '../hooks/useSettingsAgentFormState';
const StyledRoleContainer = styled.div`
align-items: flex-end;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(8)};
`;
const StyledRoleSelector = styled.div`
flex: 1;
const StyledWarningText = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
type SettingsAgentRoleTabProps = {
@@ -37,81 +33,146 @@ type SettingsAgentRoleTabProps = {
value: SettingsAIAgentFormValues[keyof SettingsAIAgentFormValues],
) => void;
disabled: boolean;
agentId?: string;
agentLabel: string;
};
export const SettingsAgentRoleTab = ({
formValues,
onFieldChange,
disabled,
agentId,
agentLabel,
}: SettingsAgentRoleTabProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const navigateSettings = useNavigateSettings();
const [isCreatingRole, setIsCreatingRole] = useState(false);
const { data: rolesData } = useGetRolesQuery();
const rolesOptions = [
{
label: t`None`,
value: null,
Icon: IconUser,
},
...(rolesData?.getRoles
?.filter((role) => role.canBeAssignedToAgents)
.map((role) => ({
label: role.label,
value: role.id,
Icon: getIcon(role.icon) ?? IconUser,
})) || []),
];
const [createRole] = useCreateOneRoleMutation();
const [assignRoleToAgent] = useAssignRoleToAgentMutation();
const setSettingsDraftRole = useSetRecoilState(
settingsDraftRoleFamilyState(formValues.role || ''),
);
const selectedRole = rolesData?.getRoles?.find(
(role) => role.id === formValues.role,
);
const handleOpenRole = () => {
if (isDefined(selectedRole)) {
navigateSettings(SettingsPath.RoleDetail, { roleId: selectedRole.id });
const hasValidAgentId = isNonEmptyString(agentId);
const isRoleShared = selectedRole
? (selectedRole.workspaceMembers?.length || 0) +
(selectedRole.agents?.length || 0) +
(selectedRole.apiKeys?.length || 0) >
1
: false;
// Role is only editable if it's not shared and either:
// 1. Assigned exclusively to this agent (edit mode)
// 2. Not yet assigned to anyone (create mode)
const isRoleExclusiveToThisAgent =
!isRoleShared &&
selectedRole &&
(selectedRole.workspaceMembers?.length || 0) === 0 &&
(selectedRole.apiKeys?.length || 0) === 0 &&
(hasValidAgentId
? selectedRole.agents?.length === 1 &&
selectedRole.agents[0].id === agentId
: (selectedRole.agents?.length || 0) === 0);
const handleCreateRole = async () => {
setIsCreatingRole(true);
try {
const roleId = v4();
const roleName = `${agentLabel} Agent Role`;
const { data } = await createRole({
variables: {
createRoleInput: {
id: roleId,
label: roleName,
description: t`Role for ${agentLabel} agent`,
icon: 'IconLock',
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
});
if (isDefined(data?.createOneRole)) {
onFieldChange('role', data.createOneRole.id);
if (hasValidAgentId) {
await assignRoleToAgent({
variables: {
agentId,
roleId: data.createOneRole.id,
},
refetchQueries: ['GetRoles'],
});
}
setSettingsDraftRole({
...data.createOneRole,
workspaceMembers: [],
agents: [],
apiKeys: [],
objectPermissions: [],
fieldPermissions: [],
permissionFlags: [],
});
}
} finally {
setIsCreatingRole(false);
}
};
const isRoleEditable =
Boolean(selectedRole?.isEditable) &&
!disabled &&
Boolean(isRoleExclusiveToThisAgent);
return (
<Section>
<H2Title
title={t`Role`}
description={t`The agent can perform all actions defined by the following role.`}
/>
<StyledRoleContainer>
<StyledRoleSelector>
<Select
dropdownId="agent-role-select"
options={rolesOptions}
value={formValues.role || ''}
onChange={(value) => onFieldChange('role', value)}
disabled={disabled}
withSearchInput
fullWidth
/>
</StyledRoleSelector>
<Button
Icon={IconArrowUpRight}
title={t`Open`}
variant="secondary"
onClick={handleOpenRole}
disabled={!selectedRole}
/>
</StyledRoleContainer>
{selectedRole?.id && (
{!formValues.role ? (
<>
<H1Title
title={t`Role Permissions`}
fontColor={H1TitleFontColor.Primary}
<H2Title
title={t`Role`}
description={t`Create a role to define permissions for this agent.`}
/>
<SettingsRolePermissions
roleId={selectedRole.id}
isEditable={false}
<Button
Icon={IconPlus}
title={t`Create Role`}
variant="secondary"
onClick={handleCreateRole}
disabled={disabled || isCreatingRole}
/>
</>
) : (
<>
{selectedRole?.id && (
<>
{isRoleShared && (
<StyledWarningText>
{t`This role is shared with other users or agents and cannot be edited here.`}
</StyledWarningText>
)}
<SettingsRolePermissions
roleId={selectedRole.id}
isEditable={isRoleEditable}
fromAgentId={hasValidAgentId ? agentId : undefined}
/>
</>
)}
</>
)}
</Section>
);
@@ -1,16 +1,13 @@
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { getSettingsPath } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Section } from 'twenty-ui/layout';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import styled from '@emotion/styled';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
const APPLICATIONS_ID = 'applications';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
import { useFindManyApplicationsQuery } from '~/generated-metadata/graphql';
import { SettingsApplicationsTable } from '~/pages/settings/applications/components/SettingsApplicationsTable';
const StyledNoApplicationContainer = styled.div``;
@@ -19,8 +16,6 @@ export const SettingsApplications = () => {
const applications = data?.findManyApplications ?? [];
const tabs = [{ id: 'inUsed', title: 'In used' }];
return (
<SubMenuTopBarContainer
title={t`Applications`}
@@ -35,14 +30,7 @@ export const SettingsApplications = () => {
<SettingsPageContainer>
<Section>
{applications.length > 0 ? (
<>
<TabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={APPLICATIONS_ID}
/>
<SettingsApplicationsTable applications={applications} />
</>
<SettingsApplicationsTable applications={applications} />
) : (
<StyledNoApplicationContainer>
No installed application. Please check our{' '}
@@ -5,19 +5,22 @@ import {
} from '@/settings/data-model/object-details/components/SettingsObjectFieldItemTableRow';
import { settingsObjectFieldsFamilyState } from '@/settings/data-model/object-details/states/settingsObjectFieldsFamilyState';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableSection } from '@/ui/layout/table/components/TableSection';
import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { type TableMetadata } from '@/ui/layout/table/types/TableMetadata';
import styled from '@emotion/styled';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyArray } from '@sniptt/guards';
import { useEffect, useMemo, useState } from 'react';
import { useRecoilState } from 'recoil';
import { IconSearch } from 'twenty-ui/display';
import { IconArchive, IconFilter, IconSearch } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { useMapFieldMetadataItemToSettingsObjectDetailTableItem } from '~/pages/settings/data-model/hooks/useMapFieldMetadataItemToSettingsObjectDetailTableItem';
import { type SettingsObjectDetailTableItem } from '~/pages/settings/data-model/types/SettingsObjectDetailTableItem';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
@@ -80,10 +83,17 @@ const GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM: TableMetadata<SettingsOb
},
};
const StyledSearchInput = styled(SettingsTextInput)`
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
export type SettingsObjectFieldTableProps = {
objectMetadataItem: ObjectMetadataItem;
mode: 'view' | 'new-field';
@@ -96,6 +106,7 @@ export const SettingsObjectFieldTable = ({
}: SettingsObjectFieldTableProps) => {
const { t } = useLingui();
const [searchTerm, setSearchTerm] = useState('');
const [showInactive, setShowInactive] = useState(mode === 'new-field');
const tableMetadata = objectMetadataItem.isCustom
? GET_SETTINGS_OBJECT_DETAIL_TABLE_METADATA_CUSTOM
@@ -114,14 +125,13 @@ export const SettingsObjectFieldTable = ({
setSettingsObjectFields(objectMetadataItem.fields);
}, [objectMetadataItem, setSettingsObjectFields]);
const activeObjectSettingsDetailItems = useMemo(() => {
const activeMetadataFields = settingsObjectFields?.filter(
(fieldMetadataItem) =>
fieldMetadataItem.isActive && !fieldMetadataItem.isSystem,
const allObjectSettingsDetailItems = useMemo(() => {
const nonSystemFields = settingsObjectFields?.filter(
(fieldMetadataItem) => !fieldMetadataItem.isSystem,
);
return (
activeMetadataFields?.map(
nonSystemFields?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
@@ -130,61 +140,64 @@ export const SettingsObjectFieldTable = ({
mapFieldMetadataItemToSettingsObjectDetailTableItem,
]);
const disabledObjectSettingsDetailItems = useMemo(() => {
const disabledFieldMetadataItems = settingsObjectFields?.filter(
(fieldMetadataItem) =>
!fieldMetadataItem.isActive && !fieldMetadataItem.isSystem,
);
return (
disabledFieldMetadataItems?.map(
mapFieldMetadataItemToSettingsObjectDetailTableItem,
) ?? []
);
}, [
settingsObjectFields,
mapFieldMetadataItemToSettingsObjectDetailTableItem,
]);
const sortedActiveObjectSettingsDetailItems = useSortedArray(
activeObjectSettingsDetailItems,
const sortedAllObjectSettingsDetailItems = useSortedArray(
allObjectSettingsDetailItems,
tableMetadata,
);
const sortedDisabledObjectSettingsDetailItems = useSortedArray(
disabledObjectSettingsDetailItems,
tableMetadata,
);
const filteredActiveItems = useMemo(() => {
const filteredItems = useMemo(() => {
const searchNormalized = normalizeSearchText(searchTerm);
return sortedActiveObjectSettingsDetailItems.filter((item) => {
return (
normalizeSearchText(item.label).includes(searchNormalized) ||
normalizeSearchText(item.dataType).includes(searchNormalized)
);
});
}, [sortedActiveObjectSettingsDetailItems, searchTerm]);
const filteredDisabledItems = useMemo(() => {
const searchNormalized = normalizeSearchText(searchTerm);
return sortedDisabledObjectSettingsDetailItems.filter((item) => {
return (
return sortedAllObjectSettingsDetailItems.filter((item) => {
const matchesActiveFilter =
showInactive || item.fieldMetadataItem.isActive;
const matchesSearch =
normalizeSearchText(item.label).includes(searchNormalized) ||
normalizeSearchText(item.dataType).includes(searchNormalized)
);
normalizeSearchText(item.dataType).includes(searchNormalized);
return matchesActiveFilter && matchesSearch;
});
}, [sortedDisabledObjectSettingsDetailItems, searchTerm]);
}, [sortedAllObjectSettingsDetailItems, searchTerm, showInactive]);
return (
<>
<StyledSearchInput
instanceId="object-field-table-search"
LeftIcon={IconSearch}
placeholder={t`Search a field...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="object-field-table-search"
LeftIcon={IconSearch}
placeholder={t`Search a field...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-fields-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconArchive}
onToggleChange={() => setShowInactive(!showInactive)}
toggled={showInactive}
text={t`Inactive`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>
<Table>
<StyledObjectFieldTableRow>
{tableMetadata.fields.map((item) => (
@@ -198,33 +211,20 @@ export const SettingsObjectFieldTable = ({
))}
<TableHeader></TableHeader>
</StyledObjectFieldTableRow>
{isNonEmptyArray(filteredActiveItems) && (
<TableSection title={t`Active`}>
{filteredActiveItems.map((objectSettingsDetailItem) => (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status="active"
mode={mode}
/>
))}
</TableSection>
)}
{isNonEmptyArray(filteredDisabledItems) && (
<TableSection
isInitiallyExpanded={mode === 'new-field' ? true : false}
title={t`Inactive`}
>
{filteredDisabledItems.map((objectSettingsDetailItem) => (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status="disabled"
mode={mode}
/>
))}
</TableSection>
)}
{filteredItems.map((objectSettingsDetailItem) => {
const status = objectSettingsDetailItem.fieldMetadataItem.isActive
? 'active'
: 'disabled';
return (
<SettingsObjectFieldItemTableRow
key={objectSettingsDetailItem.fieldMetadataItem.id}
settingsObjectDetailTableItem={objectSettingsDetailItem}
status={status}
mode={mode}
/>
);
})}
</Table>
</>
);
@@ -4,27 +4,54 @@ import { SettingsRolePermissionsObjectLevelObjectPicker } from '@/settings/roles
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { Navigate, useParams } from 'react-router-dom';
import { Navigate, useParams, useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { useFindOneAgentQuery } from '~/generated-metadata/graphql';
export const SettingsRoleAddObjectLevel = () => {
const { roleId } = useParams();
const [searchParams] = useSearchParams();
const fromAgentId = searchParams.get('fromAgent');
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId ?? ''),
);
const { data: agentData } = useFindOneAgentQuery({
variables: { id: fromAgentId || '' },
skip: !fromAgentId,
});
const agent = agentData?.findOneAgent;
if (!roleId) {
return <Navigate to={getSettingsPath(SettingsPath.Roles)} />;
}
return (
<>
<SettingsRolesQueryEffect />
<SubMenuTopBarContainer
title={t`1. Select an object`}
links={[
const breadcrumbLinks =
fromAgentId && isDefined(agent)
? [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`AI`,
href: getSettingsPath(SettingsPath.AI),
},
{
children: agent.label,
href: getSettingsPath(SettingsPath.AIAgentDetail, {
agentId: agent.id,
}),
},
{
children: t`Add object permission`,
},
]
: [
{ children: t`Roles`, href: getSettingsPath(SettingsPath.Roles) },
{
children: settingsDraftRole.label ?? '',
@@ -34,7 +61,14 @@ export const SettingsRoleAddObjectLevel = () => {
children: t`Add object permission`,
href: getSettingsPath(SettingsPath.RoleAddObjectLevel, { roleId }),
},
]}
];
return (
<>
<SettingsRolesQueryEffect />
<SubMenuTopBarContainer
title={t`1. Select an object`}
links={breadcrumbLinks}
>
<SettingsPageContainer>
<SettingsRolePermissionsObjectLevelObjectPicker roleId={roleId} />