Feat: role applicability controls (#14239)

Closes [#1404](https://github.com/twentyhq/core-team-issues/issues/1404)

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-09-04 08:58:50 +05:30
committed by GitHub
parent 052f91cff1
commit 79bcd90d8d
72 changed files with 1889 additions and 344 deletions
File diff suppressed because one or more lines are too long
@@ -141,6 +141,14 @@ export type ApiKey = {
workspaceId: Scalars['UUID'];
};
export type ApiKeyForRole = {
__typename?: 'ApiKeyForRole';
expiresAt: Scalars['DateTime'];
id: Scalars['UUID'];
name: Scalars['String'];
revokedAt?: Maybe<Scalars['DateTime']>;
};
export type ApiKeyToken = {
__typename?: 'ApiKeyToken';
token: Scalars['String'];
@@ -675,6 +683,9 @@ export type CreatePageLayoutInput = {
export type CreateRoleInput = {
canAccessAllTools?: InputMaybe<Scalars['Boolean']>;
canBeAssignedToAgents?: InputMaybe<Scalars['Boolean']>;
canBeAssignedToApiKeys?: InputMaybe<Scalars['Boolean']>;
canBeAssignedToUsers?: InputMaybe<Scalars['Boolean']>;
canDestroyAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canReadAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canSoftDeleteAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
@@ -2788,7 +2799,12 @@ export type RevokeApiKeyDto = {
export type Role = {
__typename?: 'Role';
agents: Array<Agent>;
apiKeys: Array<ApiKeyForRole>;
canAccessAllTools: Scalars['Boolean'];
canBeAssignedToAgents: Scalars['Boolean'];
canBeAssignedToApiKeys: Scalars['Boolean'];
canBeAssignedToUsers: Scalars['Boolean'];
canDestroyAllObjectRecords: Scalars['Boolean'];
canReadAllObjectRecords: Scalars['Boolean'];
canSoftDeleteAllObjectRecords: Scalars['Boolean'];
@@ -3212,6 +3228,9 @@ export type UpdateRoleInput = {
export type UpdateRolePayload = {
canAccessAllTools?: InputMaybe<Scalars['Boolean']>;
canBeAssignedToAgents?: InputMaybe<Scalars['Boolean']>;
canBeAssignedToApiKeys?: InputMaybe<Scalars['Boolean']>;
canBeAssignedToUsers?: InputMaybe<Scalars['Boolean']>;
canDestroyAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canReadAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
canSoftDeleteAllObjectRecords?: InputMaybe<Scalars['Boolean']>;
@@ -43,7 +43,57 @@ const StyledMessageText = styled.div<{ isUser?: boolean }>`
isUser ? theme.font.color.light : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
white-space: pre-line;
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
code {
overflow: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 100%;
line-height: 1.4;
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.sm};
background: ${({ theme }) => theme.background.tertiary};
}
pre {
background: ${({ theme }) => theme.background.tertiary};
padding: ${({ theme }) => theme.spacing(2)};
border-radius: ${({ theme }) => theme.border.radius.sm};
overflow-x: auto;
max-width: 100%;
code {
padding: 0;
border-radius: 0;
background: none;
}
}
p {
margin: ${({ theme }) => theme.spacing(1)} 0;
line-height: 1.5;
}
ul,
ol {
margin: ${({ theme }) => theme.spacing(1)} 0;
padding-left: ${({ theme }) => theme.spacing(4)};
}
li {
margin: ${({ theme }) => theme.spacing(0.5)} 0;
}
blockquote {
border-left: 3px solid ${({ theme }) => theme.border.color.medium};
margin: ${({ theme }) => theme.spacing(2)} 0;
padding-left: ${({ theme }) => theme.spacing(2)};
color: ${({ theme }) => theme.font.color.secondary};
}
`;
const StyledMessageFooter = styled.div`
@@ -118,6 +118,8 @@ export const AIChatTab = ({
placeholder={t`Enter a question...`}
value={input}
onChange={handleInputChange}
minRows={1}
maxRows={20}
/>
<StyledButtonsContainer>
{!isWorkflowAgentNodeChat && (
@@ -25,7 +25,7 @@ export type CurrentWorkspace = Pick<
| 'metadataVersion'
| 'isTwoFactorAuthenticationEnforced'
> & {
defaultRole?: Omit<Role, 'workspaceMembers'> | null;
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
defaultAgent?: { id: string } | null;
};
@@ -1,7 +1,7 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { Select } from '@/ui/input/components/Select';
import { useRecoilValue } from 'recoil';
import { useIcons } from 'twenty-ui/display';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { type Role } from '~/generated-metadata/graphql';
type SettingsDevelopersRoleSelectorProps = {
@@ -40,11 +40,20 @@ export const SettingsDevelopersRoleSelector = ({
return null;
}
const options = roles.map((role) => ({
label: role.label,
value: role.id,
Icon: getIcon(role.icon) ?? undefined,
}));
const options = roles.reduce<
Array<{ label: string; value: string; Icon?: IconComponent }>
>((acc, role) => {
{
if (role.canBeAssignedToApiKeys) {
acc.push({
label: role.label,
value: role.id,
Icon: getIcon(role.icon) ?? undefined,
});
}
return acc;
}
}, []);
const selectValue =
value || (doesDefaultRoleExistInRoles ? defaultRole?.id : roles[0].id);
@@ -6,9 +6,12 @@ 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 } from 'twenty-ui/display';
@@ -16,6 +19,10 @@ 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);
@@ -36,7 +43,9 @@ export const SettingsRolesContainer = () => {
>
<SettingsPageContainer>
<SettingsRolesList />
<SettingsRoleDefaultRole roles={settingsAllRoles} />
{activeTabId === ROLES_LIST_TABS.TABS_IDS.USER_ROLES && (
<SettingsRoleDefaultRole roles={settingsAllRoles} />
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -4,13 +4,25 @@ 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 { SettingsPath } from '@/types/SettingsPath';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { H2Title, IconPlus } from 'twenty-ui/display';
import {
H2Title,
IconKey,
IconPlus,
IconRobot,
IconUser,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { FeatureFlagKey } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { sortByAscString } from '~/utils/array/sortByAscString';
@@ -33,26 +45,74 @@ const StyledNoRoles = styled(TableCell)`
export const SettingsRolesList = () => {
const navigateSettings = useNavigateSettings();
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
ROLES_LIST_TABS.COMPONENT_INSTANCE_ID,
);
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const sortedSettingsAllRoles = [...settingsAllRoles].sort((a, b) =>
sortByAscString(a.label, b.label),
);
const filteredRoles = sortedSettingsAllRoles.filter((role) => {
switch (activeTabId) {
case ROLES_LIST_TABS.TABS_IDS.USER_ROLES:
return role.canBeAssignedToUsers;
case ROLES_LIST_TABS.TABS_IDS.AGENT_ROLES:
return role.canBeAssignedToAgents;
case ROLES_LIST_TABS.TABS_IDS.API_KEY_ROLES:
return role.canBeAssignedToApiKeys;
default:
return role.canBeAssignedToUsers;
}
});
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,
},
];
const description = isAiEnabled
? t`Manage roles and permissions for team members, agents, and API keys`
: t`Manage roles and permissions for team members and API keys`;
return (
<Section>
<H2Title
title={t`All roles`}
description={t`Assign roles to manage each members access and permissions`}
<H2Title title={t`All roles`} description={description} />
<TabList
tabs={tabs}
className="tab-list"
componentInstanceId={ROLES_LIST_TABS.COMPONENT_INSTANCE_ID}
/>
<Table>
<SettingsRolesTableHeader />
<StyledTableRows>
{sortedSettingsAllRoles.length === 0 ? (
{filteredRoles.length === 0 ? (
<StyledNoRoles>{t`No roles found`}</StyledNoRoles>
) : (
sortedSettingsAllRoles.map((role) => (
filteredRoles.map((role) => (
<SettingsRolesTableRow key={role.id} role={role} />
))
)}
@@ -0,0 +1,8 @@
export const ROLES_LIST_TABS = {
COMPONENT_INSTANCE_ID: 'settings-roles-list-tabs',
TABS_IDS: {
USER_ROLES: 'user-roles',
AGENT_ROLES: 'agent-roles',
API_KEY_ROLES: 'api-key-roles',
},
} as const;
@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const API_KEY_FOR_ROLE_FRAGMENT = gql`
fragment ApiKeyForRoleFragment on ApiKeyForRole {
id
name
expiresAt
revokedAt
}
`;
@@ -13,5 +13,8 @@ export const ROLE_FRAGMENT = gql`
canUpdateAllObjectRecords
canSoftDeleteAllObjectRecords
canDestroyAllObjectRecords
canBeAssignedToUsers
canBeAssignedToAgents
canBeAssignedToApiKeys
}
`;
@@ -1,3 +1,5 @@
import { AGENT_FRAGMENT } from '@/ai/graphql/fragments/agentFragment';
import { API_KEY_FOR_ROLE_FRAGMENT } from '@/settings/roles/graphql/fragments/apiKeyForRoleFragment';
import { FIELD_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/fieldPermissionFragment';
import { OBJECT_PERMISSION_FRAGMENT } from '@/settings/roles/graphql/fragments/objectPermissionFragment';
import { PERMISSION_FLAG_FRAGMENT } from '@/settings/roles/graphql/fragments/permissionFlagFragment';
@@ -8,6 +10,8 @@ import { gql } from '@apollo/client';
export const GET_ROLES = gql`
${WORKSPACE_MEMBER_QUERY_FRAGMENT}
${ROLE_FRAGMENT}
${AGENT_FRAGMENT}
${API_KEY_FOR_ROLE_FRAGMENT}
${PERMISSION_FLAG_FRAGMENT}
${OBJECT_PERMISSION_FRAGMENT}
${FIELD_PERMISSION_FRAGMENT}
@@ -17,6 +21,12 @@ export const GET_ROLES = gql`
workspaceMembers {
...WorkspaceMemberQueryFragment
}
agents {
...AgentFields
}
apiKeys {
...ApiKeyForRoleFragment
}
permissionFlags {
...PermissionFlagFragment
}
@@ -0,0 +1,63 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useRecoilState } from 'recoil';
import {
useAssignRoleToAgentMutation,
type Agent,
} from '~/generated-metadata/graphql';
export const useUpdateAgentRole = (roleId: string) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const [assignRoleToAgentMutation] = useAssignRoleToAgentMutation();
const updateAgentRoleDraftState = ({ agent }: { agent: Agent }) => {
setSettingsDraftRole({
...settingsDraftRole,
agents: [...settingsDraftRole.agents, agent],
});
};
const addAgentToRoleAndUpdateState = async ({
agentId,
}: {
agentId: string;
}) => {
const { data } = await assignRoleToAgentMutation({
variables: {
agentId,
roleId,
},
awaitRefetchQueries: true,
refetchQueries: ['GetRoles'],
});
return data?.assignRoleToAgent;
};
const addAgentsToRole = async ({
roleId,
agentIds,
}: {
roleId: string;
agentIds: string[];
}) => {
await Promise.all(
agentIds.map((agentId) =>
assignRoleToAgentMutation({
variables: {
roleId,
agentId,
},
}),
),
);
};
return {
addAgentToRoleAndUpdateState,
updateAgentRoleDraftState,
addAgentsToRole,
};
};
@@ -0,0 +1,65 @@
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { useRecoilState } from 'recoil';
import { useAssignRoleToApiKeyMutation } from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
export const useUpdateApiKeyRole = (roleId: string) => {
const [settingsDraftRole, setSettingsDraftRole] = useRecoilState(
settingsDraftRoleFamilyState(roleId),
);
const [assignRoleToApiKeyMutation] = useAssignRoleToApiKeyMutation();
const updateApiKeyRoleDraftState = ({
apiKey,
}: {
apiKey: ApiKeyForRole;
}) => {
setSettingsDraftRole({
...settingsDraftRole,
apiKeys: [...settingsDraftRole.apiKeys, apiKey],
});
};
const addApiKeyToRoleAndUpdateState = async ({
apiKeyId,
}: {
apiKeyId: string;
}) => {
const { data } = await assignRoleToApiKeyMutation({
variables: {
apiKeyId,
roleId,
},
awaitRefetchQueries: true,
refetchQueries: ['GetRoles'],
});
return data?.assignRoleToApiKey;
};
const addApiKeysToRole = async ({
roleId,
apiKeyIds,
}: {
roleId: string;
apiKeyIds: string[];
}) => {
await Promise.all(
apiKeyIds.map((apiKeyId) =>
assignRoleToApiKeyMutation({
variables: {
roleId,
apiKeyId,
},
}),
),
);
};
return {
addApiKeyToRoleAndUpdateState,
updateApiKeyRoleDraftState,
addApiKeysToRole,
};
};
@@ -0,0 +1,113 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsRoleAssignmentEntityPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown';
import { SettingsRoleAssignmentTable } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTable';
import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import styled from '@emotion/styled';
import { AppTooltip, IconPlus, TooltipDelay } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { type Agent, type Role } from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
import { ROLE_TARGET_CONFIG } from '../constants/RoleTargetConfig';
const StyledAssignToMemberContainer = styled.div`
display: flex;
justify-content: flex-end;
padding-block: ${({ theme }) => theme.spacing(2)};
`;
type RoleAssignmentSectionProps = {
roleTargetType: keyof typeof ROLE_TARGET_CONFIG;
roleId: string;
settingsDraftRole: Role;
currentWorkspaceMember?: CurrentWorkspaceMember;
onSelect: (
roleTarget: CurrentWorkspaceMember | Agent | ApiKeyForRole,
roleTargetType: keyof typeof ROLE_TARGET_CONFIG,
) => void;
allWorkspaceMembersHaveThisRole?: boolean;
};
export const RoleAssignmentSection = ({
roleTargetType,
roleId,
settingsDraftRole,
currentWorkspaceMember,
onSelect,
allWorkspaceMembersHaveThisRole,
}: RoleAssignmentSectionProps) => {
const config = ROLE_TARGET_CONFIG[roleTargetType];
const { closeDropdown } = useCloseDropdown();
if (!config.canBeAssigned(settingsDraftRole)) {
return null;
}
const assignedIds = config.getAssignedIds(settingsDraftRole);
const excludedIds = config.getExcludedIds(
assignedIds,
currentWorkspaceMember?.id,
);
return (
<Section>
<SettingsRoleAssignmentTable
roleId={roleId}
roleTargetType={roleTargetType}
/>
<StyledAssignToMemberContainer>
<Dropdown
dropdownId={config.dropdownId}
dropdownOffset={{ x: 0, y: 4 }}
clickableComponent={
<>
<div id={config.tooltip?.anchorId}>
<Button
Icon={IconPlus}
title={config.buttonTitle()}
variant="secondary"
size="small"
disabled={allWorkspaceMembersHaveThisRole}
/>
</div>
{config.tooltip && (
<AppTooltip
anchorSelect={`#${config.tooltip.anchorId}`}
content={config.tooltip.content()}
delay={TooltipDelay.noDelay}
hidden={
!config.tooltip.shouldShow(allWorkspaceMembersHaveThisRole)
}
/>
)}
</>
}
dropdownComponents={
roleTargetType === 'member' ? (
<SettingsRoleAssignmentWorkspaceMemberPickerDropdown
excludedWorkspaceMemberIds={excludedIds}
onSelect={(roleTarget: CurrentWorkspaceMember) => {
closeDropdown(config.dropdownId);
onSelect(roleTarget, roleTargetType);
}}
/>
) : (
<SettingsRoleAssignmentEntityPickerDropdown
entityType={roleTargetType}
excludedIds={excludedIds}
onSelect={(
roleTarget: CurrentWorkspaceMember | Agent | ApiKeyForRole,
) => {
closeDropdown(config.dropdownId);
onSelect(roleTarget, roleTargetType);
}}
/>
)
}
/>
</StyledAssignToMemberContainer>
</Section>
);
};
@@ -1,72 +1,33 @@
import {
type CurrentWorkspaceMember,
currentWorkspaceMemberState,
type CurrentWorkspaceMember,
} from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { useUpdateAgentRole } from '@/settings/roles/hooks/useUpdateAgentRole';
import { useUpdateApiKeyRole } from '@/settings/roles/hooks/useUpdateApiKeyRole';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
import { RoleAssignmentSection } from '@/settings/roles/role-assignment/components/RoleAssignmentSection';
import { SettingsRoleAssignmentConfirmationModal } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModal';
import { SettingsRoleAssignmentTableHeader } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTableHeader';
import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown';
import { type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget';
import { settingsAllRolesSelector } from '@/settings/roles/states/settingsAllRolesSelector';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsPath } from '@/types/SettingsPath';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import {
AppTooltip,
H2Title,
IconPlus,
IconSearch,
TooltipDelay,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { type Role, type WorkspaceMember } from '~/generated-metadata/graphql';
useFindManyAgentsQuery,
useGetApiKeysQuery,
type Agent,
} from '~/generated-metadata/graphql';
import { FeatureFlagKey, type ApiKeyForRole } from '~/generated/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID } from '../constants/RoleAssignmentConfirmationModalId';
import { SettingsRoleAssignmentTableRow } from './SettingsRoleAssignmentTableRow';
const StyledAssignToMemberContainer = styled.div`
display: flex;
justify-content: flex-end;
padding-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
input {
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
`;
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableRows = styled.div`
gap: ${({ theme }) => theme.spacing(0.5)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledNoMembers = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
import { ROLE_TARGET_CONFIG } from '../constants/RoleTargetConfig';
import { buildRoleMaps } from '../utils/build-role-maps';
type SettingsRoleAssignmentProps = {
roleId: string;
@@ -77,6 +38,8 @@ export const SettingsRoleAssignment = ({
roleId,
isCreateMode,
}: SettingsRoleAssignmentProps) => {
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
@@ -87,49 +50,29 @@ export const SettingsRoleAssignment = ({
updateWorkspaceMemberRoleDraftState,
} = useUpdateWorkspaceMemberRole(roleId);
const { addAgentToRoleAndUpdateState, updateAgentRoleDraftState } =
useUpdateAgentRole(roleId);
const { addApiKeyToRoleAndUpdateState, updateApiKeyRoleDraftState } =
useUpdateApiKeyRole(roleId);
const { data: agentsData } = useFindManyAgentsQuery();
const { data: apiKeysData } = useGetApiKeysQuery();
const { openModal, closeModal } = useModal();
const [selectedWorkspaceMember, setSelectedWorkspaceMember] =
useState<SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember | null>(
const [selectedRoleTarget, setSelectRoleTarget] =
useState<SettingsRoleAssignmentConfirmationModalSelectedRoleTarget | null>(
null,
);
const dropdownId = 'role-member-select';
const { closeDropdown } = useCloseDropdown();
const [searchFilter, setSearchFilter] = useState('');
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const settingsAllRoles = useRecoilValue(settingsAllRolesSelector);
const workspaceMemberRoleMap = new Map<
string,
{ id: string; label: string }
>();
settingsAllRoles.forEach((role: Role) => {
role.workspaceMembers.forEach((member: WorkspaceMember) => {
workspaceMemberRoleMap.set(member.id, { id: role.id, label: role.label });
});
});
const roleMaps = buildRoleMaps(settingsAllRoles);
const filteredWorkspaceMembers = !searchFilter
? settingsDraftRole.workspaceMembers
: settingsDraftRole.workspaceMembers.filter((member) => {
const searchTerm = searchFilter.toLowerCase();
const firstName = member.name.firstName?.toLowerCase() || '';
const lastName = member.name.lastName?.toLowerCase() || '';
const email = member.userEmail?.toLowerCase() || '';
return (
firstName.includes(searchTerm) ||
lastName.includes(searchTerm) ||
email.includes(searchTerm)
);
});
const assignedWorkspaceMemberIds = settingsDraftRole.workspaceMembers.map(
(workspaceMember) => workspaceMember.id,
);
const assignedWorkspaceMemberIds =
ROLE_TARGET_CONFIG.member.getAssignedIds(settingsDraftRole);
const assignableWorkspaceMembers = currentWorkspaceMembers.filter(
(member) => member.id !== currentWorkspaceMember?.id,
@@ -140,21 +83,7 @@ export const SettingsRoleAssignment = ({
);
const handleModalClose = () => {
setSelectedWorkspaceMember(null);
};
const handleSelectWorkspaceMember = (
workspaceMember: CurrentWorkspaceMember,
) => {
const existingRole = workspaceMemberRoleMap.get(workspaceMember.id);
setSelectedWorkspaceMember({
id: workspaceMember.id,
name: `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`,
role: existingRole,
});
openModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID);
closeDropdown(dropdownId);
setSelectRoleTarget(null);
};
const isModalOpened = useRecoilComponentValue(
@@ -163,124 +92,141 @@ export const SettingsRoleAssignment = ({
);
const handleConfirm = async () => {
if (!selectedWorkspaceMember || !isModalOpened) return;
if (!selectedRoleTarget || !isModalOpened) return;
if (!isCreateMode) {
await addWorkspaceMemberToRoleAndUpdateState({
workspaceMemberId: selectedWorkspaceMember.id,
});
} else {
const workspaceMember = currentWorkspaceMembers.find(
(member) => member.id === selectedWorkspaceMember.id,
);
if (!workspaceMember) {
throw new Error('Workspace member not found');
switch (selectedRoleTarget.entityType) {
case 'member':
await addWorkspaceMemberToRoleAndUpdateState({
workspaceMemberId: selectedRoleTarget.id,
});
break;
case 'agent':
await addAgentToRoleAndUpdateState({
agentId: selectedRoleTarget.id,
});
break;
case 'apiKey':
await addApiKeyToRoleAndUpdateState({
apiKeyId: selectedRoleTarget.id,
});
break;
}
} else {
switch (selectedRoleTarget.entityType) {
case 'member': {
const member = currentWorkspaceMembers.find(
(member) => member.id === selectedRoleTarget.id,
);
updateWorkspaceMemberRoleDraftState({
workspaceMember: {
id: workspaceMember.id,
name: workspaceMember.name,
colorScheme: '',
userEmail: '',
},
});
if (!member) {
throw new Error('Workspace member not found');
}
updateWorkspaceMemberRoleDraftState({
workspaceMember: {
id: member.id,
name: member.name,
colorScheme: '',
userEmail: '',
},
});
break;
}
case 'agent': {
const agent = agentsData?.findManyAgents.find(
(agent) => agent.id === selectedRoleTarget.id,
);
if (!agent) {
throw new Error('Agent not found');
}
updateAgentRoleDraftState({
agent,
});
break;
}
case 'apiKey': {
const apiKeyData = apiKeysData?.apiKeys.find(
(apiKey) => apiKey.id === selectedRoleTarget.id,
);
if (!apiKeyData) {
throw new Error('API key not found');
}
updateApiKeyRoleDraftState({
apiKey: apiKeyData as ApiKeyForRole,
});
break;
}
}
}
handleModalClose();
};
const handleSelectEntity = (
entity: CurrentWorkspaceMember | Agent | ApiKeyForRole,
entityType: keyof typeof ROLE_TARGET_CONFIG,
) => {
const config = ROLE_TARGET_CONFIG[entityType];
const existingRole = config.getRoleMap(roleMaps).get(entity.id);
const name = config.getName(entity as never);
setSelectRoleTarget({
id: entity.id,
name,
role: existingRole,
entityType: entityType as 'member' | 'agent' | 'apiKey',
});
openModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID);
};
const handleRoleClick = (roleId: string) => {
navigateSettings(SettingsPath.RoleDetail, { roleId });
handleModalClose();
closeModal(ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID);
};
const handleSearchChange = (text: string) => {
setSearchFilter(text);
};
if (!settingsDraftRole) {
return null;
}
return (
<>
<Section>
<H2Title
title={t`Assigned members`}
description={t`This role is assigned to these workspace members.`}
/>
<StyledSearchContainer>
<StyledSearchInput
instanceId="role-assignment-member-search"
value={searchFilter}
onChange={handleSearchChange}
placeholder={t`Search an assigned team member...`}
fullWidth
LeftIcon={IconSearch}
sizeVariant="lg"
/>
</StyledSearchContainer>
<StyledTable>
<SettingsRoleAssignmentTableHeader />
<StyledTableRows>
{filteredWorkspaceMembers.length > 0 ? (
filteredWorkspaceMembers.map((workspaceMember) => (
<SettingsRoleAssignmentTableRow
key={workspaceMember.id}
workspaceMember={workspaceMember}
/>
))
) : (
<StyledNoMembers>
{!searchFilter
? t`No members assigned`
: t`No members match your search`}
</StyledNoMembers>
)}
</StyledTableRows>
</StyledTable>
{Object.keys(ROLE_TARGET_CONFIG).map(
(roleTargetType) =>
(isAiEnabled || roleTargetType !== 'agent') && (
<RoleAssignmentSection
key={roleTargetType}
roleTargetType={roleTargetType as keyof typeof ROLE_TARGET_CONFIG}
roleId={roleId}
settingsDraftRole={settingsDraftRole}
currentWorkspaceMember={
roleTargetType === 'member'
? currentWorkspaceMember || undefined
: undefined
}
onSelect={handleSelectEntity}
allWorkspaceMembersHaveThisRole={
roleTargetType === 'member'
? allWorkspaceMembersHaveThisRole
: false
}
/>
),
)}
<StyledAssignToMemberContainer>
<Dropdown
dropdownId="role-member-select"
dropdownOffset={{ x: 0, y: 4 }}
clickableComponent={
<>
<div id="assign-member">
<Button
Icon={IconPlus}
title={t`Assign to member`}
variant="secondary"
size="small"
disabled={allWorkspaceMembersHaveThisRole}
/>
</div>
<AppTooltip
anchorSelect="#assign-member"
content={t`The workspace needs at least one Admin`}
delay={TooltipDelay.noDelay}
hidden={!allWorkspaceMembersHaveThisRole}
/>
</>
}
dropdownComponents={
<SettingsRoleAssignmentWorkspaceMemberPickerDropdown
excludedWorkspaceMemberIds={[
...assignedWorkspaceMemberIds,
currentWorkspaceMember?.id,
]}
onSelect={handleSelectWorkspaceMember}
/>
}
/>
</StyledAssignToMemberContainer>
</Section>
{selectedWorkspaceMember && (
{selectedRoleTarget && (
<SettingsRoleAssignmentConfirmationModal
selectedWorkspaceMember={selectedWorkspaceMember}
selectedRoleTarget={selectedRoleTarget}
onClose={handleModalClose}
onConfirm={handleConfirm}
onRoleClick={handleRoleClick}
newRoleName={settingsDraftRole.label}
/>
)}
</>
@@ -1,35 +1,41 @@
import { SettingsRoleAssignmentConfirmationModalSubtitle } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentConfirmationModalSubtitle';
import { ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-assignment/constants/RoleAssignmentConfirmationModalId';
import { type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { t } from '@lingui/core/macro';
type SettingsRoleAssignmentConfirmationModalProps = {
selectedWorkspaceMember: SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember;
selectedRoleTarget: SettingsRoleAssignmentConfirmationModalSelectedRoleTarget;
onClose: () => void;
onConfirm: () => void;
onRoleClick: (roleId: string) => void;
newRoleName: string;
};
export const SettingsRoleAssignmentConfirmationModal = ({
selectedWorkspaceMember,
selectedRoleTarget,
onClose,
onConfirm,
onRoleClick,
newRoleName,
}: SettingsRoleAssignmentConfirmationModalProps) => {
const workspaceMemberName = selectedWorkspaceMember.name;
const roleTargetName = selectedRoleTarget.name;
const title = t`Assign ${workspaceMemberName}?`;
const title = t`Assign ${roleTargetName}?`;
return (
<ConfirmationModal
modalId={ROLE_ASSIGNMENT_CONFIRMATION_MODAL_ID}
title={title}
subtitle={
<SettingsRoleAssignmentConfirmationModalSubtitle
selectedWorkspaceMember={selectedWorkspaceMember}
onRoleClick={onRoleClick}
/>
selectedRoleTarget.role ? (
<SettingsRoleAssignmentConfirmationModalSubtitle
selectedRoleTarget={selectedRoleTarget}
onRoleClick={onRoleClick}
/>
) : (
t`${roleTargetName} will be assigned to the "${newRoleName}" role.`
)
}
onClose={onClose}
onConfirmClick={onConfirm}
@@ -1,6 +1,7 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember';
import { type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget } from '@/settings/roles/role-assignment/types/SettingsRoleAssignmentConfirmationModalSelectedRoleTarget';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
@@ -11,28 +12,30 @@ const StyledSettingsCardContainer = styled.div`
`;
type SettingsRoleAssignmentConfirmationModalSubtitleProps = {
selectedWorkspaceMember: SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember;
selectedRoleTarget: SettingsRoleAssignmentConfirmationModalSelectedRoleTarget;
onRoleClick: (roleId: string) => void;
};
export const SettingsRoleAssignmentConfirmationModalSubtitle = ({
selectedWorkspaceMember,
selectedRoleTarget,
onRoleClick,
}: SettingsRoleAssignmentConfirmationModalSubtitleProps) => {
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const enrichedSelectedWorkspaceMember = currentWorkspaceMembers.find(
(member) => member.id === selectedWorkspaceMember.id,
(member) => member.id === selectedRoleTarget.id,
);
const workspaceMemberName = `${enrichedSelectedWorkspaceMember?.name.firstName} ${enrichedSelectedWorkspaceMember?.name.lastName}`;
const workspaceMemberName = enrichedSelectedWorkspaceMember
? `${enrichedSelectedWorkspaceMember?.name.firstName} ${enrichedSelectedWorkspaceMember?.name.lastName}`
: selectedRoleTarget.name;
return (
<>
{t`${workspaceMemberName} will be unassigned from the following role:`}
<StyledSettingsCardContainer>
<SettingsCard
title={selectedWorkspaceMember.role?.label || ''}
title={selectedRoleTarget.role?.label || ''}
Icon={
<Avatar
avatarUrl={enrichedSelectedWorkspaceMember?.avatarUrl}
@@ -43,8 +46,7 @@ export const SettingsRoleAssignmentConfirmationModalSubtitle = ({
/>
}
onClick={() =>
selectedWorkspaceMember.role &&
onRoleClick(selectedWorkspaceMember.role.id)
selectedRoleTarget.role && onRoleClick(selectedRoleTarget.role.id)
}
/>
</StyledSettingsCardContainer>
@@ -0,0 +1,132 @@
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import {
type Agent,
useFindManyAgentsQuery,
useGetApiKeysQuery,
} from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
const StyledLoadingContainer = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
text-align: center;
`;
const StyledDropdownItem = styled.div`
padding: ${({ theme }) => theme.spacing(2)};
cursor: pointer;
&:hover {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledItemName = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledEmptyState = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(2)};
text-align: center;
`;
type EntityData = Agent | ApiKeyForRole;
type SettingsRoleAssignmentEntityPickerDropdownProps = {
entityType: 'agent' | 'apiKey';
excludedIds: string[];
onSelect: (entity: EntityData) => void;
};
export const SettingsRoleAssignmentEntityPickerDropdown = ({
entityType,
excludedIds,
onSelect,
}: SettingsRoleAssignmentEntityPickerDropdownProps) => {
const [searchFilter, setSearchFilter] = useState('');
const { t } = useLingui();
const isAgent = entityType === 'agent';
const { data: agentsData, loading: agentsLoading } = useFindManyAgentsQuery({
skip: !isAgent,
});
const { data: apiKeysData, loading: apiKeysLoading } = useGetApiKeysQuery({
skip: isAgent,
});
const loading = isAgent ? agentsLoading : apiKeysLoading;
const entities = ((isAgent
? agentsData?.findManyAgents.filter((agent) => agent.isCustom)
: apiKeysData?.apiKeys) || []) as EntityData[];
const placeholder = isAgent ? t`Search agents` : t`Search API keys`;
const getEmptyStateMessage = () => {
if (searchFilter !== '') {
return isAgent
? t`No agents match your search`
: t`No API keys match your search`;
} else {
return isAgent ? t`No agents available` : t`No API keys available`;
}
};
const filteredEntities = entities.filter((entity) => {
const isExcluded = excludedIds.includes(entity.id);
if (isExcluded) {
return false;
}
if (isAgent) {
const agent = entity as Agent;
return (
agent.name.toLowerCase().includes(searchFilter.toLowerCase()) ||
agent.label.toLowerCase().includes(searchFilter.toLowerCase())
);
} else {
return (entity as ApiKeyForRole).name
.toLowerCase()
.includes(searchFilter.toLowerCase());
}
});
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.Medium}>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.target.value)}
placeholder={placeholder}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{loading ? (
<StyledLoadingContainer>{t`Loading...`}</StyledLoadingContainer>
) : filteredEntities.length > 0 ? (
filteredEntities.map((entity) => (
<StyledDropdownItem
key={entity.id}
onClick={() => onSelect(entity as EntityData)}
>
<StyledItemName>
{isAgent ? (entity as Agent).label : entity.name}
</StyledItemName>
</StyledDropdownItem>
))
) : (
<StyledEmptyState>{getEmptyStateMessage()}</StyledEmptyState>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,176 @@
import {
SettingsRoleAssignmentTableRow,
type RoleTarget,
} from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentTableRow';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { H2Title, IconSearch } from 'twenty-ui/display';
import { type Agent, type WorkspaceMember } from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
const StyledTable = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledTableRows = styled.div`
gap: ${({ theme }) => theme.spacing(0.5)};
padding-block: ${({ theme }) => theme.spacing(2)};
`;
const StyledEmptyState = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledSearchContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
input {
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
}
`;
type RoleTargetType = 'member' | 'agent' | 'apiKey';
type SettingsRoleAssignmentTableProps<T extends RoleTargetType> = {
roleTargetType: T;
roleId: string;
};
export const SettingsRoleAssignmentTable = <T extends RoleTargetType>({
roleTargetType,
roleId,
}: SettingsRoleAssignmentTableProps<T>) => {
const settingsDraftRole = useRecoilValue(
settingsDraftRoleFamilyState(roleId),
);
const [searchFilter, setSearchFilter] = useState('');
const handleSearchChange = (text: string) => {
setSearchFilter(text);
};
const tableConfig = {
member: {
columns: [t`Name`, t`Email`],
displayName: t`Members`,
emptyStateText: t`No members assigned`,
roleTargets: settingsDraftRole.workspaceMembers,
},
agent: {
columns: [t`Name`, t`Description`],
displayName: t`Agents`,
emptyStateText: t`No agents assigned`,
roleTargets: settingsDraftRole.agents,
},
apiKey: {
columns: [t`Name`, t`Expires`],
displayName: t`API keys`,
emptyStateText: t`No API keys assigned`,
roleTargets: settingsDraftRole.apiKeys,
},
};
const roleTargetDisplayName = tableConfig[roleTargetType].displayName;
const roleTargets = tableConfig[roleTargetType].roleTargets;
const getSearchableFields = (
roleTarget: WorkspaceMember | Agent | ApiKeyForRole,
): string[] => {
switch (roleTargetType) {
case 'member': {
const member = roleTarget as WorkspaceMember;
return [
member.name.firstName?.toLowerCase() || '',
member.name.lastName?.toLowerCase() || '',
member.userEmail?.toLowerCase() || '',
];
}
case 'agent': {
const agent = roleTarget as Agent;
return [
agent.name?.toLowerCase() || '',
agent.label?.toLowerCase() || '',
agent.description?.toLowerCase() || '',
];
}
case 'apiKey': {
const apiKey = roleTarget as ApiKeyForRole;
return [apiKey.name?.toLowerCase() || ''];
}
}
};
const filteredRoleTargets = !searchFilter
? roleTargets
: roleTargets.filter((roleTarget) => {
const searchTerm = searchFilter.toLowerCase();
const searchableFields = getSearchableFields(roleTarget);
return searchableFields.some((field) => field.includes(searchTerm));
});
const createRoleTarget = (
roleTarget: WorkspaceMember | Agent | ApiKeyForRole,
): RoleTarget => {
switch (roleTargetType) {
case 'member':
return { type: roleTargetType, data: roleTarget as WorkspaceMember };
case 'agent':
return { type: roleTargetType, data: roleTarget as Agent };
case 'apiKey':
return { type: roleTargetType, data: roleTarget as ApiKeyForRole };
}
};
return (
<>
<H2Title
title={t`Assigned ${roleTargetDisplayName}`}
description={t`This role is assigned to these ${roleTargetDisplayName}.`}
/>
<StyledSearchContainer>
<StyledSearchInput
instanceId={`role-assignment-${roleTargetType}-search`}
value={searchFilter}
onChange={handleSearchChange}
placeholder={t`Search an assigned ${roleTargetDisplayName}...`}
fullWidth
LeftIcon={IconSearch}
sizeVariant="lg"
/>
</StyledSearchContainer>
<StyledTable>
<TableRow gridAutoColumns="2fr 4fr">
{tableConfig[roleTargetType].columns.map((column) => (
<TableHeader key={column}>{column}</TableHeader>
))}
</TableRow>
<StyledTableRows>
{filteredRoleTargets.map((roleTarget) => (
<SettingsRoleAssignmentTableRow
key={roleTarget.id}
roleTarget={createRoleTarget(roleTarget)}
/>
))}
{filteredRoleTargets.length === 0 && (
<StyledEmptyState>
{tableConfig[roleTargetType].emptyStateText}
</StyledEmptyState>
)}
</StyledTableRows>
</StyledTable>
</>
);
};
@@ -1,16 +1,26 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersStates';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { UserContext } from '@/users/contexts/UserContext';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { type WorkspaceMember } from '~/generated-metadata/graphql';
import {
Avatar,
IconKey,
OverflowingTextWithTooltip,
useIcons,
} from 'twenty-ui/display';
import { type Agent, type WorkspaceMember } from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { formatDateString } from '~/utils/string/formatDateString';
const StyledIconWrapper = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledNameCell = styled.div`
@@ -23,6 +33,7 @@ const StyledNameContainer = styled.div`
align-items: center;
display: flex;
overflow: hidden;
gap: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
@@ -30,40 +41,91 @@ const StyledTableCell = styled(TableCell)`
overflow: hidden;
`;
export type RoleTarget =
| { type: 'member'; data: WorkspaceMember }
| { type: 'agent'; data: Agent }
| { type: 'apiKey'; data: ApiKeyForRole };
type SettingsRoleAssignmentTableRowProps = {
workspaceMember: WorkspaceMember;
roleTarget: RoleTarget;
};
export const SettingsRoleAssignmentTableRow = ({
workspaceMember,
roleTarget,
}: SettingsRoleAssignmentTableRowProps) => {
const theme = useTheme();
const currentWorkspaceMembers = useRecoilValue(currentWorkspaceMembersState);
const enrichedWorkspaceMember = currentWorkspaceMembers.find(
(member) => member.id === workspaceMember.id,
);
const { getIcon } = useIcons();
const { dateFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const renderIcon = () => {
switch (roleTarget.type) {
case 'member': {
const enrichedWorkspaceMember = currentWorkspaceMembers.find(
(member) => member.id === roleTarget.data.id,
);
return (
<Avatar
avatarUrl={enrichedWorkspaceMember?.avatarUrl}
placeholderColorSeed={enrichedWorkspaceMember?.id}
placeholder={enrichedWorkspaceMember?.name.firstName ?? ''}
type="rounded"
size="md"
/>
);
}
case 'agent': {
const Icon = getIcon(roleTarget.data.icon || 'IconRobot');
return <Icon size={theme.icon.size.md} />;
}
case 'apiKey': {
return <IconKey size={theme.icon.size.md} />;
}
}
};
const renderName = () => {
switch (roleTarget.type) {
case 'member':
return `${roleTarget.data.name.firstName} ${roleTarget.data.name.lastName}`;
case 'agent':
return roleTarget.data.label;
case 'apiKey':
return roleTarget.data.name;
}
};
const renderSecondaryInfo = () => {
switch (roleTarget.type) {
case 'member':
return roleTarget.data.userEmail;
case 'agent':
return roleTarget.data.description;
case 'apiKey':
return roleTarget.data.expiresAt
? formatDateString({
value: roleTarget.data.expiresAt,
timeZone,
dateFormat,
localeCatalog: dateLocale.localeCatalog,
})
: 'Never expires';
}
};
return (
<TableRow gridAutoColumns="2fr 4fr">
<StyledTableCell>
<StyledNameContainer>
<StyledIconWrapper>
<Avatar
avatarUrl={enrichedWorkspaceMember?.avatarUrl}
placeholderColorSeed={enrichedWorkspaceMember?.id}
placeholder={enrichedWorkspaceMember?.name.firstName ?? ''}
type="rounded"
size="md"
/>
</StyledIconWrapper>
<StyledIconWrapper>{renderIcon()}</StyledIconWrapper>
<StyledNameCell>
<OverflowingTextWithTooltip
text={`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`}
/>
<OverflowingTextWithTooltip text={renderName()} />
</StyledNameCell>
</StyledNameContainer>
</StyledTableCell>
<StyledTableCell>
<OverflowingTextWithTooltip text={workspaceMember.userEmail} />
<OverflowingTextWithTooltip text={renderSecondaryInfo()} />
</StyledTableCell>
</TableRow>
);
@@ -0,0 +1,67 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsRoleAssignmentEntityPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentEntityPickerDropdown';
import { SettingsRoleAssignmentWorkspaceMemberPickerDropdown } from '@/settings/roles/role-assignment/components/SettingsRoleAssignmentWorkspaceMemberPickerDropdown';
import { t } from '@lingui/core/macro';
import {
type Agent,
type Role,
type WorkspaceMember,
} from '~/generated-metadata/graphql';
import { type ApiKeyForRole } from '~/generated/graphql';
import { type RoleMaps } from '../types/role-maps';
export const ROLE_TARGET_CONFIG = {
member: {
dropdownId: 'role-member-select',
getRoleMap: (maps: RoleMaps) => maps.member,
getName: (entity: CurrentWorkspaceMember) =>
`${entity.name.firstName} ${entity.name.lastName}`,
getAssignedIds: (settingsDraftRole: Role) =>
settingsDraftRole.workspaceMembers.map(
(member: WorkspaceMember) => member.id,
),
getExcludedIds: (assignedIds: string[], currentMemberId?: string) =>
currentMemberId ? [...assignedIds, currentMemberId] : assignedIds,
canBeAssigned: (settingsDraftRole: Role) =>
settingsDraftRole.canBeAssignedToUsers,
buttonTitle: () => t`Assign to member`,
dropdownComponent: SettingsRoleAssignmentWorkspaceMemberPickerDropdown,
// Tooltip configuration for member type
tooltip: {
anchorId: 'assign-member',
content: () => t`All workspace members already have this role`,
shouldShow: (allWorkspaceMembersHaveThisRole?: boolean) =>
allWorkspaceMembersHaveThisRole ?? false,
},
},
agent: {
dropdownId: 'role-agent-select',
getRoleMap: (maps: RoleMaps) => maps.agent,
getName: (entity: Agent) => entity.label,
getAssignedIds: (settingsDraftRole: Role) =>
(settingsDraftRole.agents || []).map((agent: Agent) => agent.id),
getExcludedIds: (assignedIds: string[]) => assignedIds,
canBeAssigned: (settingsDraftRole: Role) =>
settingsDraftRole.canBeAssignedToAgents,
buttonTitle: () => t`Assign to agent`,
dropdownComponent: SettingsRoleAssignmentEntityPickerDropdown,
// No tooltip for agent type
tooltip: null,
},
apiKey: {
dropdownId: 'role-api-key-select',
getRoleMap: (maps: RoleMaps) => maps.apiKey,
getName: (entity: ApiKeyForRole) => entity.name,
getAssignedIds: (settingsDraftRole: Role) =>
(settingsDraftRole.apiKeys || []).map(
(apiKey: ApiKeyForRole) => apiKey.id,
),
getExcludedIds: (assignedIds: string[]) => assignedIds,
canBeAssigned: (settingsDraftRole: Role) =>
settingsDraftRole.canBeAssignedToApiKeys,
buttonTitle: () => t`Assign to API key`,
dropdownComponent: SettingsRoleAssignmentEntityPickerDropdown,
// No tooltip for apiKey type
tooltip: null,
},
} as const;
@@ -1,7 +1,8 @@
export type SettingsRoleAssignmentConfirmationModalSelectedWorkspaceMember = {
export type SettingsRoleAssignmentConfirmationModalSelectedRoleTarget = {
id: string;
name: string;
role?: { id: string; label: string };
colorScheme?: string;
userEmail?: string;
entityType: 'member' | 'agent' | 'apiKey';
};
@@ -0,0 +1,5 @@
export type RoleMaps = {
member: Map<string, { id: string; label: string }>;
agent: Map<string, { id: string; label: string }>;
apiKey: Map<string, { id: string; label: string }>;
};
@@ -0,0 +1,24 @@
import { type Role } from '~/generated-metadata/graphql';
import { type RoleMaps } from '../types/role-maps';
export const buildRoleMaps = (roles: Role[]): RoleMaps => {
const maps: RoleMaps = {
member: new Map(),
agent: new Map(),
apiKey: new Map(),
};
roles.forEach((role) => {
role.workspaceMembers.forEach((member) => {
maps.member.set(member.id, { id: role.id, label: role.label });
});
role.agents?.forEach((agent) => {
maps.agent.set(agent.id, { id: role.id, label: role.label });
});
role.apiKeys?.forEach((apiKey) => {
maps.apiKey.set(apiKey.id, { id: role.id, label: role.label });
});
});
return maps;
};
@@ -17,9 +17,14 @@ const BASE_ROLE_MOCK: Role = {
canSoftDeleteAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canUpdateAllSettings: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
id: '1',
isEditable: true,
label: 'Role base',
agents: [],
apiKeys: [],
workspaceMembers: [],
fieldPermissions: [BASE_FIELD_PERMISSION],
};
@@ -8,9 +8,14 @@ const BASE_ROLE_MOCK: Role = {
canSoftDeleteAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canUpdateAllSettings: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
id: '1',
isEditable: true,
label: 'Role base',
agents: [],
apiKeys: [],
workspaceMembers: [],
};
@@ -0,0 +1,110 @@
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { H2Title, IconKey, IconRobot, IconUsers } from 'twenty-ui/display';
import { Checkbox } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { FeatureFlagKey } from '~/generated/graphql';
const StyledCheckboxContainer = styled.div<{ disabled: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.sm};
transition: background-color
${({ theme }) => theme.animation.duration.normal}s ease;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
&:hover {
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
const StyledCheckboxLabel = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
`;
type SettingsRoleApplicabilityValues = {
canBeAssignedToUsers: boolean;
canBeAssignedToAgents: boolean;
canBeAssignedToApiKeys: boolean;
};
type SettingsRoleApplicabilityProps = {
values: SettingsRoleApplicabilityValues;
onApplicabilityChange: (
key: keyof SettingsRoleApplicabilityValues,
value: boolean,
) => void;
isEditable: boolean;
};
export const SettingsRoleApplicability = ({
values,
onApplicabilityChange,
isEditable,
}: SettingsRoleApplicabilityProps) => {
const theme = useTheme();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const options = [
{
key: 'canBeAssignedToUsers' as const,
label: t`Assignable to team members`,
Icon: IconUsers,
},
{
key: 'canBeAssignedToAgents' as const,
label: t`Assignable to Agents`,
Icon: IconRobot,
},
...(isAiEnabled
? [
{
key: 'canBeAssignedToApiKeys' as const,
label: t`Assignable to API Keys`,
Icon: IconKey,
},
]
: []),
];
return (
<Section>
<H2Title
title={t`Applicability`}
description={t`Control which types of entities this role can be assigned to`}
/>
<div>
{options.map((option) => (
<StyledCheckboxContainer
disabled={!isEditable}
key={option.key}
onClick={() =>
isEditable &&
onApplicabilityChange(option.key, !values[option.key])
}
>
<StyledCheckboxLabel>
<option.Icon size={theme.icon.size.sm} />
<span>{option.label}</span>
</StyledCheckboxLabel>
<Checkbox
checked={values[option.key]}
onChange={(event) => {
event.stopPropagation();
onApplicabilityChange(option.key, event.target.checked);
}}
disabled={!isEditable}
/>
</StyledCheckboxContainer>
))}
</div>
</Section>
);
};
@@ -2,6 +2,7 @@ import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import { SettingsRoleApplicability } from '@/settings/roles/role-settings/components/SettingsRoleApplicability';
import { SettingsRoleSettingsDeleteRoleConfirmationModal } from '@/settings/roles/role-settings/components/SettingsRoleSettingsDeleteRoleConfirmationModal';
import { ROLE_SETTINGS_DELETE_ROLE_CONFIRMATION_MODAL_ID } from '@/settings/roles/role-settings/components/constants/RoleSettingsDeleteRoleConfirmationModalId';
import { settingsDraftRoleFamilyState } from '@/settings/roles/states/settingsDraftRoleFamilyState';
@@ -91,6 +92,21 @@ export const SettingsRoleSettings = ({
/>
</Section>
<SettingsRoleApplicability
values={{
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys,
}}
onApplicabilityChange={(key, value) => {
setSettingsDraftRole({
...settingsDraftRole,
[key]: value,
});
}}
isEditable={isEditable}
/>
{!isCreateMode && (
<>
<Section>
@@ -47,8 +47,13 @@ export const SettingsRoleCreateEffect = ({
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
isEditable: true,
workspaceMembers: [],
apiKeys: [],
agents: [],
};
setSettingsPersistedRole(undefined);
@@ -1,4 +1,6 @@
import { GET_ROLES } from '@/settings/roles/graphql/queries/getRolesQuery';
import { useUpdateAgentRole } from '@/settings/roles/hooks/useUpdateAgentRole';
import { useUpdateApiKeyRole } from '@/settings/roles/hooks/useUpdateApiKeyRole';
import { useUpdateWorkspaceMemberRole } from '@/settings/roles/hooks/useUpdateWorkspaceMemberRole';
import { useRemoveFieldPermissionInDraftRole } from '@/settings/roles/role-permissions/object-level-permissions/field-permissions/hooks/useRemoveFieldPermissionInDraftRole';
import { newFieldPermissionsFilter } from '@/settings/roles/role/hooks/utils/newFieldPermissionsFilter.util';
@@ -30,6 +32,9 @@ const ROLE_BASIC_KEYS: Array<keyof Role> = [
'canUpdateAllObjectRecords',
'canSoftDeleteAllObjectRecords',
'canDestroyAllObjectRecords',
'canBeAssignedToUsers',
'canBeAssignedToAgents',
'canBeAssignedToApiKeys',
];
export const useSaveDraftRoleToDB = ({
@@ -45,6 +50,8 @@ export const useSaveDraftRoleToDB = ({
const [upsertObjectPermissions] = useUpsertObjectPermissionsMutation();
const [upsertFieldPermissions] = useUpsertFieldPermissionsMutation();
const { addWorkspaceMembersToRole } = useUpdateWorkspaceMemberRole(roleId);
const { addAgentsToRole } = useUpdateAgentRole(roleId);
const { addApiKeysToRole } = useUpdateApiKeyRole(roleId);
const navigateSettings = useNavigateSettings();
const settingsPersistedRole = useRecoilValue(
@@ -125,6 +132,9 @@ export const useSaveDraftRoleToDB = ({
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys: settingsDraftRole.canBeAssignedToApiKeys,
} satisfies Partial<Role>,
},
refetchQueries: [getOperationName(GET_ROLES) ?? ''],
@@ -191,7 +201,10 @@ export const useSaveDraftRoleToDB = ({
});
}
if (isDefined(dirtyFields.workspaceMembers)) {
if (
isDefined(dirtyFields.workspaceMembers) &&
settingsDraftRole.canBeAssignedToUsers
) {
await addWorkspaceMembersToRole({
roleId: data.createOneRole.id,
workspaceMemberIds: settingsDraftRole.workspaceMembers.map(
@@ -200,6 +213,26 @@ export const useSaveDraftRoleToDB = ({
});
}
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,
});
@@ -238,6 +271,10 @@ export const useSaveDraftRoleToDB = ({
settingsDraftRole.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords:
settingsDraftRole.canDestroyAllObjectRecords,
canBeAssignedToUsers: settingsDraftRole.canBeAssignedToUsers,
canBeAssignedToAgents: settingsDraftRole.canBeAssignedToAgents,
canBeAssignedToApiKeys:
settingsDraftRole.canBeAssignedToApiKeys,
},
},
},
@@ -19,5 +19,10 @@ export const settingsDraftRoleFamilyState = createFamilyState<Role, string>({
permissionFlags: [],
objectPermissions: [],
fieldPermissions: [],
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: false,
canBeAssignedToUsers: false,
agents: [],
apiKeys: [],
},
});
@@ -175,7 +175,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
<SubMenuTopBarContainer
title={title}
actionButton={
!isEditMode || !isAskAIAgent ? (
!isEditMode || agent?.isCustom ? (
<SaveAndCancelButtons
onSave={handleSave}
onCancel={() => navigate(SettingsPath.AI)}
@@ -210,6 +210,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
<SettingsAIAgentForm
formValues={formValues}
onFieldChange={handleFieldChange}
disabled={isEditMode ? !agent?.isCustom : false}
/>
{isEditMode && agent && formValues.isCustom && (
<Section>
@@ -39,11 +39,13 @@ type SettingsAIAgentFormProps = {
field: keyof SettingsAIAgentFormValues,
value: SettingsAIAgentFormValues[keyof SettingsAIAgentFormValues],
) => void;
disabled: boolean;
};
export const SettingsAIAgentForm = ({
formValues,
onFieldChange,
disabled,
}: SettingsAIAgentFormProps) => {
const { t } = useLingui();
@@ -51,10 +53,12 @@ export const SettingsAIAgentForm = ({
const { data: rolesData } = useGetRolesQuery();
const rolesOptions =
rolesData?.getRoles?.map((role) => ({
label: role.label,
value: role.id,
})) || [];
rolesData?.getRoles
?.filter((role) => role.canBeAssignedToAgents)
.map((role) => ({
label: role.label,
value: role.id,
})) || [];
const noModelsAvailable = modelOptions.length === 0;
@@ -72,6 +76,7 @@ export const SettingsAIAgentForm = ({
onChange={({ iconKey }) => {
onFieldChange('icon', iconKey);
}}
disabled={disabled}
/>
<StyledNameContainer>
@@ -84,6 +89,7 @@ export const SettingsAIAgentForm = ({
fillNameFromLabel(value);
}}
fullWidth
disabled={disabled}
/>
</StyledNameContainer>
</StyledIconNameRow>
@@ -96,6 +102,7 @@ export const SettingsAIAgentForm = ({
minRows={3}
value={formValues.description || ''}
onChange={(value) => onFieldChange('description', value)}
disabled={disabled}
/>
</StyledFormContainer>
@@ -106,7 +113,7 @@ export const SettingsAIAgentForm = ({
value={formValues.modelId}
onChange={(value) => onFieldChange('modelId', value)}
options={modelOptions}
disabled={noModelsAvailable}
disabled={noModelsAvailable || disabled}
/>
{noModelsAvailable && (
<StyledErrorMessage>
@@ -126,6 +133,7 @@ export const SettingsAIAgentForm = ({
label: t`Select a role`,
value: '',
}}
disabled={disabled}
/>
</StyledFormContainer>
@@ -137,6 +145,7 @@ export const SettingsAIAgentForm = ({
minRows={6}
value={formValues.prompt}
onChange={(value) => onFieldChange('prompt', value)}
disabled={disabled}
/>
</StyledFormContainer>
</StyledFormContainer>
@@ -39,7 +39,11 @@ export const SettingsDevelopersApiKeysNew = () => {
const defaultRole = data.getRoles.find(
(role) => role.id === currentWorkspace?.defaultRole?.id,
);
if (isDefined(defaultRole) && !formValues.roleId) {
if (
isDefined(defaultRole) &&
defaultRole.canBeAssignedToApiKeys &&
!formValues.roleId
) {
setFormValues((prev) => ({
...prev,
roleId: defaultRole.id,
@@ -15,6 +15,11 @@ const rolesMock: Role[] = [
canUpdateAllSettings: true,
canAccessAllTools: true,
isEditable: false,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
agents: [],
apiKeys: [],
workspaceMembers: [mockWorkspaceMembers[0]],
permissionFlags: [
{
@@ -48,6 +53,11 @@ const rolesMock: Role[] = [
canUpdateAllSettings: false,
canAccessAllTools: false,
isEditable: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
agents: [],
apiKeys: [],
workspaceMembers: [mockWorkspaceMembers[1]],
permissionFlags: [],
objectPermissions: [
@@ -59,9 +59,14 @@ export const BASE_ROLE_MOCK_GRANTS_ALL: Role = {
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canUpdateAllSettings: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
id: MOCK_ROLE_ID_GRANTS_ALL,
isEditable: true,
label: 'Role grants all',
agents: [],
apiKeys: [],
workspaceMembers: [],
fieldPermissions: [
{ ...BASE_FIELD_PERMISSION, roleId: MOCK_ROLE_ID_GRANTS_ALL },
@@ -84,9 +89,14 @@ export const BASE_ROLE_MOCK_REVOKES_ALL: Role = {
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: true,
id: MOCK_ROLE_ID_REVOKES_ALL,
isEditable: true,
label: 'Role revokes all',
agents: [],
apiKeys: [],
workspaceMembers: [],
fieldPermissions: [
{ ...BASE_FIELD_PERMISSION, roleId: MOCK_ROLE_ID_REVOKES_ALL },
@@ -0,0 +1,35 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddRoleApplicabilityFields1756434280823
implements MigrationInterface
{
name = 'AddRoleApplicabilityFields1756434280823';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."role" ADD "canBeAssignedToUsers" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "core"."role" ADD "canBeAssignedToAgents" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`ALTER TABLE "core"."role" ADD "canBeAssignedToApiKeys" boolean NOT NULL DEFAULT true`,
);
await queryRunner.query(
`UPDATE "core"."role" SET "canBeAssignedToUsers" = true, "canBeAssignedToAgents" = true, "canBeAssignedToApiKeys" = true`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."role" DROP COLUMN "canBeAssignedToApiKeys"`,
);
await queryRunner.query(
`ALTER TABLE "core"."role" DROP COLUMN "canBeAssignedToAgents"`,
);
await queryRunner.query(
`ALTER TABLE "core"."role" DROP COLUMN "canBeAssignedToUsers"`,
);
}
}
@@ -55,6 +55,9 @@ describe('ApiKeyRoleService', () => {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
};
const mockNewRole: Partial<RoleEntity> = {
@@ -427,6 +430,10 @@ describe('ApiKeyRoleService', () => {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToAgents: false,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
standardId: undefined,
});
});
@@ -1,7 +1,14 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, type EntityManager, In, Repository } from 'typeorm';
import {
DataSource,
type EntityManager,
In,
IsNull,
Not,
Repository,
} from 'typeorm';
import { ApiKey } from 'src/engine/core-modules/api-key/api-key.entity';
import {
@@ -145,6 +152,13 @@ export class ApiKeyRoleService {
);
}
if (!role.canBeAssignedToApiKeys) {
throw new ApiKeyException(
`Role "${role.label}" cannot be assigned to API keys`,
ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS,
);
}
const existingRoleTarget = await this.roleTargetsRepository.findOne({
where: {
apiKeyId,
@@ -190,4 +204,35 @@ export class ApiKeyRoleService {
return rolesMap;
}
public async getApiKeysAssignedToRole(
roleId: string,
workspaceId: string,
): Promise<ApiKey[]> {
const roleTargets = await this.roleTargetsRepository.find({
where: {
roleId,
workspaceId,
apiKeyId: Not(IsNull()),
},
});
const apiKeyIds = roleTargets
.map((roleTarget) => roleTarget.apiKeyId)
.filter((apiKeyId): apiKeyId is string => apiKeyId !== null);
if (!apiKeyIds.length) {
return [];
}
const apiKeys = await this.apiKeyRepository.find({
where: {
id: In(apiKeyIds),
workspaceId,
revokedAt: IsNull(),
},
});
return apiKeys;
}
}
@@ -7,4 +7,5 @@ export enum ApiKeyExceptionCode {
API_KEY_REVOKED = 'API_KEY_REVOKED',
API_KEY_EXPIRED = 'API_KEY_EXPIRED',
API_KEY_NO_ROLE_ASSIGNED = 'API_KEY_NO_ROLE_ASSIGNED',
ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS = 'ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS',
}
@@ -27,6 +27,10 @@ export const apiKeyGraphqlApiExceptionHandler = (error: Error) => {
throw new ForbiddenError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
case ApiKeyExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
@@ -87,6 +87,9 @@ describe('AgentRoleService', () => {
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToAgents: true,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -102,6 +105,9 @@ describe('AgentRoleService', () => {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToAgents: true,
canBeAssignedToUsers: true,
canBeAssignedToApiKeys: true,
workspaceId: testWorkspaceId,
createdAt: new Date(),
updatedAt: new Date(),
@@ -288,7 +294,7 @@ describe('AgentRoleService', () => {
roleId: nonExistentRoleId,
}),
).rejects.toMatchObject({
code: AgentExceptionCode.AGENT_EXECUTION_FAILED,
code: AgentExceptionCode.ROLE_NOT_FOUND,
message: `Role with id ${nonExistentRoleId} not found in workspace`,
});
});
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
@@ -54,6 +54,33 @@ export class AgentRoleService {
});
}
public async assignStandardRoleToAgent({
workspaceId,
agentId,
standardRoleId,
}: {
workspaceId: string;
agentId: string;
standardRoleId: string;
}) {
const role = await this.roleRepository.findOne({
where: { standardId: standardRoleId, workspaceId },
});
if (!role) {
throw new AgentException(
`Standard role with standard ID ${standardRoleId} not found in workspace`,
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
await this.assignRoleToAgent({
workspaceId,
agentId,
roleId: role.id,
});
}
public async removeRoleFromAgent({
workspaceId,
agentId,
@@ -67,6 +94,36 @@ export class AgentRoleService {
});
}
public async getAgentsAssignedToRole(
roleId: string,
workspaceId: string,
): Promise<AgentEntity[]> {
const roleTargets = await this.roleTargetsRepository.find({
where: {
roleId,
workspaceId,
agentId: Not(IsNull()),
},
});
const agentIds = roleTargets
.map((roleTarget) => roleTarget.agentId)
.filter((agentId): agentId is string => agentId !== null);
if (!agentIds.length) {
return [];
}
const agents = await this.agentRepository.find({
where: {
id: In(agentIds),
workspaceId,
},
});
return agents;
}
private async validateAssignRoleInput({
agentId,
workspaceId,
@@ -94,7 +151,14 @@ export class AgentRoleService {
if (!role) {
throw new AgentException(
`Role with id ${roleId} not found in workspace`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
if (!role.canBeAssignedToAgents) {
throw new AgentException(
`Role "${role.label}" cannot be assigned to agents`,
AgentExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS,
);
}
@@ -49,6 +49,7 @@ export class AgentToolGeneratorService {
id: roleId,
workspaceId,
},
relations: ['permissionFlags'],
});
if (!role) {
@@ -9,4 +9,5 @@ export enum AgentExceptionCode {
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
HANDOFF_ALREADY_EXISTS = 'HANDOFF_ALREADY_EXISTS',
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
}
@@ -38,10 +38,7 @@ export class AgentResolver {
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) {
return this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
return this.agentService.findManyAgents(workspaceId);
}
@Query(() => AgentDTO)
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { In, Repository } from 'typeorm';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/agent/dtos/create-agent.input';
@@ -23,6 +23,37 @@ export class AgentService {
private readonly agentRoleService: AgentRoleService,
) {}
async findManyAgents(workspaceId: string) {
const agents = await this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
if (agents.length === 0) {
return [];
}
const roleTargets = await this.roleTargetsRepository.find({
where: {
workspaceId,
agentId: In(agents.map((agent) => agent.id)),
},
});
const agentRoleMap = new Map<string, string>();
roleTargets.forEach((roleTarget) => {
if (roleTarget.agentId) {
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
}
});
return agents.map((agent) => ({
...agent,
roleId: agentRoleMap.get(agent.id) || null,
}));
}
async findOneAgent(id: string, workspaceId: string) {
const agent = await this.agentRepository.findOne({
where: { id, workspaceId },
@@ -15,6 +15,9 @@ export const fromRoleEntityToFlatRole = (role: RoleEntity): FlatRole => {
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
canBeAssignedToUsers: role.canBeAssignedToUsers,
canBeAssignedToAgents: role.canBeAssignedToAgents,
canBeAssignedToApiKeys: role.canBeAssignedToApiKeys,
workspaceId: role.workspaceId,
universalIdentifier: role.standardId || role.id,
};
@@ -49,6 +49,8 @@ export enum PermissionsExceptionCode {
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED',
JOIN_COLUMN_NAME_REQUIRED = 'JOIN_COLUMN_NAME_REQUIRED',
COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND',
ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET',
ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'ROLE_CANNOT_BE_ASSIGNED_TO_USERS',
}
export enum PermissionsExceptionMessage {
@@ -78,4 +80,6 @@ export enum PermissionsExceptionMessage {
FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'Field restriction on update only makes sense on updatable object',
OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found',
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed',
ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'Role must be assignable to at least one target type',
ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'Role cannot be assigned to users',
}
@@ -41,6 +41,8 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT:
case PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT:
case PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED:
case PermissionsExceptionCode.ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET:
case PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS:
throw new UserInputError(error);
case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
@@ -52,4 +52,19 @@ export class CreateRoleInput {
@IsOptional()
@Field({ nullable: true })
canDestroyAllObjectRecords?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToUsers?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToAgents?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToApiKeys?: boolean;
}
@@ -4,11 +4,27 @@ import { Relation } from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { PermissionFlagDTO } from 'src/engine/metadata-modules/permission-flag/dtos/permission-flag.dto';
import { type RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
@ObjectType('ApiKeyForRole')
export class ApiKeyForRoleDTO {
@Field(() => UUIDScalarType, { nullable: false })
id: string;
@Field({ nullable: false })
name: string;
@Field(() => Date, { nullable: false })
expiresAt: Date;
@Field(() => Date, { nullable: true })
revokedAt?: Date | null;
}
@ObjectType('Role')
export class RoleDTO {
@Field(() => UUIDScalarType, { nullable: false })
@@ -29,12 +45,27 @@ export class RoleDTO {
@Field({ nullable: false })
isEditable: boolean;
@Field({ nullable: false })
canBeAssignedToUsers: boolean;
@Field({ nullable: false })
canBeAssignedToAgents: boolean;
@Field({ nullable: false })
canBeAssignedToApiKeys: boolean;
@HideField()
roleTargets: Relation<RoleTargetsEntity[]>;
@Field(() => [WorkspaceMember], { nullable: true })
workspaceMembers?: WorkspaceMember[];
@Field(() => [AgentDTO], { nullable: true })
agents?: AgentDTO[];
@Field(() => [ApiKeyForRoleDTO], { nullable: true })
apiKeys?: ApiKeyForRoleDTO[];
@Field({ nullable: false })
canUpdateAllSettings: boolean;
@@ -56,6 +56,21 @@ export class UpdateRolePayload {
@IsOptional()
@Field({ nullable: true })
canDestroyAllObjectRecords?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToUsers?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToAgents?: boolean;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
canBeAssignedToApiKeys?: boolean;
}
@InputType()
@@ -62,6 +62,15 @@ export class RoleEntity {
@Column({ nullable: false, default: true })
isEditable: boolean;
@Column({ nullable: false, default: true })
canBeAssignedToUsers: boolean;
@Column({ nullable: false, default: true })
canBeAssignedToAgents: boolean;
@Column({ nullable: false, default: true })
canBeAssignedToApiKeys: boolean;
@OneToMany(
() => RoleTargetsEntity,
(roleTargets: RoleTargetsEntity) => roleTargets.role,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
@@ -22,6 +23,7 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
TypeOrmModule.forFeature([UserWorkspace, Workspace]),
UserRoleModule,
AgentRoleModule,
ApiKeyModule,
PermissionsModule,
UserWorkspaceModule,
ObjectPermissionModule,
@@ -9,6 +9,7 @@ import {
} from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -22,6 +23,7 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input';
@@ -39,7 +41,10 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { CreateRoleInput } from 'src/engine/metadata-modules/role/dtos/create-role-input.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import {
ApiKeyForRoleDTO,
RoleDTO,
} from 'src/engine/metadata-modules/role/dtos/role.dto';
import { UpdateRoleInput } from 'src/engine/metadata-modules/role/dtos/update-role-input.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -63,6 +68,7 @@ export class RoleResolver {
private readonly objectPermissionService: ObjectPermissionService,
private readonly settingPermissionService: PermissionFlagService,
private readonly agentRoleService: AgentRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
private readonly fieldPermissionService: FieldPermissionService,
) {}
@@ -243,4 +249,35 @@ export class RoleResolver {
return workspaceMembers;
}
@ResolveField('agents', () => [AgentDTO])
async getAgentsAssignedToRole(
@Parent() role: RoleDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<AgentDTO[]> {
const agents = await this.agentRoleService.getAgentsAssignedToRole(
role.id,
workspace.id,
);
return agents;
}
@ResolveField('apiKeys', () => [ApiKeyForRoleDTO])
async getApiKeysAssignedToRole(
@Parent() role: RoleDTO,
@AuthWorkspace() workspace: Workspace,
): Promise<ApiKeyForRoleDTO[]> {
const apiKeys = await this.apiKeyRoleService.getApiKeysAssignedToRole(
role.id,
workspace.id,
);
return apiKeys.map((apiKey) => ({
id: apiKey.id,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
revokedAt: apiKey.revokedAt,
}));
}
}
@@ -83,6 +83,9 @@ export class RoleService {
canUpdateAllObjectRecords: input.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: input.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: input.canDestroyAllObjectRecords,
canBeAssignedToUsers: input.canBeAssignedToUsers,
canBeAssignedToAgents: input.canBeAssignedToAgents,
canBeAssignedToApiKeys: input.canBeAssignedToApiKeys,
isEditable: true,
workspaceId,
});
@@ -231,6 +234,9 @@ export class RoleService {
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: false,
isEditable: false,
workspaceId,
});
@@ -15,6 +15,9 @@ export const fromRoleEntityToRoleDto = (role: RoleEntity): RoleDTO => {
canUpdateAllObjectRecords: role.canUpdateAllObjectRecords,
canSoftDeleteAllObjectRecords: role.canSoftDeleteAllObjectRecords,
canDestroyAllObjectRecords: role.canDestroyAllObjectRecords,
canBeAssignedToUsers: role.canBeAssignedToUsers,
canBeAssignedToAgents: role.canBeAssignedToAgents,
canBeAssignedToApiKeys: role.canBeAssignedToApiKeys,
roleTargets: role.roleTargets,
};
};
@@ -258,6 +258,17 @@ export class UserRoleService {
);
}
if (!role.canBeAssignedToUsers) {
throw new PermissionsException(
`Role "${role.label}" cannot be assigned to users`,
PermissionsExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_USERS,
{
userFriendlyMessage:
'This role cannot be assigned to users. Please select a different role.',
},
);
}
const roles = await this.getRolesByUserWorkspaces({
userWorkspaceIds: [userWorkspace.id],
workspaceId,
@@ -49,11 +49,27 @@ export class WorkspaceAgentComparator {
switch (difference.type) {
case 'CREATE': {
const fromAgent = fromFlatAgents.find(
(agent) => keyFactory(agent) === universalIdentifier,
);
const toAgent = toFlatAgents.find(
(agent) => keyFactory(agent) === universalIdentifier,
);
if (toAgent) {
if (!toAgent) {
break;
}
if (fromAgent) {
fromAgent &&
results.push({
action: ComparatorAction.UPDATE,
object: {
...toAgent,
id: fromAgent.id,
},
});
} else {
results.push({
action: ComparatorAction.CREATE,
object: toAgent,
@@ -1,11 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { IsNull, Not, type EntityManager, type Repository } from 'typeorm';
import { IsNull, Not, type EntityManager } from 'typeorm';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { transformAgentEntityToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
@@ -19,7 +20,6 @@ import { WorkspaceAgentComparator } from 'src/engine/workspace-manager/workspace
import { StandardAgentFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory';
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
import { WORKFLOW_CREATION_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent';
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
@Injectable()
export class WorkspaceSyncAgentService {
@@ -28,6 +28,7 @@ export class WorkspaceSyncAgentService {
constructor(
private readonly standardAgentFactory: StandardAgentFactory,
private readonly workspaceAgentComparator: WorkspaceAgentComparator,
private readonly agentRoleService: AgentRoleService,
) {}
async synchronize(
@@ -37,8 +38,6 @@ export class WorkspaceSyncAgentService {
this.logger.log('Syncing standard agent.');
const agentRepository = manager.getRepository(AgentEntity);
const roleRepository = manager.getRepository(RoleEntity);
const roleTargetsRepository = manager.getRepository(RoleTargetsEntity);
const existingStandardAgentEntities = await agentRepository.find({
where: {
@@ -75,13 +74,43 @@ export class WorkspaceSyncAgentService {
workspaceId: context.workspaceId,
});
await this.assignAdminRoleToAgent(
createdAgent.id,
context.workspaceId,
roleRepository,
roleTargetsRepository,
const agentDefinition = standardAgentDefinitions.find(
(def) => def.standardId === createdAgent.standardId,
);
if (agentDefinition?.standardRoleId) {
try {
const roleRepository = manager.getRepository(RoleEntity);
const role = await roleRepository.findOne({
where: {
standardId: agentDefinition.standardRoleId,
workspaceId: context.workspaceId,
},
});
if (!role) {
throw new Error(
`Standard role with standard ID ${agentDefinition.standardRoleId} not found in workspace`,
);
}
const roleTargetsRepository =
manager.getRepository(RoleTargetsEntity);
await roleTargetsRepository.save({
roleId: role.id,
agentId: createdAgent.id,
workspaceId: context.workspaceId,
});
} catch (error) {
this.logger.error(
`Failed to assign standard role ${agentDefinition.standardRoleId} to agent ${createdAgent.id}: ${error.message}`,
error.stack,
);
throw error;
}
}
if (createdAgent.standardId === WORKFLOW_CREATION_AGENT.standardId) {
await this.createAgentHandoffToWorkflowCreationAgent(
createdAgent.id,
@@ -99,9 +128,47 @@ export class WorkspaceSyncAgentService {
'id',
'universalIdentifier',
'workspaceId',
'standardRoleId' as keyof typeof agentToUpdate,
]);
await agentRepository.update({ id: agentToUpdate.id }, flatAgentData);
const agentDefinition = standardAgentDefinitions.find(
(def) => def.standardId === agentToUpdate.standardId,
);
if (agentDefinition?.standardRoleId) {
try {
const roleRepository = manager.getRepository(RoleEntity);
const role = await roleRepository.findOne({
where: {
standardId: agentDefinition.standardRoleId,
workspaceId: context.workspaceId,
},
});
if (!role) {
throw new Error(
`Standard role with standard ID ${agentDefinition.standardRoleId} not found in workspace`,
);
}
const roleTargetsRepository =
manager.getRepository(RoleTargetsEntity);
await roleTargetsRepository.save({
roleId: role.id,
agentId: agentToUpdate.id,
workspaceId: context.workspaceId,
});
} catch (error) {
this.logger.error(
`Failed to assign standard role ${agentDefinition.standardRoleId} to agent ${agentToUpdate.id}: ${error.message}`,
error.stack,
);
throw error;
}
}
break;
}
@@ -115,60 +182,6 @@ export class WorkspaceSyncAgentService {
}
}
private async assignAdminRoleToAgent(
agentId: string,
workspaceId: string,
roleRepository: Repository<RoleEntity>,
roleTargetsRepository: Repository<RoleTargetsEntity>,
): Promise<void> {
try {
const adminRole = await roleRepository.findOne({
where: {
workspaceId,
standardId: ADMIN_ROLE.standardId,
},
});
if (!adminRole) {
this.logger.warn(
`Admin role not found for workspace ${workspaceId}, cannot assign to agent ${agentId}.`,
);
return;
}
const existingRoleTarget = await roleTargetsRepository.findOne({
where: {
agentId,
roleId: adminRole.id,
workspaceId,
},
});
if (existingRoleTarget) {
this.logger.log(
`Workflow creation agent already has admin role assigned`,
);
return;
}
await roleTargetsRepository.save({
roleId: adminRole.id,
agentId,
workspaceId,
});
this.logger.log(
`Successfully assigned admin role to workflow creation agent`,
);
} catch (error) {
this.logger.error(
`Failed to assign admin role to workflow creation agent: ${error.message}`,
);
}
}
private async createAgentHandoffToWorkflowCreationAgent(
workflowCreationAgentId: string,
workspaceId: string,
@@ -1,12 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { removePropertiesFromRecord } from 'twenty-shared/utils';
import { IsNull, Not, type EntityManager } from 'typeorm';
import { IsNull, Not, type EntityManager, type Repository } from 'typeorm';
import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface';
import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { fromRoleEntityToFlatRole } from 'src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspaceRoleComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-role.comparator';
import { StandardRoleFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-role.factory';
@@ -28,12 +30,15 @@ export class WorkspaceSyncRoleService {
this.logger.log('Syncing standard role metadata');
const roleRepository = manager.getRepository(RoleEntity);
const permissionFlagRepository =
manager.getRepository(PermissionFlagEntity);
const existingStandardRoleEntities = await roleRepository.find({
where: {
workspaceId: context.workspaceId,
standardId: Not(IsNull()),
},
relations: ['permissionFlags'],
});
const targetStandardRoles = this.standardRoleFactory.create(
@@ -57,10 +62,23 @@ export class WorkspaceSyncRoleService {
'id',
]);
await roleRepository.save({
const createdRole = await roleRepository.save({
...flatRoleData,
workspaceId: context.workspaceId,
});
const roleDefinition = standardRoleDefinitions.find(
(def) => def.standardId === roleToCreate.standardId,
);
if (roleDefinition?.permissionFlags?.length) {
await this.syncPermissionFlags(
permissionFlagRepository,
createdRole.id,
context.workspaceId,
roleDefinition.permissionFlags,
);
}
break;
}
@@ -74,6 +92,19 @@ export class WorkspaceSyncRoleService {
]);
await roleRepository.update({ id: roleToUpdate.id }, flatRoleData);
const roleDefinition = standardRoleDefinitions.find(
(def) => def.standardId === roleToUpdate.standardId,
);
if (roleDefinition?.permissionFlags) {
await this.syncPermissionFlags(
permissionFlagRepository,
roleToUpdate.id,
context.workspaceId,
roleDefinition.permissionFlags,
);
}
break;
}
@@ -86,4 +117,28 @@ export class WorkspaceSyncRoleService {
}
}
}
private async syncPermissionFlags(
permissionFlagRepository: Repository<PermissionFlagEntity>,
roleId: string,
workspaceId: string,
permissionFlags: PermissionFlagType[],
): Promise<void> {
await permissionFlagRepository.delete({
roleId,
workspaceId,
});
if (permissionFlags.length > 0) {
const newPermissionFlags = permissionFlags.map((flag) =>
permissionFlagRepository.create({
roleId,
workspaceId,
flag,
}),
);
await permissionFlagRepository.save(newPermissionFlags);
}
}
}
@@ -1,4 +1,5 @@
import { type StandardAgentDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface';
import { WORKFLOW_MANAGER_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/workflow-manager-role';
export const WORKFLOW_CREATION_AGENT: StandardAgentDefinition = {
standardId: '20202020-0002-0001-0001-000000000001',
@@ -44,4 +45,5 @@ Be helpful, thorough, and always prioritize user understanding and workflow effe
modelId: 'auto',
responseFormat: {},
isCustom: false,
standardRoleId: WORKFLOW_MANAGER_ROLE.standardId,
};
@@ -5,4 +5,5 @@ export type StandardAgentDefinition = Omit<
'id' | 'workspaceId' | 'universalIdentifier' | 'standardId'
> & {
standardId: string;
standardRoleId?: string;
};
@@ -1,6 +1,8 @@
import { ADMIN_ROLE } from './roles/admin-role';
import { WORKFLOW_MANAGER_ROLE } from './roles/workflow-manager-role';
import { type StandardRoleDefinition } from './types/standard-role-definition.interface';
export const standardRoleDefinitions = [
ADMIN_ROLE,
WORKFLOW_MANAGER_ROLE,
] as const satisfies StandardRoleDefinition[];
@@ -12,4 +12,7 @@ export const ADMIN_ROLE: StandardRoleDefinition = {
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canBeAssignedToUsers: true,
canBeAssignedToAgents: false,
canBeAssignedToApiKeys: true,
};
@@ -0,0 +1,20 @@
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { type StandardRoleDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/types/standard-role-definition.interface';
export const WORKFLOW_MANAGER_ROLE: StandardRoleDefinition = {
standardId: '20202020-0001-0001-0001-000000000002',
label: 'Workflow Manager',
description: 'Role for managing workflows',
icon: 'IconSettingsAutomation',
isEditable: false,
canUpdateAllSettings: false,
canAccessAllTools: true,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
permissionFlags: [PermissionFlagType.WORKFLOWS],
};
@@ -1,8 +1,10 @@
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
export type StandardRoleDefinition = Omit<
FlatRole,
'id' | 'workspaceId' | 'universalIdentifier' | 'standardId'
> & {
standardId: string;
permissionFlags?: PermissionFlagType[];
};
@@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -38,6 +39,7 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works
DataSourceModule,
TypeOrmModule.forFeature([Workspace, FeatureFlag]),
WorkspaceMetadataVersionModule,
AgentRoleModule,
],
providers: [
...workspaceSyncMetadataFactories,
@@ -89,7 +89,7 @@ describe('roles permissions', () => {
expect(resp.status).toBe(200);
expect(resp.body.errors).toBeUndefined();
expect(resp.body.data.getRoles).toHaveLength(4);
expect(resp.body.data.getRoles).toHaveLength(5);
expect(resp.body.data.getRoles).toEqual(
expect.arrayContaining([
{
@@ -222,7 +222,7 @@ export const workflowFilterActionSettingsSchema =
export const workflowIteratorActionSettingsSchema =
baseWorkflowActionSettingsSchema.extend({
input: z.object({
items: z.union([z.array(z.any()), z.string()]).optional(),
items: z.union([z.array(z.union([z.string(), z.number(), z.boolean(), z.null(), z.record(z.any()), z.any()])), z.string()]).optional(),
// TODO: should never be a string once fix the UI
initialLoopStepIds: z.union([z.array(z.string()), z.string()]).optional(),
}),