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:
parent
900df537e3
commit
b5f0d3e86a
@ -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>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
|
@ -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>
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -90,6 +90,7 @@ export type UiFlags = {
|
||||
showUserDeviceCount?: boolean;
|
||||
flagOverviewRedesign?: boolean;
|
||||
granularAdminPermissions?: boolean;
|
||||
sortProjectRoles?: boolean;
|
||||
};
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -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 = {
|
||||
|
@ -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],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
@ -54,6 +54,7 @@ process.nextTick(async () => {
|
||||
showUserDeviceCount: true,
|
||||
flagOverviewRedesign: false,
|
||||
granularAdminPermissions: true,
|
||||
sortProjectRoles: true,
|
||||
deltaApi: true,
|
||||
uniqueSdkTracking: true,
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user