[permissions] Filter tabs + registered actions according to permissions (#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 <charles@twenty.com>
This commit is contained in:
@ -410,7 +410,13 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
|
|||||||
Icon: IconSettingsAutomation,
|
Icon: IconSettingsAutomation,
|
||||||
accent: 'default',
|
accent: 'default',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
shouldBeRegistered: ({ objectMetadataItem, viewType, isWorkflowEnabled }) =>
|
shouldBeRegistered: ({
|
||||||
|
objectMetadataItem,
|
||||||
|
viewType,
|
||||||
|
isWorkflowEnabled,
|
||||||
|
getTargetObjectReadPermission,
|
||||||
|
}) =>
|
||||||
|
getTargetObjectReadPermission(CoreObjectNameSingular.Workflow) === true &&
|
||||||
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Workflow ||
|
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Workflow ||
|
||||||
viewType === ActionViewType.SHOW_PAGE) &&
|
viewType === ActionViewType.SHOW_PAGE) &&
|
||||||
isWorkflowEnabled,
|
isWorkflowEnabled,
|
||||||
@ -443,9 +449,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
|
|||||||
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
||||||
ActionViewType.SHOW_PAGE,
|
ActionViewType.SHOW_PAGE,
|
||||||
],
|
],
|
||||||
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
|
shouldBeRegistered: ({
|
||||||
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person ||
|
objectMetadataItem,
|
||||||
viewType === ActionViewType.SHOW_PAGE,
|
viewType,
|
||||||
|
getTargetObjectReadPermission,
|
||||||
|
}) =>
|
||||||
|
getTargetObjectReadPermission(CoreObjectNameSingular.Person) === true &&
|
||||||
|
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Person ||
|
||||||
|
viewType === ActionViewType.SHOW_PAGE),
|
||||||
component: (
|
component: (
|
||||||
<ActionLink
|
<ActionLink
|
||||||
to={AppPath.RecordIndexPage}
|
to={AppPath.RecordIndexPage}
|
||||||
@ -469,9 +480,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
|
|||||||
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
||||||
ActionViewType.SHOW_PAGE,
|
ActionViewType.SHOW_PAGE,
|
||||||
],
|
],
|
||||||
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
|
shouldBeRegistered: ({
|
||||||
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company ||
|
objectMetadataItem,
|
||||||
viewType === ActionViewType.SHOW_PAGE,
|
viewType,
|
||||||
|
getTargetObjectReadPermission,
|
||||||
|
}) =>
|
||||||
|
getTargetObjectReadPermission(CoreObjectNameSingular.Company) === true &&
|
||||||
|
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Company ||
|
||||||
|
viewType === ActionViewType.SHOW_PAGE),
|
||||||
component: (
|
component: (
|
||||||
<ActionLink
|
<ActionLink
|
||||||
to={AppPath.RecordIndexPage}
|
to={AppPath.RecordIndexPage}
|
||||||
@ -495,9 +511,16 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
|
|||||||
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
||||||
ActionViewType.SHOW_PAGE,
|
ActionViewType.SHOW_PAGE,
|
||||||
],
|
],
|
||||||
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
|
shouldBeRegistered: ({
|
||||||
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Opportunity ||
|
objectMetadataItem,
|
||||||
viewType === ActionViewType.SHOW_PAGE,
|
viewType,
|
||||||
|
getTargetObjectReadPermission,
|
||||||
|
}) =>
|
||||||
|
getTargetObjectReadPermission(CoreObjectNameSingular.Opportunity) ===
|
||||||
|
true &&
|
||||||
|
(objectMetadataItem?.nameSingular !==
|
||||||
|
CoreObjectNameSingular.Opportunity ||
|
||||||
|
viewType === ActionViewType.SHOW_PAGE),
|
||||||
component: (
|
component: (
|
||||||
<ActionLink
|
<ActionLink
|
||||||
to={AppPath.RecordIndexPage}
|
to={AppPath.RecordIndexPage}
|
||||||
@ -547,9 +570,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
|
|||||||
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
||||||
ActionViewType.SHOW_PAGE,
|
ActionViewType.SHOW_PAGE,
|
||||||
],
|
],
|
||||||
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
|
shouldBeRegistered: ({
|
||||||
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task ||
|
objectMetadataItem,
|
||||||
viewType === ActionViewType.SHOW_PAGE,
|
viewType,
|
||||||
|
getTargetObjectReadPermission,
|
||||||
|
}) =>
|
||||||
|
getTargetObjectReadPermission(CoreObjectNameSingular.Task) === true &&
|
||||||
|
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Task ||
|
||||||
|
viewType === ActionViewType.SHOW_PAGE),
|
||||||
component: (
|
component: (
|
||||||
<ActionLink
|
<ActionLink
|
||||||
to={AppPath.RecordIndexPage}
|
to={AppPath.RecordIndexPage}
|
||||||
@ -573,9 +601,14 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
|
|||||||
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
ActionViewType.INDEX_PAGE_BULK_SELECTION,
|
||||||
ActionViewType.SHOW_PAGE,
|
ActionViewType.SHOW_PAGE,
|
||||||
],
|
],
|
||||||
shouldBeRegistered: ({ objectMetadataItem, viewType }) =>
|
shouldBeRegistered: ({
|
||||||
objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note ||
|
objectMetadataItem,
|
||||||
viewType === ActionViewType.SHOW_PAGE,
|
viewType,
|
||||||
|
getTargetObjectReadPermission,
|
||||||
|
}) =>
|
||||||
|
getTargetObjectReadPermission(CoreObjectNameSingular.Note) === true &&
|
||||||
|
(objectMetadataItem?.nameSingular !== CoreObjectNameSingular.Note ||
|
||||||
|
viewType === ActionViewType.SHOW_PAGE),
|
||||||
component: (
|
component: (
|
||||||
<ActionLink
|
<ActionLink
|
||||||
to={AppPath.RecordIndexPage}
|
to={AppPath.RecordIndexPage}
|
||||||
|
|||||||
@ -20,4 +20,7 @@ export type ShouldBeRegisteredFunctionParams = {
|
|||||||
numberOfSelectedRecords?: number;
|
numberOfSelectedRecords?: number;
|
||||||
workflowWithCurrentVersion?: WorkflowWithCurrentVersion;
|
workflowWithCurrentVersion?: WorkflowWithCurrentVersion;
|
||||||
viewType?: ActionViewType;
|
viewType?: ActionViewType;
|
||||||
|
getTargetObjectReadPermission: (
|
||||||
|
objectMetadataItemNameSingular: string,
|
||||||
|
) => boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,20 +7,25 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
|
|||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export const getActionConfig = (
|
export const getActionConfig = ({
|
||||||
objectMetadataItem?: ObjectMetadataItem,
|
objectMetadataItem,
|
||||||
): Record<string, ActionConfig> => {
|
}: {
|
||||||
|
objectMetadataItem?: ObjectMetadataItem;
|
||||||
|
}): Record<string, ActionConfig> => {
|
||||||
if (!isDefined(objectMetadataItem)) {
|
if (!isDefined(objectMetadataItem)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (objectMetadataItem.nameSingular) {
|
switch (objectMetadataItem.nameSingular) {
|
||||||
case CoreObjectNameSingular.Workflow:
|
case CoreObjectNameSingular.Workflow: {
|
||||||
return WORKFLOW_ACTIONS_CONFIG;
|
return WORKFLOW_ACTIONS_CONFIG;
|
||||||
case CoreObjectNameSingular.WorkflowVersion:
|
}
|
||||||
|
case CoreObjectNameSingular.WorkflowVersion: {
|
||||||
return WORKFLOW_VERSIONS_ACTIONS_CONFIG;
|
return WORKFLOW_VERSIONS_ACTIONS_CONFIG;
|
||||||
case CoreObjectNameSingular.WorkflowRun:
|
}
|
||||||
|
case CoreObjectNameSingular.WorkflowRun: {
|
||||||
return WORKFLOW_RUNS_ACTIONS_CONFIG;
|
return WORKFLOW_RUNS_ACTIONS_CONFIG;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return DEFAULT_RECORD_ACTIONS_CONFIG;
|
return DEFAULT_RECORD_ACTIONS_CONFIG;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,9 @@ export const useRegisteredActions = (
|
|||||||
contextStoreTargetedRecordsRule,
|
contextStoreTargetedRecordsRule,
|
||||||
);
|
);
|
||||||
|
|
||||||
const recordActionConfig = getActionConfig(objectMetadataItem);
|
const recordActionConfig = getActionConfig({
|
||||||
|
objectMetadataItem,
|
||||||
|
});
|
||||||
|
|
||||||
const recordAgnosticActionConfig = RECORD_AGNOSTIC_ACTIONS_CONFIG;
|
const recordAgnosticActionConfig = RECORD_AGNOSTIC_ACTIONS_CONFIG;
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
|
import { ShouldBeRegisteredFunctionParams } from '@/action-menu/actions/types/ShouldBeRegisteredFunctionParams';
|
||||||
import { getActionViewType } from '@/action-menu/actions/utils/getActionViewType';
|
import { getActionViewType } from '@/action-menu/actions/utils/getActionViewType';
|
||||||
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
||||||
|
import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector';
|
||||||
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
|
||||||
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
|
||||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
|
||||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilCallback, useRecoilValue } from 'recoil';
|
||||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
export const useShouldActionBeRegisteredParams = ({
|
export const useShouldActionBeRegisteredParams = ({
|
||||||
@ -77,6 +78,20 @@ export const useShouldActionBeRegisteredParams = ({
|
|||||||
contextStoreTargetedRecordsRule,
|
contextStoreTargetedRecordsRule,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getObjectReadPermission = useRecoilCallback(
|
||||||
|
({ snapshot }) =>
|
||||||
|
(objectMetadataNameSingular: string) => {
|
||||||
|
return snapshot
|
||||||
|
.getLoadable(
|
||||||
|
objectPermissionsFamilySelector({
|
||||||
|
objectNameSingular: objectMetadataNameSingular,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.getValue().canRead;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
objectMetadataItem,
|
objectMetadataItem,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
@ -89,5 +104,6 @@ export const useShouldActionBeRegisteredParams = ({
|
|||||||
isWorkflowEnabled,
|
isWorkflowEnabled,
|
||||||
numberOfSelectedRecords,
|
numberOfSelectedRecords,
|
||||||
viewType: viewType ?? undefined,
|
viewType: viewType ?? undefined,
|
||||||
|
getTargetObjectReadPermission: getObjectReadPermission,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -10,11 +10,14 @@ import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
|||||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||||
import {
|
import {
|
||||||
mockCurrentWorkspace,
|
mockCurrentWorkspace,
|
||||||
|
mockedLimitedPermissionsUserData,
|
||||||
|
mockedUserData,
|
||||||
mockedWorkspaceMemberData,
|
mockedWorkspaceMemberData,
|
||||||
} from '~/testing/mock-data/users';
|
} from '~/testing/mock-data/users';
|
||||||
import { sleep } from '~/utils/sleep';
|
import { sleep } from '~/utils/sleep';
|
||||||
|
|
||||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||||
|
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
|
||||||
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
|
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
|
||||||
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
|
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
|
||||||
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
|
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
|
||||||
@ -72,6 +75,9 @@ const meta: Meta<typeof CommandMenu> = {
|
|||||||
I18nFrontDecorator,
|
I18nFrontDecorator,
|
||||||
(Story) => {
|
(Story) => {
|
||||||
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
|
||||||
|
const setCurrentUserWorkspace = useSetRecoilState(
|
||||||
|
currentUserWorkspaceState,
|
||||||
|
);
|
||||||
const setCurrentWorkspaceMember = useSetRecoilState(
|
const setCurrentWorkspaceMember = useSetRecoilState(
|
||||||
currentWorkspaceMemberState,
|
currentWorkspaceMemberState,
|
||||||
);
|
);
|
||||||
@ -84,6 +90,8 @@ const meta: Meta<typeof CommandMenu> = {
|
|||||||
|
|
||||||
setCurrentWorkspace(mockCurrentWorkspace);
|
setCurrentWorkspace(mockCurrentWorkspace);
|
||||||
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
|
setCurrentWorkspaceMember(mockedWorkspaceMemberData);
|
||||||
|
setCurrentUserWorkspace(mockedUserData.currentUserWorkspace);
|
||||||
|
|
||||||
setIsCommandMenuOpened(true);
|
setIsCommandMenuOpened(true);
|
||||||
setCommandMenuNavigationStack([
|
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 <Story />;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
export const MatchingNavigate: Story = {
|
export const MatchingNavigate: Story = {
|
||||||
play: async () => {
|
play: async () => {
|
||||||
const canvas = within(document.body);
|
const canvas = within(document.body);
|
||||||
|
|||||||
@ -44,6 +44,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
|
|||||||
Icon: IconCheckbox,
|
Icon: IconCheckbox,
|
||||||
position: 300,
|
position: 300,
|
||||||
cards: [{ type: CardType.TaskCard }],
|
cards: [{ type: CardType.TaskCard }],
|
||||||
|
targetObjectNameSingular: CoreObjectNameSingular.Task,
|
||||||
hide: {
|
hide: {
|
||||||
ifMobile: false,
|
ifMobile: false,
|
||||||
ifDesktop: false,
|
ifDesktop: false,
|
||||||
@ -51,6 +52,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
|
|||||||
ifFeaturesDisabled: [],
|
ifFeaturesDisabled: [],
|
||||||
ifRequiredObjectsInactive: [CoreObjectNameSingular.Task],
|
ifRequiredObjectsInactive: [CoreObjectNameSingular.Task],
|
||||||
ifRelationsMissing: ['taskTargets'],
|
ifRelationsMissing: ['taskTargets'],
|
||||||
|
ifNoReadPermission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
notes: {
|
notes: {
|
||||||
@ -58,6 +60,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
|
|||||||
Icon: IconNotes,
|
Icon: IconNotes,
|
||||||
position: 400,
|
position: 400,
|
||||||
cards: [{ type: CardType.NoteCard }],
|
cards: [{ type: CardType.NoteCard }],
|
||||||
|
targetObjectNameSingular: CoreObjectNameSingular.Note,
|
||||||
hide: {
|
hide: {
|
||||||
ifMobile: false,
|
ifMobile: false,
|
||||||
ifDesktop: false,
|
ifDesktop: false,
|
||||||
@ -65,6 +68,7 @@ export const BASE_RECORD_LAYOUT: RecordLayout = {
|
|||||||
ifFeaturesDisabled: [],
|
ifFeaturesDisabled: [],
|
||||||
ifRequiredObjectsInactive: [CoreObjectNameSingular.Note],
|
ifRequiredObjectsInactive: [CoreObjectNameSingular.Note],
|
||||||
ifRelationsMissing: ['noteTargets'],
|
ifRelationsMissing: ['noteTargets'],
|
||||||
|
ifNoReadPermission: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
|||||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||||
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
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 { BASE_RECORD_LAYOUT } from '@/object-record/record-show/constants/BaseRecordLayout';
|
||||||
import { CardType } from '@/object-record/record-show/types/CardType';
|
import { CardType } from '@/object-record/record-show/types/CardType';
|
||||||
import { RecordLayout } from '@/object-record/record-show/types/RecordLayout';
|
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 { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import {
|
import {
|
||||||
IconCalendarEvent,
|
IconCalendarEvent,
|
||||||
IconHome,
|
IconHome,
|
||||||
@ -30,6 +32,7 @@ export const useRecordShowContainerTabs = (
|
|||||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||||
|
|
||||||
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
const currentWorkspace = useRecoilValue(currentWorkspaceState);
|
||||||
|
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
||||||
|
|
||||||
// Object-specific layouts that override or extend the base layout
|
// Object-specific layouts that override or extend the base layout
|
||||||
const OBJECT_SPECIFIC_LAYOUTS: Partial<
|
const OBJECT_SPECIFIC_LAYOUTS: Partial<
|
||||||
@ -212,17 +215,19 @@ export const useRecordShowContainerTabs = (
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const baseRecordLayout = BASE_RECORD_LAYOUT;
|
||||||
|
|
||||||
// Merge base layout with object-specific layout
|
// Merge base layout with object-specific layout
|
||||||
const recordLayout: RecordLayout = useMemo(() => {
|
const recordLayout: RecordLayout = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
...BASE_RECORD_LAYOUT,
|
...baseRecordLayout,
|
||||||
...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular] || {}),
|
...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular] || {}),
|
||||||
tabs: {
|
tabs: {
|
||||||
...BASE_RECORD_LAYOUT.tabs,
|
...baseRecordLayout.tabs,
|
||||||
...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular]?.tabs || {}),
|
...(OBJECT_SPECIFIC_LAYOUTS[targetObjectNameSingular]?.tabs || {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [OBJECT_SPECIFIC_LAYOUTS, targetObjectNameSingular]);
|
}, [OBJECT_SPECIFIC_LAYOUTS, baseRecordLayout, targetObjectNameSingular]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
layout: recordLayout,
|
layout: recordLayout,
|
||||||
@ -232,7 +237,7 @@ export const useRecordShowContainerTabs = (
|
|||||||
entry[1] !== null && entry[1] !== undefined,
|
entry[1] !== null && entry[1] !== undefined,
|
||||||
)
|
)
|
||||||
.sort(([, a], [, b]) => a.position - b.position)
|
.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
|
// Special handling for fields tab
|
||||||
if (key === 'fields') {
|
if (key === 'fields') {
|
||||||
return {
|
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 =
|
const requiredObjectsInactive =
|
||||||
hide.ifRequiredObjectsInactive.length > 0 &&
|
hide.ifRequiredObjectsInactive.length > 0 &&
|
||||||
!hide.ifRequiredObjectsInactive.every((obj) =>
|
!hide.ifRequiredObjectsInactive.every((obj) =>
|
||||||
@ -286,7 +301,8 @@ export const useRecordShowContainerTabs = (
|
|||||||
baseHide ||
|
baseHide ||
|
||||||
featureNotEnabled ||
|
featureNotEnabled ||
|
||||||
requiredObjectsInactive ||
|
requiredObjectsInactive ||
|
||||||
relationsDontExist,
|
relationsDontExist ||
|
||||||
|
permissionHide,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
// When isInRightDrawer === true, we merge first and second tab into first tab
|
// When isInRightDrawer === true, we merge first and second tab into first tab
|
||||||
|
|||||||
@ -8,4 +8,5 @@ export type RecordLayoutTab = {
|
|||||||
Icon: IconComponent;
|
Icon: IconComponent;
|
||||||
hide: TabVisibilityConfig;
|
hide: TabVisibilityConfig;
|
||||||
cards: LayoutCard[];
|
cards: LayoutCard[];
|
||||||
|
targetObjectNameSingular?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,4 +8,5 @@ export type TabVisibilityConfig = {
|
|||||||
ifFeaturesDisabled: FeatureFlagKey[];
|
ifFeaturesDisabled: FeatureFlagKey[];
|
||||||
ifRequiredObjectsInactive: CoreObjectNameSingular[];
|
ifRequiredObjectsInactive: CoreObjectNameSingular[];
|
||||||
ifRelationsMissing: string[];
|
ifRelationsMissing: string[];
|
||||||
|
ifNoReadPermission?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
WorkspaceMemberDateFormatEnum,
|
WorkspaceMemberDateFormatEnum,
|
||||||
WorkspaceMemberTimeFormatEnum,
|
WorkspaceMemberTimeFormatEnum,
|
||||||
} from '~/generated/graphql';
|
} from '~/generated/graphql';
|
||||||
|
import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems';
|
||||||
|
|
||||||
type MockedUser = Pick<
|
type MockedUser = Pick<
|
||||||
User,
|
User,
|
||||||
@ -128,12 +129,13 @@ export const mockedUserData: MockedUser = {
|
|||||||
currentWorkspace: mockCurrentWorkspace,
|
currentWorkspace: mockCurrentWorkspace,
|
||||||
currentUserWorkspace: {
|
currentUserWorkspace: {
|
||||||
settingsPermissions: [SettingPermissionType.WORKSPACE_MEMBERS],
|
settingsPermissions: [SettingPermissionType.WORKSPACE_MEMBERS],
|
||||||
objectPermissions: [
|
objectPermissions: generatedMockObjectMetadataItems.map((item) => ({
|
||||||
{
|
objectMetadataId: item.id,
|
||||||
objectMetadataId: '4a45f524-b8cb-40e8-8450-28e402b442cf',
|
canReadObjectRecords: true,
|
||||||
canReadObjectRecords: true,
|
canUpdateObjectRecords: true,
|
||||||
},
|
canSoftDeleteObjectRecords: true,
|
||||||
],
|
canDestroyObjectRecords: true,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
workspaces: [{ workspace: mockCurrentWorkspace }],
|
workspaces: [{ workspace: mockCurrentWorkspace }],
|
||||||
@ -146,6 +148,26 @@ export const mockedUserData: MockedUser = {
|
|||||||
userVars: {},
|
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 = (
|
export const mockedOnboardingUserData = (
|
||||||
onboardingStatus?: OnboardingStatus,
|
onboardingStatus?: OnboardingStatus,
|
||||||
) => {
|
) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user