fix(twenty-front): hide layout editor UI when SystemPermissionFlag.LAYOUTS is missing (#23303) (#23343)

## Description
Fixes #23303.

This PR ensures that the layout editor UI and customization entry points
are hidden and protected when a user lacks the
`SystemPermissionFlag.LAYOUTS` permission flag.

### Changes Made:
1. **`useEnterLayoutCustomizationMode.ts`**: Added
`useHasPermissionFlag(PermissionFlagType.LAYOUTS)` check inside
`enterLayoutCustomizationMode` to return `false` and prevent entering
customization mode if the user lacks permission.
2. **`WorkspaceSection.tsx`**: Updated the sidebar `WorkspaceSection`
component to render the layout edit button (`IconTool`) only when
`hasLayoutsPermission` is `true`.
3. **`ObjectLayout.tsx`**: Disabled customize and reset layout controls
in the Data Model Object Details settings page if the user lacks
`LAYOUTS` permission.
4. **`useEnterLayoutCustomizationMode.test.tsx`**: Added unit tests to
verify that `useEnterLayoutCustomizationMode` correctly guards layout
customization initialization based on permission.

## Testing
- Added unit tests for `useEnterLayoutCustomizationMode` permission
checks.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23343?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Shinu Cherian
2026-07-27 18:27:24 +05:30
committed by GitHub
parent 22a01dc1c6
commit b81ca99162
4 changed files with 107 additions and 17 deletions
@@ -0,0 +1,70 @@
import { renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
jest.mock('@/settings/roles/hooks/useHasPermissionFlag');
jest.mock('@/side-panel/hooks/useNavigateSidePanel', () => ({
useNavigateSidePanel: () => ({
navigateSidePanel: jest.fn(),
}),
}));
jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({
useSnackBar: () => ({
enqueueWarningSnackBar: jest.fn(),
}),
}));
const mockUseHasPermissionFlag = useHasPermissionFlag as jest.Mock;
const getWrapper =
(store = createStore()) =>
({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
describe('useEnterLayoutCustomizationMode', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return false and not enable customization mode when user lacks LAYOUTS permission', () => {
mockUseHasPermissionFlag.mockReturnValue(false);
const store = createStore();
const wrapper = getWrapper(store);
const { result } = renderHook(() => useEnterLayoutCustomizationMode(), {
wrapper,
});
const success = result.current.enterLayoutCustomizationMode();
expect(success).toBe(false);
expect(store.get(isLayoutCustomizationModeEnabledState.atom)).toBe(false);
});
it('should return true and enable customization mode when user has LAYOUTS permission', () => {
mockUseHasPermissionFlag.mockReturnValue(true);
const store = createStore();
const wrapper = getWrapper(store);
store.set(metadataStoreState.atomFamily('navigationMenuItems'), {
current: [],
draft: [],
status: 'up-to-date',
});
const { result } = renderHook(() => useEnterLayoutCustomizationMode(), {
wrapper,
});
const success = result.current.enterLayoutCustomizationMode();
expect(success).toBe(true);
expect(store.get(isLayoutCustomizationModeEnabledState.atom)).toBe(true);
});
});
@@ -15,17 +15,25 @@ import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/state
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { isDashboardInEditModeComponentState } from '@/page-layout/states/isDashboardInEditModeComponentState';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { PermissionFlagType } from '~/generated-metadata/graphql';
export const useEnterLayoutCustomizationMode = () => {
const store = useStore();
const { navigateSidePanel } = useNavigateSidePanel();
const { enqueueWarningSnackBar } = useSnackBar();
const hasLayoutsPermission = useHasPermissionFlag(PermissionFlagType.LAYOUTS);
const enterLayoutCustomizationMode = useCallback((): boolean => {
if (!hasLayoutsPermission) {
return false;
}
const isLayoutCustomizationModeAlreadyEnabled = store.get(
isLayoutCustomizationModeEnabledState.atom,
);
@@ -86,7 +94,7 @@ export const useEnterLayoutCustomizationMode = () => {
}
return true;
}, [enqueueWarningSnackBar, navigateSidePanel, store]);
}, [enqueueWarningSnackBar, hasLayoutsPermission, navigateSidePanel, store]);
return { enterLayoutCustomizationMode };
};
@@ -2,6 +2,7 @@ import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useNavigate } from 'react-router-dom';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
import {
IconColumnInsertRight,
IconLink,
@@ -27,15 +28,17 @@ import { WorkspaceSectionContainer } from '@/navigation-menu-item/display/sectio
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { useOpenNavigationMenuItemInSidePanel } from '@/navigation-menu-item/edit/hooks/useOpenNavigationMenuItemInSidePanel';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
import { NavigationMenuItemType, SidePanelPages } from 'twenty-shared/types';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { PermissionFlagType } from '~/generated-metadata/graphql';
const StyledRightIconsContainer = styled.div`
align-items: center;
@@ -52,6 +55,7 @@ export const WorkspaceSection = () => {
lastVisitedViewPerObjectMetadataItemState,
);
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
const hasLayoutsPermission = useHasPermissionFlag(PermissionFlagType.LAYOUTS);
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
@@ -169,7 +173,9 @@ export const WorkspaceSection = () => {
objectMetadataItem: EnrichedObjectMetadataItem,
navigationMenuItemId: string,
) => {
enterLayoutCustomizationMode();
if (!enterLayoutCustomizationMode()) {
return;
}
setSelectedNavigationMenuItemIdInEditMode(navigationMenuItemId);
openNavigationMenuItemInSidePanel({
pageTitle: objectMetadataItem.labelSingular,
@@ -201,14 +207,16 @@ export const WorkspaceSection = () => {
onClick={handleAddMenuItem}
/>
) : (
<div onMouseEnter={preloadNavigationMenuItemDndKit}>
<LightIconButton
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleEditClick}
/>
</div>
hasLayoutsPermission && (
<div onMouseEnter={preloadNavigationMenuItemDndKit}>
<LightIconButton
Icon={IconTool}
accent="tertiary"
size="small"
onClick={handleEditClick}
/>
</div>
)
)}
</StyledRightIconsContainer>
}
@@ -5,10 +5,10 @@ import { useLingui } from '@lingui/react/macro';
import { AppPath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLayoutDashboard, IconReload } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { H2Title } from 'twenty-ui/typography';
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
@@ -16,9 +16,12 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useResetPageLayoutToDefault } from '@/page-layout/hooks/useResetPageLayoutToDefault';
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
import { SettingsCard } from '@/settings/components/SettingsCard';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { PermissionFlagType } from '~/generated-metadata/graphql';
import { useNavigateApp } from '~/hooks/useNavigateApp';
const StyledContentContainer = styled.div`
@@ -38,6 +41,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
const { t } = useLingui();
const navigateApp = useNavigateApp();
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
const hasLayoutsPermission = useHasPermissionFlag(PermissionFlagType.LAYOUTS);
const { openModal } = useModal();
const { resetPageLayoutToDefault } = useResetPageLayoutToDefault();
@@ -55,7 +59,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
const firstRecord = records[0];
const handleCustomizeRecordPage = () => {
if (!isDefined(firstRecord)) {
if (!hasLayoutsPermission || !isDefined(firstRecord)) {
return;
}
@@ -74,7 +78,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
};
const handleConfirmReset = async () => {
if (!isDefined(pageLayout)) {
if (!hasLayoutsPermission || !isDefined(pageLayout)) {
return;
}
@@ -94,7 +98,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
title={t`Customize record page`}
Icon={<IconLayoutDashboard size={theme.icon.size.md} />}
onClick={handleCustomizeRecordPage}
disabled={!isDefined(firstRecord)}
disabled={!hasLayoutsPermission || !isDefined(firstRecord)}
/>
</Section>
<Section>
@@ -108,7 +112,7 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
size="small"
Icon={IconReload}
onClick={handleResetPageLayout}
disabled={!isDefined(pageLayout)}
disabled={!hasLayoutsPermission || !isDefined(pageLayout)}
/>
</Section>
<ConfirmationModal