From da5ae341096f518fbb68cd1a16309b6bce0ff0be Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 18 Jun 2025 17:12:58 +0200 Subject: [PATCH] [permissions] Filter tabs + registered actions according to permissions (#12657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note and task tabs in side panel should only show if user has reading permission on them. "Go to companies", "Go to workflows", etc. in command menu should only show is user has reading permission on related objects. Capture d’écran 2025-06-17 à 11 09 50 Capture d’écran 2025-06-17 à 11 09 56 **How to test** Assign a user with a custom role that has **no** read permissions on notes/tasks/workflows/companies/opportunities/people (no need to test them all but at least one between note and tasks; workflows; one between companies/opportunities/people). Check that you don't see the related tab / action. --------- Co-authored-by: Charles Bochet --- .../constants/DefaultRecordActionsConfig.tsx | 65 ++++++++++++++----- .../types/ShouldBeRegisteredFunctionParams.ts | 3 + .../actions/utils/getActionConfig.ts | 17 +++-- .../action-menu/hooks/useRegisteredActions.ts | 4 +- .../useShouldActionBeRegisteredParams.ts | 18 ++++- .../states/objectPermissionsFamilySelector.ts | 37 +++++++++++ .../__stories__/CommandMenu.stories.tsx | 31 +++++++++ .../record-show/constants/BaseRecordLayout.ts | 4 ++ .../hooks/useRecordShowContainerTabs.ts | 26 ++++++-- .../layout/tab-list/types/RecordLayoutTab.ts | 1 + .../tab-list/types/TabVisibilityConfig.ts | 1 + .../src/testing/mock-data/users.ts | 34 ++++++++-- 12 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx index c0ed019ca46..f3f5b31b983 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx @@ -410,7 +410,13 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record< Icon: IconSettingsAutomation, accent: 'default', isPinned: false, - shouldBeRegistered: ({ objectMetadataItem, viewType, isWorkflowEnabled }) => + shouldBeRegistered: ({ + objectMetadataItem, + viewType, + isWorkflowEnabled, + getTargetObjectReadPermission, + }) => + getTargetObjectReadPermission(CoreObjectNameSingular.Workflow) === true && (objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Workflow || viewType === ActionViewType.SHOW_PAGE) && isWorkflowEnabled, @@ -443,9 +449,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record< ActionViewType.INDEX_PAGE_BULK_SELECTION, ActionViewType.SHOW_PAGE, ], - shouldBeRegistered: ({ objectMetadataItem, viewType }) => - objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person || - viewType === ActionViewType.SHOW_PAGE, + shouldBeRegistered: ({ + objectMetadataItem, + viewType, + getTargetObjectReadPermission, + }) => + getTargetObjectReadPermission(CoreObjectNameSingular.Person) === true && + (objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person || + viewType === ActionViewType.SHOW_PAGE), component: ( - objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company || - viewType === ActionViewType.SHOW_PAGE, + shouldBeRegistered: ({ + objectMetadataItem, + viewType, + getTargetObjectReadPermission, + }) => + getTargetObjectReadPermission(CoreObjectNameSingular.Company) === true && + (objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company || + viewType === ActionViewType.SHOW_PAGE), component: ( - objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Opportunity || - viewType === ActionViewType.SHOW_PAGE, + shouldBeRegistered: ({ + objectMetadataItem, + viewType, + getTargetObjectReadPermission, + }) => + getTargetObjectReadPermission(CoreObjectNameSingular.Opportunity) === + true && + (objectMetadataItem?.nameSingular !== + CoreObjectNameSingular.Opportunity || + viewType === ActionViewType.SHOW_PAGE), component: ( - objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task || - viewType === ActionViewType.SHOW_PAGE, + shouldBeRegistered: ({ + objectMetadataItem, + viewType, + getTargetObjectReadPermission, + }) => + getTargetObjectReadPermission(CoreObjectNameSingular.Task) === true && + (objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task || + viewType === ActionViewType.SHOW_PAGE), component: ( - objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note || - viewType === ActionViewType.SHOW_PAGE, + shouldBeRegistered: ({ + objectMetadataItem, + viewType, + getTargetObjectReadPermission, + }) => + getTargetObjectReadPermission(CoreObjectNameSingular.Note) === true && + (objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note || + viewType === ActionViewType.SHOW_PAGE), component: ( boolean; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts b/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts index 145ef834e67..30dc8644ca6 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts +++ b/packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts @@ -7,20 +7,25 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isDefined } from 'twenty-shared/utils'; -export const getActionConfig = ( - objectMetadataItem?: ObjectMetadataItem, -): Record => { +export const getActionConfig = ({ + objectMetadataItem, +}: { + objectMetadataItem?: ObjectMetadataItem; +}): Record => { if (!isDefined(objectMetadataItem)) { return {}; } switch (objectMetadataItem.nameSingular) { - case CoreObjectNameSingular.Workflow: + case CoreObjectNameSingular.Workflow: { return WORKFLOW_ACTIONS_CONFIG; - case CoreObjectNameSingular.WorkflowVersion: + } + case CoreObjectNameSingular.WorkflowVersion: { return WORKFLOW_VERSIONS_ACTIONS_CONFIG; - case CoreObjectNameSingular.WorkflowRun: + } + case CoreObjectNameSingular.WorkflowRun: { return WORKFLOW_RUNS_ACTIONS_CONFIG; + } default: return DEFAULT_RECORD_ACTIONS_CONFIG; } diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts b/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts index add0872a59d..658b04d9546 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts @@ -26,7 +26,9 @@ export const useRegisteredActions = ( contextStoreTargetedRecordsRule, ); - const recordActionConfig = getActionConfig(objectMetadataItem); + const recordActionConfig = getActionConfig({ + objectMetadataItem, + }); const recordAgnosticActionConfig = RECORD_AGNOSTIC_ACTIONS_CONFIG; diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts b/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts index 9e96479071d..484af156744 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts @@ -1,6 +1,7 @@ import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams'; import { getActionViewType } from '@/action-menu/actions/utils/getActionViewType'; import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; +import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector'; import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; @@ -14,7 +15,7 @@ import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-t import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { useContext } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useRecoilCallback, useRecoilValue } from 'recoil'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; export const useShouldActionBeRegisteredParams = ({ @@ -77,6 +78,20 @@ export const useShouldActionBeRegisteredParams = ({ contextStoreTargetedRecordsRule, ); + const getObjectReadPermission = useRecoilCallback( + ({ snapshot }) => + (objectMetadataNameSingular: string) => { + return snapshot + .getLoadable( + objectPermissionsFamilySelector({ + objectNameSingular: objectMetadataNameSingular, + }), + ) + .getValue().canRead; + }, + [], + ); + return { objectMetadataItem, isFavorite, @@ -89,5 +104,6 @@ export const useShouldActionBeRegisteredParams = ({ isWorkflowEnabled, numberOfSelectedRecords, viewType: viewType ?? undefined, + getTargetObjectReadPermission: getObjectReadPermission, }; }; diff --git a/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts b/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts new file mode 100644 index 00000000000..8a292a7d8dd --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/objectPermissionsFamilySelector.ts @@ -0,0 +1,37 @@ +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { selectorFamily } from 'recoil'; + +export const objectPermissionsFamilySelector = selectorFamily< + { + canRead: boolean; + }, + { objectNameSingular: string } +>({ + key: 'objectPermissionsFamilySelector', + get: + ({ objectNameSingular }) => + ({ get }) => { + const currentUserWorkspace = get(currentUserWorkspaceState); + const objectMetadataItems = get(objectMetadataItemsState); + + const objectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === objectNameSingular, + ); + + if (!objectMetadataItem) { + return { + canRead: false, + canUpdate: false, + }; + } + + const objectPermissions = currentUserWorkspace?.objectPermissions?.find( + (permission) => permission.objectMetadataId === objectMetadataItem.id, + ); + + return { + canRead: objectPermissions?.canReadObjectRecords ?? false, + }; + }, +}); diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index 5abf9fc1468..6e284e892bf 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -10,11 +10,14 @@ import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { mockCurrentWorkspace, + mockedLimitedPermissionsUserData, + mockedUserData, mockedWorkspaceMemberData, } from '~/testing/mock-data/users'; import { sleep } from '~/utils/sleep'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter'; import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId'; import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState'; @@ -72,6 +75,9 @@ const meta: Meta = { I18nFrontDecorator, (Story) => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); + const setCurrentUserWorkspace = useSetRecoilState( + currentUserWorkspaceState, + ); const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -84,6 +90,8 @@ const meta: Meta = { setCurrentWorkspace(mockCurrentWorkspace); setCurrentWorkspaceMember(mockedWorkspaceMemberData); + setCurrentUserWorkspace(mockedUserData.currentUserWorkspace); + setIsCommandMenuOpened(true); setCommandMenuNavigationStack([ { @@ -122,6 +130,29 @@ export const DefaultWithoutSearch: Story = { }, }; +export const LimitedPermissions: Story = { + play: async () => { + const canvas = within(document.body); + await expect(canvas.findByText('Go to Opportunities')).rejects.toThrow(); + await expect(canvas.findByText('Go to Tasks')).rejects.toThrow(); + expect(await canvas.findByText('Go to People')).toBeVisible(); + expect(await canvas.findByText('Go to Settings')).toBeVisible(); + expect(await canvas.findByText('Go to Notes')).toBeVisible(); + }, + decorators: [ + (Story) => { + const setCurrentUserWorkspace = useSetRecoilState( + currentUserWorkspaceState, + ); + setCurrentUserWorkspace( + mockedLimitedPermissionsUserData.currentUserWorkspace, + ); + + return ; + }, + ], +}; + export const MatchingNavigate: Story = { play: async () => { const canvas = within(document.body); diff --git a/packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts b/packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts index 3892da68356..7091608a8c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts @@ -44,6 +44,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = { Icon: IconCheckbox, position: 300, cards: [{ type: CardType.TaskCard }], + targetObjectNameSingular: CoreObjectNameSingular.Task, hide: { ifMobile: false, ifDesktop: false, @@ -51,6 +52,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = { ifFeaturesDisabled: [], ifRequiredObjectsInactive: [CoreObjectNameSingular.Task], ifRelationsMissing: ['taskTargets'], + ifNoReadPermission: true, }, }, notes: { @@ -58,6 +60,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = { Icon: IconNotes, position: 400, cards: [{ type: CardType.NoteCard }], + targetObjectNameSingular: CoreObjectNameSingular.Note, hide: { ifMobile: false, ifDesktop: false, @@ -65,6 +68,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = { ifFeaturesDisabled: [], ifRequiredObjectsInactive: [CoreObjectNameSingular.Note], ifRelationsMissing: ['noteTargets'], + ifNoReadPermission: true, }, }, files: { diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts index 8cbf1e8ca3a..e78755e5ac8 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerTabs.ts @@ -2,6 +2,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; import { BASE_RECORD_LAYOUT } from '@/object-record/record-show/constants/BaseRecordLayout'; import { CardType } from '@/object-record/record-show/types/CardType'; import { RecordLayout } from '@/object-record/record-show/types/RecordLayout'; @@ -10,6 +11,7 @@ import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; import { IconCalendarEvent, IconHome, @@ -30,6 +32,7 @@ export const useRecordShowContainerTabs = ( const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); // Object-specific layouts that override or extend the base layout const OBJECT_SPECIFIC_LAYOUTS: Partial< @@ -212,17 +215,19 @@ export const useRecordShowContainerTabs = ( [], ); + const baseRecordLayout = BASE_RECORD_LAYOUT; + // Merge base layout with object-specific layout const recordLayout: RecordLayout = useMemo(() => { return { - ...BASE_RECORD_LAYOUT, + ...baseRecordLayout, ...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular] || {}), tabs: { - ...BASE_RECORD_LAYOUT.tabs, + ...baseRecordLayout.tabs, ...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular]?.tabs || {}), }, }; - }, [OBJECT_SPECIFIC_LAYOUTS, targetObjectNameSingular]); + }, [OBJECT_SPECIFIC_LAYOUTS, baseRecordLayout, targetObjectNameSingular]); return { layout: recordLayout, @@ -232,7 +237,7 @@ export const useRecordShowContainerTabs = ( entry[1] !== null && entry[1] !== undefined, ) .sort(([, a], [, b]) => a.position - b.position) - .map(([key, { title, Icon, hide, cards }]) => { + .map(([key, { title, Icon, hide, cards, targetObjectNameSingular }]) => { // Special handling for fields tab if (key === 'fields') { return { @@ -257,6 +262,16 @@ export const useRecordShowContainerTabs = ( ); }); + const targetObjectMetadataId = objectMetadataItems.find( + (item) => item.nameSingular === targetObjectNameSingular, + )?.id; + + const permissionHide = + hide.ifNoReadPermission && + isDefined(targetObjectNameSingular) && + !objectPermissionsByObjectMetadataId[targetObjectMetadataId] + ?.canReadObjectRecords; + const requiredObjectsInactive = hide.ifRequiredObjectsInactive.length > 0 && !hide.ifRequiredObjectsInactive.every((obj) => @@ -286,7 +301,8 @@ export const useRecordShowContainerTabs = ( baseHide || featureNotEnabled || requiredObjectsInactive || - relationsDontExist, + relationsDontExist || + permissionHide, }; }) // When isInRightDrawer === true, we merge first and second tab into first tab diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts index c4cdc283186..d23e71e3b40 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/RecordLayoutTab.ts @@ -8,4 +8,5 @@ export type RecordLayoutTab = { Icon: IconComponent; hide: TabVisibilityConfig; cards: LayoutCard[]; + targetObjectNameSingular?: string; }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts index cc7fd55d3a0..713d4b85280 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts +++ b/packages/twenty-front/src/modules/ui/layout/tab-list/types/TabVisibilityConfig.ts @@ -8,4 +8,5 @@ export type TabVisibilityConfig = { ifFeaturesDisabled: FeatureFlagKey[]; ifRequiredObjectsInactive: CoreObjectNameSingular[]; ifRelationsMissing: string[]; + ifNoReadPermission?: boolean; }; diff --git a/packages/twenty-front/src/testing/mock-data/users.ts b/packages/twenty-front/src/testing/mock-data/users.ts index 6f174bceeae..e6da563ae8e 100644 --- a/packages/twenty-front/src/testing/mock-data/users.ts +++ b/packages/twenty-front/src/testing/mock-data/users.ts @@ -12,6 +12,7 @@ import { WorkspaceMemberDateFormatEnum, WorkspaceMemberTimeFormatEnum, } from '~/generated/graphql'; +import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; type MockedUser = Pick< User, @@ -128,12 +129,13 @@ export const mockedUserData: MockedUser = { currentWorkspace: mockCurrentWorkspace, currentUserWorkspace: { settingsPermissions: [SettingPermissionType.WORKSPACE_MEMBERS], - objectPermissions: [ - { - objectMetadataId: '4a45f524-b8cb-40e8-8450-28e402b442cf', - canReadObjectRecords: true, - }, - ], + objectPermissions: generatedMockObjectMetadataItems.map((item) => ({ + objectMetadataId: item.id, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + })), }, locale: 'en', workspaces: [{ workspace: mockCurrentWorkspace }], @@ -146,6 +148,26 @@ export const mockedUserData: MockedUser = { userVars: {}, }; +export const mockedLimitedPermissionsUserData: MockedUser = { + ...mockedUserData, + currentUserWorkspace: { + ...mockedUserData.currentUserWorkspace, + objectPermissions: generatedMockObjectMetadataItems + .filter( + (objectMetadata) => + objectMetadata.nameSingular !== 'task' && + objectMetadata.nameSingular !== 'opportunity', + ) + .map((item) => ({ + objectMetadataId: item.id, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: true, + })), + }, +}; + export const mockedOnboardingUserData = ( onboardingStatus?: OnboardingStatus, ) => {