Skip to content

Commit da5ae34

Browse files
[permissions] Filter tabs + registered actions according to permissions (twentyhq#12657)
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. <img width="507" alt="Capture d’écran 2025-06-17 à 11 09 50" src="https://github.com/user-attachments/assets/3a2a4c25-0b9b-4ee6-b18f-b019b8a56d47" /> <img width="505" alt="Capture d’écran 2025-06-17 à 11 09 56" src="https://github.com/user-attachments/assets/8a219955-cc8e-4dbf-a4f9-a50e1aaa4b59" /> **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 <[email protected]>
1 parent e77e7e3 commit da5ae34

File tree

12 files changed

+206
-35
lines changed

12 files changed

+206
-35
lines changed

packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,13 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
410410
Icon: IconSettingsAutomation,
411411
accent: 'default',
412412
isPinned: false,
413-
shouldBeRegistered: ({ objectMetadataItem, viewType, isWorkflowEnabled }) =>
413+
shouldBeRegistered: ({
414+
objectMetadataItem,
415+
viewType,
416+
isWorkflowEnabled,
417+
getTargetObjectReadPermission,
418+
}) =>
419+
getTargetObjectReadPermission(CoreObjectNameSingular.Workflow) === true &&
414420
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Workflow ||
415421
viewType === ActionViewType.SHOW_PAGE) &&
416422
isWorkflowEnabled,
@@ -443,9 +449,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
443449
ActionViewType.INDEX_PAGE_BULK_SELECTION,
444450
ActionViewType.SHOW_PAGE,
445451
],
446-
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
447-
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person ||
448-
viewType === ActionViewType.SHOW_PAGE,
452+
shouldBeRegistered: ({
453+
objectMetadataItem,
454+
viewType,
455+
getTargetObjectReadPermission,
456+
}) =>
457+
getTargetObjectReadPermission(CoreObjectNameSingular.Person) === true &&
458+
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person ||
459+
viewType === ActionViewType.SHOW_PAGE),
449460
component: (
450461
<ActionLink
451462
to={AppPath.RecordIndexPage}
@@ -469,9 +480,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
469480
ActionViewType.INDEX_PAGE_BULK_SELECTION,
470481
ActionViewType.SHOW_PAGE,
471482
],
472-
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
473-
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company ||
474-
viewType === ActionViewType.SHOW_PAGE,
483+
shouldBeRegistered: ({
484+
objectMetadataItem,
485+
viewType,
486+
getTargetObjectReadPermission,
487+
}) =>
488+
getTargetObjectReadPermission(CoreObjectNameSingular.Company) === true &&
489+
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company ||
490+
viewType === ActionViewType.SHOW_PAGE),
475491
component: (
476492
<ActionLink
477493
to={AppPath.RecordIndexPage}
@@ -495,9 +511,16 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
495511
ActionViewType.INDEX_PAGE_BULK_SELECTION,
496512
ActionViewType.SHOW_PAGE,
497513
],
498-
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
499-
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Opportunity ||
500-
viewType === ActionViewType.SHOW_PAGE,
514+
shouldBeRegistered: ({
515+
objectMetadataItem,
516+
viewType,
517+
getTargetObjectReadPermission,
518+
}) =>
519+
getTargetObjectReadPermission(CoreObjectNameSingular.Opportunity) ===
520+
true &&
521+
(objectMetadataItem?.nameSingular !==
522+
CoreObjectNameSingular.Opportunity ||
523+
viewType === ActionViewType.SHOW_PAGE),
501524
component: (
502525
<ActionLink
503526
to={AppPath.RecordIndexPage}
@@ -547,9 +570,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
547570
ActionViewType.INDEX_PAGE_BULK_SELECTION,
548571
ActionViewType.SHOW_PAGE,
549572
],
550-
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
551-
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task ||
552-
viewType === ActionViewType.SHOW_PAGE,
573+
shouldBeRegistered: ({
574+
objectMetadataItem,
575+
viewType,
576+
getTargetObjectReadPermission,
577+
}) =>
578+
getTargetObjectReadPermission(CoreObjectNameSingular.Task) === true &&
579+
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task ||
580+
viewType === ActionViewType.SHOW_PAGE),
553581
component: (
554582
<ActionLink
555583
to={AppPath.RecordIndexPage}
@@ -573,9 +601,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
573601
ActionViewType.INDEX_PAGE_BULK_SELECTION,
574602
ActionViewType.SHOW_PAGE,
575603
],
576-
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
577-
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note ||
578-
viewType === ActionViewType.SHOW_PAGE,
604+
shouldBeRegistered: ({
605+
objectMetadataItem,
606+
viewType,
607+
getTargetObjectReadPermission,
608+
}) =>
609+
getTargetObjectReadPermission(CoreObjectNameSingular.Note) === true &&
610+
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note ||
611+
viewType === ActionViewType.SHOW_PAGE),
579612
component: (
580613
<ActionLink
581614
to={AppPath.RecordIndexPage}

packages/twenty-front/src/modules/action-menu/actions/types/ShouldBeRegisteredFunctionParams.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ export type ShouldBeRegisteredFunctionParams = {
2020
numberOfSelectedRecords?: number;
2121
workflowWithCurrentVersion?: WorkflowWithCurrentVersion;
2222
viewType?: ActionViewType;
23+
getTargetObjectReadPermission: (
24+
objectMetadataItemNameSingular: string,
25+
) => boolean;
2326
};

packages/twenty-front/src/modules/action-menu/actions/utils/getActionConfig.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,25 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
77
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
88
import { isDefined } from 'twenty-shared/utils';
99

10-
export const getActionConfig = (
11-
objectMetadataItem?: ObjectMetadataItem,
12-
): Record<string, ActionConfig> => {
10+
export const getActionConfig = ({
11+
objectMetadataItem,
12+
}: {
13+
objectMetadataItem?: ObjectMetadataItem;
14+
}): Record<string, ActionConfig> => {
1315
if (!isDefined(objectMetadataItem)) {
1416
return {};
1517
}
1618

1719
switch (objectMetadataItem.nameSingular) {
18-
case CoreObjectNameSingular.Workflow:
20+
case CoreObjectNameSingular.Workflow: {
1921
return WORKFLOW_ACTIONS_CONFIG;
20-
case CoreObjectNameSingular.WorkflowVersion:
22+
}
23+
case CoreObjectNameSingular.WorkflowVersion: {
2124
return WORKFLOW_VERSIONS_ACTIONS_CONFIG;
22-
case CoreObjectNameSingular.WorkflowRun:
25+
}
26+
case CoreObjectNameSingular.WorkflowRun: {
2327
return WORKFLOW_RUNS_ACTIONS_CONFIG;
28+
}
2429
default:
2530
return DEFAULT_RECORD_ACTIONS_CONFIG;
2631
}

packages/twenty-front/src/modules/action-menu/hooks/useRegisteredActions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export const useRegisteredActions = (
2626
contextStoreTargetedRecordsRule,
2727
);
2828

29-
const recordActionConfig = getActionConfig(objectMetadataItem);
29+
const recordActionConfig = getActionConfig({
30+
objectMetadataItem,
31+
});
3032

3133
const recordAgnosticActionConfig = RECORD_AGNOSTIC_ACTIONS_CONFIG;
3234

packages/twenty-front/src/modules/action-menu/hooks/useShouldActionBeRegisteredParams.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
22
import { getActionViewType } from '@/action-menu/actions/utils/getActionViewType';
33
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
4+
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
45
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
56
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
67
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
@@ -14,7 +15,7 @@ import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-t
1415
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
1516
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
1617
import { useContext } from 'react';
17-
import { useRecoilValue } from 'recoil';
18+
import { useRecoilCallback, useRecoilValue } from 'recoil';
1819
import { FeatureFlagKey } from '~/generated-metadata/graphql';
1920

2021
export const useShouldActionBeRegisteredParams = ({
@@ -77,6 +78,20 @@ export const useShouldActionBeRegisteredParams = ({
7778
contextStoreTargetedRecordsRule,
7879
);
7980

81+
const getObjectReadPermission = useRecoilCallback(
82+
({ snapshot }) =>
83+
(objectMetadataNameSingular: string) => {
84+
return snapshot
85+
.getLoadable(
86+
objectPermissionsFamilySelector({
87+
objectNameSingular: objectMetadataNameSingular,
88+
}),
89+
)
90+
.getValue().canRead;
91+
},
92+
[],
93+
);
94+
8095
return {
8196
objectMetadataItem,
8297
isFavorite,
@@ -89,5 +104,6 @@ export const useShouldActionBeRegisteredParams = ({
89104
isWorkflowEnabled,
90105
numberOfSelectedRecords,
91106
viewType: viewType ?? undefined,
107+
getTargetObjectReadPermission: getObjectReadPermission,
92108
};
93109
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
2+
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
3+
import { selectorFamily } from 'recoil';
4+
5+
export const objectPermissionsFamilySelector = selectorFamily<
6+
{
7+
canRead: boolean;
8+
},
9+
{ objectNameSingular: string }
10+
>({
11+
key: 'objectPermissionsFamilySelector',
12+
get:
13+
({ objectNameSingular }) =>
14+
({ get }) => {
15+
const currentUserWorkspace = get(currentUserWorkspaceState);
16+
const objectMetadataItems = get(objectMetadataItemsState);
17+
18+
const objectMetadataItem = objectMetadataItems.find(
19+
(item) => item.nameSingular === objectNameSingular,
20+
);
21+
22+
if (!objectMetadataItem) {
23+
return {
24+
canRead: false,
25+
canUpdate: false,
26+
};
27+
}
28+
29+
const objectPermissions = currentUserWorkspace?.objectPermissions?.find(
30+
(permission) => permission.objectMetadataId === objectMetadataItem.id,
31+
);
32+
33+
return {
34+
canRead: objectPermissions?.canReadObjectRecords ?? false,
35+
};
36+
},
37+
});

packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
1010
import { graphqlMocks } from '~/testing/graphqlMocks';
1111
import {
1212
mockCurrentWorkspace,
13+
mockedLimitedPermissionsUserData,
14+
mockedUserData,
1315
mockedWorkspaceMemberData,
1416
} from '~/testing/mock-data/users';
1517
import { sleep } from '~/utils/sleep';
1618

1719
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
20+
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
1821
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
1922
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
2023
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
@@ -72,6 +75,9 @@ const meta: Meta<typeof CommandMenu> = {
7275
I18nFrontDecorator,
7376
(Story) => {
7477
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
78+
const setCurrentUserWorkspace = useSetRecoilState(
79+
currentUserWorkspaceState,
80+
);
7581
const setCurrentWorkspaceMember = useSetRecoilState(
7682
currentWorkspaceMemberState,
7783
);
@@ -84,6 +90,8 @@ const meta: Meta<typeof CommandMenu> = {
8490

8591
setCurrentWorkspace(mockCurrentWorkspace);
8692
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
93+
setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
94+
8795
setIsCommandMenuOpened(true);
8896
setCommandMenuNavigationStack([
8997
{
@@ -122,6 +130,29 @@ export const DefaultWithoutSearch: Story = {
122130
},
123131
};
124132

133+
export const LimitedPermissions: Story = {
134+
play: async () => {
135+
const canvas = within(document.body);
136+
await expect(canvas.findByText('Go to Opportunities')).rejects.toThrow();
137+
await expect(canvas.findByText('Go to Tasks')).rejects.toThrow();
138+
expect(await canvas.findByText('Go to People')).toBeVisible();
139+
expect(await canvas.findByText('Go to Settings')).toBeVisible();
140+
expect(await canvas.findByText('Go to Notes')).toBeVisible();
141+
},
142+
decorators: [
143+
(Story) => {
144+
const setCurrentUserWorkspace = useSetRecoilState(
145+
currentUserWorkspaceState,
146+
);
147+
setCurrentUserWorkspace(
148+
mockedLimitedPermissionsUserData.currentUserWorkspace,
149+
);
150+
151+
return <Story />;
152+
},
153+
],
154+
};
155+
125156
export const MatchingNavigate: Story = {
126157
play: async () => {
127158
const canvas = within(document.body);

packages/twenty-front/src/modules/object-record/record-show/constants/BaseRecordLayout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,31 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
4444
Icon: IconCheckbox,
4545
position: 300,
4646
cards: [{ type: CardType.TaskCard }],
47+
targetObjectNameSingular: CoreObjectNameSingular.Task,
4748
hide: {
4849
ifMobile: false,
4950
ifDesktop: false,
5051
ifInRightDrawer: false,
5152
ifFeaturesDisabled: [],
5253
ifRequiredObjectsInactive: [CoreObjectNameSingular.Task],
5354
ifRelationsMissing: ['taskTargets'],
55+
ifNoReadPermission: true,
5456
},
5557
},
5658
notes: {
5759
title: 'Notes',
5860
Icon: IconNotes,
5961
position: 400,
6062
cards: [{ type: CardType.NoteCard }],
63+
targetObjectNameSingular: CoreObjectNameSingular.Note,
6164
hide: {
6265
ifMobile: false,
6366
ifDesktop: false,
6467
ifInRightDrawer: false,
6568
ifFeaturesDisabled: [],
6669
ifRequiredObjectsInactive: [CoreObjectNameSingular.Note],
6770
ifRelationsMissing: ['noteTargets'],
71+
ifNoReadPermission: true,
6872
},
6973
},
7074
files: {

0 commit comments

Comments
 (0)