1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-28 00:17:12 +01:00

refactor: project permissions list (#9082)

Re-organized project permissions.
This commit is contained in:
Tymoteusz Czech 2025-01-14 14:24:25 +01:00 committed by GitHub
parent 900df537e3
commit b5f0d3e86a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 382 additions and 40 deletions

View File

@ -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 }) => (
<RolePermissionCategory
<RolePermissionCategoryAccordion
key={label}
title={label}
context={label.toLowerCase()}
@ -116,11 +119,22 @@ export const RolePermissionCategories = ({
}
permissions={permissions}
checkedPermissions={checkedPermissions}
onPermissionChange={(permission: IPermission) =>
onPermissionChange(permission)
}
onCheckAll={() => onCheckAll(permissions)}
/>
>
{type === 'project' && sortProjectRoles ? (
<RolePermissionProject
permissions={permissions}
checkedPermissions={checkedPermissions}
onPermissionChange={onPermissionChange}
/>
) : (
<RolePermissionEnvironment
permissions={permissions}
checkedPermissions={checkedPermissions}
onPermissionChange={onPermissionChange}
/>
)}
</RolePermissionCategoryAccordion>
))}
</>
),

View File

@ -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
</Button>
<Box
display='grid'
gridTemplateColumns={{
sm: '1fr 1fr',
xs: '1fr',
}}
>
{permissions?.map((permission: IPermission) => (
<FormControlLabel
data-testid={getRoleKey(permission)}
key={getRoleKey(permission)}
control={
<Checkbox
checked={Boolean(
checkedPermissions[
getRoleKey(permission)
],
)}
onChange={() =>
onPermissionChange(permission)
}
color='primary'
/>
}
label={permission.displayName}
/>
))}
</Box>
{children}
</AccordionDetails>
</Accordion>
</Box>

View File

@ -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 (
<Box
display='grid'
gridTemplateColumns={{
sm: '1fr 1fr',
xs: '1fr',
}}
>
{permissions?.map((permission: IPermission) => (
<FormControlLabel
data-testid={getRoleKey(permission)}
key={getRoleKey(permission)}
control={
<Checkbox
checked={Boolean(
checkedPermissions[getRoleKey(permission)],
)}
onChange={() => onPermissionChange(permission)}
color='primary'
/>
}
label={permission.displayName}
/>
))}
</Box>
);
};

View File

@ -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 (
<StyledGrid>
{permissionsStructure.map((category) => (
<div>
<StyledSectionTitle>{category.label}</StyledSectionTitle>
<div>
{category.permissions.map(
([permission, parentPermission]) => (
<RolePermissionProjectItem
permission={permission}
onChange={() =>
onPermissionChange(permission)
}
isChecked={Boolean(
checkedPermissions[permission.name],
)}
hasParentPermission={Boolean(
parentPermission,
)}
isParentPermissionChecked={Boolean(
parentPermission &&
checkedPermissions[
parentPermission
],
)}
/>
),
)}
</div>
</div>
))}
</StyledGrid>
);
};

View File

@ -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) => (
<Box
sx={(theme) => ({
marginLeft: hasParentPermission ? theme.spacing(1.5) : 0,
})}
>
<FormControlLabel
data-testid={permission}
key={permission.name}
control={
<Checkbox
checked={Boolean(isChecked || isParentPermissionChecked)}
onChange={onChange}
color='primary'
disabled={isParentPermissionChecked}
/>
}
label={<StyledLabel>{permission.displayName}</StyledLabel>}
/>
</Box>
);

View File

@ -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',
]);
});
});

View File

@ -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;
};

View File

@ -90,6 +90,7 @@ export type UiFlags = {
showUserDeviceCount?: boolean;
flagOverviewRedesign?: boolean;
granularAdminPermissions?: boolean;
sortProjectRoles?: boolean;
};
export interface IVersionInfo {

View File

@ -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 = {

View File

@ -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],
],
},
];

View File

@ -54,6 +54,7 @@ process.nextTick(async () => {
showUserDeviceCount: true,
flagOverviewRedesign: false,
granularAdminPermissions: true,
sortProjectRoles: true,
deltaApi: true,
uniqueSdkTracking: true,
},