From b5f0d3e86aaaa905b5f524bb475fe8e71f20a0aa Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 14 Jan 2025 14:24:25 +0100 Subject: [PATCH] refactor: project permissions list (#9082) Re-organized project permissions. --- .../RolePermissionCategories.tsx | 26 ++++-- ...sx => RolePermissionCategoryAccordion.tsx} | 38 +------- .../RolePermissionEnvironment.tsx | 42 +++++++++ .../RolePermissionProject.tsx | 71 ++++++++++++++ .../RolePermissionProjectItem.tsx | 49 ++++++++++ .../createProjectPermissionsStructure.test.ts | 93 +++++++++++++++++++ .../createProjectPermissionsStructure.ts | 41 ++++++++ frontend/src/interfaces/uiConfig.ts | 1 + src/lib/types/experimental.ts | 7 +- src/lib/types/permissions.ts | 53 +++++++++++ src/server-dev.ts | 1 + 11 files changed, 382 insertions(+), 40 deletions(-) rename frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/{RolePermissionCategory.tsx => RolePermissionCategoryAccordion.tsx} (74%) create mode 100644 frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionEnvironment.tsx create mode 100644 frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProject.tsx create mode 100644 frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProjectItem.tsx create mode 100644 frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.test.ts create mode 100644 frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.ts diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategories.tsx b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategories.tsx index e261f8d8fb..3aa6cd7d76 100644 --- a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategories.tsx +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategories.tsx @@ -16,9 +16,11 @@ import { toggleAllPermissions, togglePermission, } from 'utils/permissions'; -import { RolePermissionCategory } from './RolePermissionCategory'; +import { RolePermissionEnvironment } from './RolePermissionEnvironment'; import { useMemo } from 'react'; import { useUiFlag } from 'hooks/useUiFlag'; +import { RolePermissionProject } from './RolePermissionProject'; +import { RolePermissionCategoryAccordion } from './RolePermissionCategoryAccordion'; interface IPermissionCategoriesProps { type: PredefinedRoleType; @@ -45,6 +47,7 @@ export const RolePermissionCategories = ({ const granularAdminPermissionsEnabled = useUiFlag( 'granularAdminPermissions', ); + const sortProjectRoles = useUiFlag('sortProjectRoles'); const isProjectRole = PROJECT_ROLE_TYPES.includes(type); @@ -95,7 +98,7 @@ export const RolePermissionCategories = ({ label !== 'Authentication'), ) .map(({ label, type, permissions }) => ( - - onPermissionChange(permission) - } onCheckAll={() => onCheckAll(permissions)} - /> + > + {type === 'project' && sortProjectRoles ? ( + + ) : ( + + )} + ))} ), diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategory.tsx b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategoryAccordion.tsx similarity index 74% rename from frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategory.tsx rename to frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategoryAccordion.tsx index cf024eb6ea..25150b0d30 100644 --- a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategory.tsx +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionCategoryAccordion.tsx @@ -5,9 +5,7 @@ import { AccordionSummary, Box, Button, - Checkbox, Divider, - FormControlLabel, IconButton, styled, Typography, @@ -24,8 +22,8 @@ interface IEnvironmentPermissionAccordionProps { Icon: ReactNode; isInitiallyExpanded?: boolean; context: string; - onPermissionChange: (permission: IPermission) => void; onCheckAll: () => void; + children: ReactNode; } const AccordionHeader = styled(Box)(({ theme }) => ({ @@ -42,14 +40,14 @@ const StyledTitle = styled(StringTruncator)(({ theme }) => ({ marginRight: theme.spacing(1), })); -export const RolePermissionCategory = ({ +export const RolePermissionCategoryAccordion = ({ title, permissions, checkedPermissions, Icon, isInitiallyExpanded = false, context, - onPermissionChange, + children, onCheckAll, }: IEnvironmentPermissionAccordionProps) => { const [expanded, setExpanded] = useState(isInitiallyExpanded); @@ -139,34 +137,8 @@ export const RolePermissionCategory = ({ {isAllChecked ? 'Unselect ' : 'Select '} all {context} permissions - - {permissions?.map((permission: IPermission) => ( - - onPermissionChange(permission) - } - color='primary' - /> - } - label={permission.displayName} - /> - ))} - + + {children} diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionEnvironment.tsx b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionEnvironment.tsx new file mode 100644 index 0000000000..7d3a260656 --- /dev/null +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionEnvironment.tsx @@ -0,0 +1,42 @@ +import { Box, Checkbox, FormControlLabel } from '@mui/material'; +import type { ICheckedPermissions, IPermission } from 'interfaces/permissions'; +import { getRoleKey } from 'utils/permissions'; + +interface IEnvironmentPermissionAccordionProps { + permissions: IPermission[]; + checkedPermissions: ICheckedPermissions; + onPermissionChange: (permission: IPermission) => void; +} + +export const RolePermissionEnvironment = ({ + permissions, + checkedPermissions, + onPermissionChange, +}: IEnvironmentPermissionAccordionProps) => { + return ( + + {permissions?.map((permission: IPermission) => ( + onPermissionChange(permission)} + color='primary' + /> + } + label={permission.displayName} + /> + ))} + + ); +}; diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProject.tsx b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProject.tsx new file mode 100644 index 0000000000..9c95cce534 --- /dev/null +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProject.tsx @@ -0,0 +1,71 @@ +import { useMemo } from 'react'; +import { styled, Typography } from '@mui/material'; +import type { ICheckedPermissions, IPermission } from 'interfaces/permissions'; +import { createProjectPermissionsStructure } from './createProjectPermissionsStructure'; +import { RolePermissionProjectItem } from './RolePermissionProjectItem'; + +interface IEnvironmentPermissionAccordionProps { + permissions: IPermission[]; + checkedPermissions: ICheckedPermissions; + onPermissionChange: (permission: IPermission) => void; +} + +const StyledGrid = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(1), + [theme.breakpoints.down('md')]: { + gridTemplateColumns: '1fr', + }, +})); + +const StyledSectionTitle = styled(Typography)(({ theme }) => ({ + fontWeight: theme.typography.fontWeightBold, + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), +})); + +export const RolePermissionProject = ({ + permissions, + checkedPermissions, + onPermissionChange, +}: IEnvironmentPermissionAccordionProps) => { + const permissionsStructure = useMemo( + () => createProjectPermissionsStructure(permissions), + [permissions], + ); + + return ( + + {permissionsStructure.map((category) => ( +
+ {category.label} +
+ {category.permissions.map( + ([permission, parentPermission]) => ( + + onPermissionChange(permission) + } + isChecked={Boolean( + checkedPermissions[permission.name], + )} + hasParentPermission={Boolean( + parentPermission, + )} + isParentPermissionChecked={Boolean( + parentPermission && + checkedPermissions[ + parentPermission + ], + )} + /> + ), + )} +
+
+ ))} +
+ ); +}; diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProjectItem.tsx b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProjectItem.tsx new file mode 100644 index 0000000000..f181898e94 --- /dev/null +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/RolePermissionProjectItem.tsx @@ -0,0 +1,49 @@ +import { + Box, + Checkbox, + FormControlLabel, + styled, + Typography, +} from '@mui/material'; +import type { IPermission } from 'interfaces/permissions'; + +type RolePermissionProjectItemProps = { + permission: IPermission; + onChange: () => void; + isChecked: boolean; + hasParentPermission?: boolean; + isParentPermissionChecked?: boolean; +}; + +const StyledLabel = styled(Typography)(({ theme }) => ({ + lineHeight: 1.2, + marginBottom: theme.spacing(1), +})); + +export const RolePermissionProjectItem = ({ + permission, + onChange, + isChecked, + hasParentPermission, + isParentPermissionChecked, +}: RolePermissionProjectItemProps) => ( + ({ + marginLeft: hasParentPermission ? theme.spacing(1.5) : 0, + })} + > + + } + label={{permission.displayName}} + /> + +); diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.test.ts b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.test.ts new file mode 100644 index 0000000000..d32b5f4d8d --- /dev/null +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { createProjectPermissionsStructure } from './createProjectPermissionsStructure'; + +describe('createProjectPermissionsStructure', () => { + it('returns an empty array when no permissions are given', () => { + const result = createProjectPermissionsStructure([]); + expect(result).toEqual([]); + }); + + it('groups known permissions into existing categories', () => { + const result = createProjectPermissionsStructure([ + { + name: 'CREATE_FEATURE', + displayName: 'Create Feature', + type: 'project', + }, + { + name: 'UPDATE_FEATURE', + displayName: 'Update Feature', + type: 'project', + }, + { + name: 'PROJECT_USER_ACCESS_READ', + displayName: 'Read Project Access', + type: 'project', + }, + { + name: 'SOME_UNKNOWN_PERMISSION', + displayName: 'Unknown Permission', + type: 'project', + }, + ]); + const featuresCategory = result.find( + (cat) => cat.label === 'Features and strategies', + ); + const projectSettingsCategory = result.find( + (cat) => cat.label === 'Project settings', + ); + + expect(featuresCategory?.permissions).toHaveLength(2); + expect(projectSettingsCategory?.permissions).toHaveLength(1); + }); + + it('places unknown permissions into the "Other" category', () => { + const result = createProjectPermissionsStructure([ + { + name: 'SOME_UNKNOWN_PERMISSION', + displayName: 'Unknown Permission', + type: 'project', + }, + ]); + const otherCategory = result.find((cat) => cat.label === 'Other'); + expect(otherCategory).toBeDefined(); + expect(otherCategory?.permissions).toHaveLength(1); + const [[permission]] = otherCategory!.permissions; + expect(permission.name).toBe('SOME_UNKNOWN_PERMISSION'); + }); + + it('omits categories when they have no assigned permissions', () => { + const result = createProjectPermissionsStructure([ + { + name: 'CREATE_FEATURE', + displayName: 'Create Feature', + type: 'project', + }, + { + name: 'UPDATE_FEATURE', + displayName: 'Update Feature', + type: 'project', + }, + ]); + expect(result).toHaveLength(1); + const projectSettingsCategory = result.find( + (cat) => cat.label === 'Project settings', + ); + expect(projectSettingsCategory).toBeUndefined(); + }); + + it('includes parent permission names in the structure', () => { + const result = createProjectPermissionsStructure([ + { + name: 'PROJECT_USER_ACCESS_READ', + displayName: 'Read Project Access', + type: 'project', + }, + ]); + const permissions = result[0].permissions; + expect(permissions[0]).toEqual([ + expect.objectContaining({ name: 'PROJECT_USER_ACCESS_READ' }), + 'UPDATE_PROJECT', + ]); + }); +}); diff --git a/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.ts b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.ts new file mode 100644 index 0000000000..51b9301693 --- /dev/null +++ b/frontend/src/component/admin/roles/RoleForm/RolePermissionCategories/createProjectPermissionsStructure.ts @@ -0,0 +1,41 @@ +import { + type ProjectPermissionCategory, + PROJECT_PERMISSIONS_STRUCTURE, +} from '@server/types/permissions'; +import type { IPermission } from 'interfaces/permissions'; +import { getRoleKey } from 'utils/permissions'; + +export const createProjectPermissionsStructure = ( + permissions: IPermission[], +) => { + const allPermissions = + permissions?.map((permission) => getRoleKey(permission)).sort() || []; + + const allStructuredPermissions = PROJECT_PERMISSIONS_STRUCTURE.flatMap( + (category) => category.permissions.map(([permission]) => permission), + ).sort() as string[]; + + const otherPermissions = allPermissions.filter( + (permission) => !allStructuredPermissions.includes(permission), + ); + + const permissionsStructure = [ + ...PROJECT_PERMISSIONS_STRUCTURE, + { + label: 'Other', + permissions: otherPermissions.map((p) => [p]), + } as ProjectPermissionCategory, + ] + .map((category) => ({ + label: category.label, + permissions: category.permissions + .filter(([permission]) => allPermissions.includes(permission)) + .map(([permission, parentPermission]) => [ + permissions.find((p) => getRoleKey(p) === permission), + parentPermission, + ]) as [IPermission, string?][], + })) + .filter((category) => category.permissions.length > 0); + + return permissionsStructure; +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 792d7aef1c..6ea4a1f280 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -90,6 +90,7 @@ export type UiFlags = { showUserDeviceCount?: boolean; flagOverviewRedesign?: boolean; granularAdminPermissions?: boolean; + sortProjectRoles?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 66245f97e9..6f544cd088 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -60,7 +60,8 @@ export type IFlagKey = | 'streaming' | 'etagVariant' | 'deltaApi' - | 'uniqueSdkTracking'; + | 'uniqueSdkTracking' + | 'sortProjectRoles'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -285,6 +286,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_UNIQUE_SDK_TRACKING, false, ), + sortProjectRoles: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_SORT_PROJECT_ROLES, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/lib/types/permissions.ts b/src/lib/types/permissions.ts index bbf07539ea..09800e1e79 100644 --- a/src/lib/types/permissions.ts +++ b/src/lib/types/permissions.ts @@ -171,3 +171,56 @@ export const MAINTENANCE_MODE_PERMISSIONS = [ UPDATE_MAINTENANCE_MODE, READ_LOGS, ]; + +export type ProjectPermissionCategory = { + label: string; + permissions: Array<[string, string?]>; // [permission, is subset of] +}; + +export const PROJECT_PERMISSIONS_STRUCTURE: ProjectPermissionCategory[] = [ + { + label: 'Features and strategies', + permissions: [ + [CREATE_FEATURE], + [UPDATE_FEATURE], + [UPDATE_FEATURE_DEPENDENCY], + [DELETE_FEATURE], + [UPDATE_FEATURE_VARIANTS], + [MOVE_FEATURE_TOGGLE], + [CREATE_FEATURE_STRATEGY], + [UPDATE_FEATURE_STRATEGY], + [DELETE_FEATURE_STRATEGY], + [UPDATE_FEATURE_ENVIRONMENT], + [UPDATE_FEATURE_ENVIRONMENT_VARIANTS], + [UPDATE_PROJECT_SEGMENT], + ], + }, + { + label: 'Project settings', + permissions: [ + [UPDATE_PROJECT], + [PROJECT_USER_ACCESS_READ, UPDATE_PROJECT], + [PROJECT_USER_ACCESS_WRITE, UPDATE_PROJECT], + [PROJECT_DEFAULT_STRATEGY_READ, UPDATE_PROJECT], + [PROJECT_DEFAULT_STRATEGY_WRITE, UPDATE_PROJECT], + [PROJECT_SETTINGS_READ, UPDATE_PROJECT], + [PROJECT_SETTINGS_WRITE, UPDATE_PROJECT], + ], + }, + { + label: 'API tokens', + permissions: [ + [READ_PROJECT_API_TOKEN], + [CREATE_PROJECT_API_TOKEN], + [DELETE_PROJECT_API_TOKEN], + [DELETE_PROJECT], + ], + }, + { + label: 'Change requests', + permissions: [ + [PROJECT_CHANGE_REQUEST_WRITE], + [PROJECT_CHANGE_REQUEST_READ], + ], + }, +]; diff --git a/src/server-dev.ts b/src/server-dev.ts index 1e08a0a007..cccf565f1a 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -54,6 +54,7 @@ process.nextTick(async () => { showUserDeviceCount: true, flagOverviewRedesign: false, granularAdminPermissions: true, + sortProjectRoles: true, deltaApi: true, uniqueSdkTracking: true, },