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:
+70
@@ -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);
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -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 };
|
||||
};
|
||||
|
||||
+19
-11
@@ -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>
|
||||
}
|
||||
|
||||
+9
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user