Enhance role-check system with stricter checks (#15392)
## Overview This PR strengthens our permission system by introducing more granular role-based access control across the platform. ## Changes ### New Permissions Added - **Applications** - Control who can install and manage applications - **Layouts** - Control who can customize page layouts and UI structure - **AI** - Control access to AI features and agents - **Upload File** - Separate permission for file uploads - **Download File** - Separate permission for file downloads (frontend visibility) ### Security Enhancements - Implemented whitelist-based validation for workspace field updates - Added explicit permission guards to core entity resolvers - Enhanced ESLint rule to enforce permission checks on all mutations - Created `CustomPermissionGuard` and `NoPermissionGuard` for better code documentation ### Affected Components - Core entity resolvers: webhooks, files, domains, applications, layouts, postgres credentials - Workspace update mutations now use whitelist validation - Settings UI updated with new permission controls ### Developer Experience - ESLint now catches missing permission guards during development - Explicit guard markers make permission requirements clear in code review - Comprehensive test coverage for new permission logic ## Testing - ✅ All TypeScript type checks pass - ✅ ESLint validation passes - ✅ New permission guards properly enforced - ✅ Frontend UI displays new permissions correctly ## Migration Notes Existing workspaces will need to assign the new permissions to roles as needed. By default, all new permissions are set to `false` for non-admin roles.
This commit is contained in:
+3
-2
@@ -8,6 +8,7 @@ import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUp
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useSetRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentFamilyState';
|
||||
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
|
||||
@@ -86,8 +87,8 @@ export const AdvancedFilterCommandMenuCreateRootFilterButton = ({
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
onClick={addRootRecordFilterGroup}
|
||||
ariaLabel="Add filter"
|
||||
title="Add filter"
|
||||
ariaLabel={t`Add filter`}
|
||||
title={t`Add filter`}
|
||||
disabled={readonly}
|
||||
/>
|
||||
);
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ import { ObjectOptionsDropdownMenuContent } from '@/object-record/object-options
|
||||
import { ObjectOptionsDropdownRecordGroupFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupFieldsContent';
|
||||
import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupsContent';
|
||||
import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent';
|
||||
import { ObjectOptionsDropdownVisibilityContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownVisibilityContent';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
|
||||
export const ObjectOptionsDropdownContent = () => {
|
||||
@@ -35,6 +36,8 @@ export const ObjectOptionsDropdownContent = () => {
|
||||
return <ObjectOptionsDropdownCalendarViewContent />;
|
||||
case 'calendarFields':
|
||||
return <ObjectOptionsDropdownCalendarFieldsContent />;
|
||||
case 'visibility':
|
||||
return <ObjectOptionsDropdownVisibilityContent />;
|
||||
default:
|
||||
return <ObjectOptionsDropdownMenuContent />;
|
||||
}
|
||||
|
||||
+22
-39
@@ -4,10 +4,10 @@ import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdow
|
||||
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
|
||||
import { recordGroupFieldMetadataComponentState } from '@/object-record/record-group/states/recordGroupFieldMetadataComponentState';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
@@ -18,7 +18,6 @@ import { ViewKey } from '@/views/types/ViewKey';
|
||||
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
|
||||
import { useDeleteViewFromCurrentState } from '@/views/view-picker/hooks/useDeleteViewFromCurrentState';
|
||||
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
@@ -26,9 +25,9 @@ import {
|
||||
AppTooltip,
|
||||
IconCalendar,
|
||||
IconCalendarWeek,
|
||||
IconCopy,
|
||||
IconLayoutList,
|
||||
IconListDetails,
|
||||
IconShare,
|
||||
IconTrash,
|
||||
} from 'twenty-ui/display';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
@@ -92,17 +91,14 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
onBackToDefault?.();
|
||||
};
|
||||
|
||||
const theme = useTheme();
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
|
||||
const selectableItemIdArray = [
|
||||
'Layout',
|
||||
'Visibility',
|
||||
'Fields',
|
||||
...(customViewData?.type === ViewType.Calendar
|
||||
? ['CalendarDateField', 'CalendarView']
|
||||
: []),
|
||||
...(customViewData?.type !== ViewType.Calendar ? ['Group'] : []),
|
||||
'Copy link to view',
|
||||
'Delete view',
|
||||
];
|
||||
|
||||
@@ -116,7 +112,7 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownContent>
|
||||
<DropdownContent widthInPixels={GenericDropdownContentWidth.Large}>
|
||||
<ObjectOptionsDropdownMenuViewName currentView={customViewData} />
|
||||
<DropdownMenuSeparator />
|
||||
<SelectableList
|
||||
@@ -141,6 +137,24 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
hasSubMenu
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId="Visibility"
|
||||
onEnter={() => onContentChange('visibility')}
|
||||
>
|
||||
<MenuItem
|
||||
focused={selectedItemId === 'Visibility'}
|
||||
onClick={() => onContentChange('visibility')}
|
||||
LeftIcon={IconShare}
|
||||
text={t`Visibility`}
|
||||
contextualText={
|
||||
customViewData?.visibility === 'UNLISTED'
|
||||
? t`Unlisted`
|
||||
: t`Workspace`
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
hasSubMenu
|
||||
/>
|
||||
</SelectableListItem>
|
||||
</DropdownMenuItemsContainer>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
@@ -245,37 +259,6 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
</DropdownMenuItemsContainer>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer scrollable={false}>
|
||||
<SelectableListItem
|
||||
itemId="Copy link to view"
|
||||
onEnter={() => {
|
||||
const currentUrl = window.location.href;
|
||||
navigator.clipboard.writeText(currentUrl);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Link copied to clipboard`,
|
||||
options: {
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
focused={selectedItemId === 'Copy link to view'}
|
||||
onClick={() => {
|
||||
const currentUrl = window.location.href;
|
||||
navigator.clipboard.writeText(currentUrl);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Link copied to clipboard`,
|
||||
options: {
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
}}
|
||||
LeftIcon={IconCopy}
|
||||
text={t`Copy link to view`}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<div id="delete-view-menu-item">
|
||||
<SelectableListItem
|
||||
itemId="Delete view"
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
||||
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
|
||||
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { createPortal } from 'react-dom';
|
||||
import {
|
||||
AppTooltip,
|
||||
IconChevronLeft,
|
||||
IconCircle,
|
||||
IconCircleDashed,
|
||||
IconCopy,
|
||||
} from 'twenty-ui/display';
|
||||
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { ViewVisibility } from '~/generated-metadata/graphql';
|
||||
import { PermissionFlagType } from '~/generated/graphql';
|
||||
|
||||
export const ObjectOptionsDropdownVisibilityContent = () => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const { resetContent } = useObjectOptionsDropdown();
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
const { updateCurrentView } = useUpdateCurrentView();
|
||||
const { enqueueSuccessSnackBar } = useSnackBar();
|
||||
const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
|
||||
const { canPersistChanges } = useCanPersistViewChanges();
|
||||
|
||||
const selectedItemId = useRecoilComponentValue(
|
||||
selectedItemIdComponentState,
|
||||
OBJECT_OPTIONS_DROPDOWN_ID,
|
||||
);
|
||||
|
||||
const selectableItemIdArray = [
|
||||
ViewVisibility.WORKSPACE,
|
||||
ViewVisibility.UNLISTED,
|
||||
];
|
||||
|
||||
const handleVisibilityChange = (visibility: ViewVisibility) => {
|
||||
if (!canPersistChanges || !currentView) return;
|
||||
updateCurrentView({
|
||||
visibility,
|
||||
});
|
||||
resetContent();
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const currentUrl = window.location.href;
|
||||
navigator.clipboard.writeText(currentUrl);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Link copied to clipboard`,
|
||||
options: {
|
||||
icon: <IconCopy size={theme.icon.size.md} />,
|
||||
duration: 2000,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const currentVisibility = currentView?.visibility ?? ViewVisibility.WORKSPACE;
|
||||
|
||||
return (
|
||||
<DropdownContent widthInPixels={GenericDropdownContentWidth.Large}>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={resetContent}
|
||||
Icon={IconChevronLeft}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t`Visibility`}
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuItemsContainer>
|
||||
<SelectableList
|
||||
selectableListInstanceId={OBJECT_OPTIONS_DROPDOWN_ID}
|
||||
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
|
||||
selectableItemIdArray={selectableItemIdArray}
|
||||
>
|
||||
<SelectableListItem
|
||||
itemId={ViewVisibility.WORKSPACE}
|
||||
onEnter={() =>
|
||||
hasViewsPermission &&
|
||||
canPersistChanges &&
|
||||
handleVisibilityChange(ViewVisibility.WORKSPACE)
|
||||
}
|
||||
>
|
||||
<>
|
||||
<div id="workspace-visibility-option">
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconCircle}
|
||||
text={t`Workspace`}
|
||||
contextualText={t`Everyone`}
|
||||
selected={currentVisibility === ViewVisibility.WORKSPACE}
|
||||
focused={selectedItemId === ViewVisibility.WORKSPACE}
|
||||
onClick={() =>
|
||||
handleVisibilityChange(ViewVisibility.WORKSPACE)
|
||||
}
|
||||
disabled={!hasViewsPermission || !canPersistChanges}
|
||||
/>
|
||||
</div>
|
||||
{!hasViewsPermission &&
|
||||
createPortal(
|
||||
<AppTooltip
|
||||
anchorSelect="#workspace-visibility-option"
|
||||
content={t`Workspace views require manage views permission`}
|
||||
positionStrategy="fixed"
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
itemId={ViewVisibility.UNLISTED}
|
||||
onEnter={() =>
|
||||
canPersistChanges &&
|
||||
handleVisibilityChange(ViewVisibility.UNLISTED)
|
||||
}
|
||||
>
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconCircleDashed}
|
||||
text={t`Unlisted`}
|
||||
contextualText={t`Visible to you`}
|
||||
selected={currentVisibility === ViewVisibility.UNLISTED}
|
||||
focused={selectedItemId === ViewVisibility.UNLISTED}
|
||||
onClick={() => handleVisibilityChange(ViewVisibility.UNLISTED)}
|
||||
disabled={!canPersistChanges}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
{currentVisibility === ViewVisibility.WORKSPACE && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<SelectableListItem
|
||||
itemId="Copy view link"
|
||||
onEnter={handleCopyLink}
|
||||
>
|
||||
<MenuItem
|
||||
focused={selectedItemId === 'Copy view link'}
|
||||
onClick={handleCopyLink}
|
||||
LeftIcon={IconCopy}
|
||||
text={t`Copy view link`}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
</>
|
||||
)}
|
||||
</SelectableList>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
);
|
||||
};
|
||||
+2
-1
@@ -8,4 +8,5 @@ export type ObjectOptionsContentId =
|
||||
| 'recordGroupFields'
|
||||
| 'recordGroupSort'
|
||||
| 'calendarFields'
|
||||
| 'calendarView';
|
||||
| 'calendarView'
|
||||
| 'visibility';
|
||||
|
||||
+3
-3
@@ -44,7 +44,7 @@ export const AggregateDropdownContent = () => {
|
||||
return (
|
||||
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
|
||||
availableAggregations={availableAggregations}
|
||||
title="Count"
|
||||
title={t`Count`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export const AggregateDropdownContent = () => {
|
||||
return (
|
||||
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
|
||||
availableAggregations={availableAggregations}
|
||||
title="Percent"
|
||||
title={t`Percent`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export const AggregateDropdownContent = () => {
|
||||
return (
|
||||
<RecordBoardColumnHeaderAggregateDropdownOptionsContent
|
||||
availableAggregations={availableAggregationsWithoutDates}
|
||||
title="More options"
|
||||
title={t`More options`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { addMonths, format, subMonths } from 'date-fns';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
@@ -144,7 +145,7 @@ export const RecordCalendarTopBar = () => {
|
||||
<Button
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
title="Today"
|
||||
title={t`Today`}
|
||||
onClick={handleTodayClick}
|
||||
/>
|
||||
<StyledNavigationButton
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import { RecordDetailRecordsListContainer } from '@/object-record/record-field-l
|
||||
import { RecordDetailRecordsListItemContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailRecordsListItemContainer';
|
||||
import { RecordDetailSectionContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailSectionContainer';
|
||||
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconArrowMerge } from 'twenty-ui/display';
|
||||
import { LightIconButton } from 'twenty-ui/input';
|
||||
@@ -44,7 +45,7 @@ export const RecordDetailDuplicatesSection = ({
|
||||
|
||||
return (
|
||||
<RecordDetailSectionContainer
|
||||
title="Duplicates"
|
||||
title={t`Duplicates`}
|
||||
rightAdornment={
|
||||
<LightIconButton
|
||||
className="displayOnHover"
|
||||
|
||||
+4
-3
@@ -2,6 +2,7 @@ import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/reco
|
||||
import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState';
|
||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
@@ -21,10 +22,10 @@ export const RecordGroupReorderConfirmationModal = ({
|
||||
{createPortal(
|
||||
<ConfirmationModal
|
||||
modalId={RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID}
|
||||
title="Group sorting"
|
||||
subtitle={`Would you like to remove ${recordGroupSort} group sorting?`}
|
||||
title={t`Group sorting`}
|
||||
subtitle={t`Would you like to remove ${recordGroupSort} group sorting?`}
|
||||
onConfirmClick={onConfirmClick}
|
||||
confirmButtonText="Remove"
|
||||
confirmButtonText={t`Remove`}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user