1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

feat: roles unification (#3999)

https://linear.app/unleash/issue/2-1137/roles-unification-on-the-ui

Root and project roles should be managed in a similar manner, which
means using the same roles route and tab for both.

Additionally, this includes a big revamp to the project roles to align
them more closely with the modern and standardized custom root roles
that were recently developed. They mostly use the same components.

There are still more things we want to improve and unify, but we've left
some of that out of this PR due to PR size concerns.
This commit is contained in:
Nuno Góis 2023-06-19 09:41:40 +01:00 committed by GitHub
parent 60f4ce31f7
commit eb8f16da8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 387 additions and 1104 deletions

View File

@ -13,10 +13,7 @@ import { InstanceAdmin } from './instance-admin/InstanceAdmin';
import { MaintenanceAdmin } from './maintenance';
import AdminMenu from './menu/AdminMenu';
import { Network } from './network/Network';
import CreateProjectRole from './projectRoles/CreateProjectRole/CreateProjectRole';
import EditProjectRole from './projectRoles/EditProjectRole/EditProjectRole';
import { Roles } from './roles/Roles';
import ProjectRoles from './projectRoles/ProjectRoles/ProjectRoles';
import { ServiceAccounts } from './serviceAccounts/ServiceAccounts';
import CreateUser from './users/CreateUser/CreateUser';
import EditUser from './users/EditUser/EditUser';
@ -28,11 +25,6 @@ export const Admin = () => (
<AdminMenu />
<Routes>
<Route path="users" element={<UsersAdmin />} />
<Route path="project-roles/new" element={<CreateProjectRole />} />
<Route
path="project-roles/:id/edit"
element={<EditProjectRole />}
/>
<Route path="api" element={<ApiTokenPage />} />
<Route path="api/create-token" element={<CreateApiToken />} />
<Route path="users/:id/edit" element={<EditUser />} />
@ -46,8 +38,7 @@ export const Admin = () => (
element={<EditGroupContainer />}
/>
<Route path="groups/:groupId" element={<Group />} />
<Route path="roles" element={<Roles />} />
<Route path="project-roles" element={<ProjectRoles />} />
<Route path="roles/*" element={<Roles />} />
<Route path="instance" element={<InstanceAdmin />} />
<Route path="network/*" element={<Network />} />
<Route path="maintenance" element={<MaintenanceAdmin />} />

View File

@ -55,7 +55,7 @@ function AdminMenu() {
}
/>
)}
{flags.customRootRoles && (
{isEnterprise() && (
<Tab
value="roles"
label={
@ -65,16 +65,6 @@ function AdminMenu() {
}
/>
)}
{flags.RE && (
<Tab
value="project-roles"
label={
<CenteredNavLink to="/admin/project-roles">
<span>Project roles</span>
</CenteredNavLink>
}
/>
)}
<Tab
value="api"
label={

View File

@ -1,108 +0,0 @@
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { useNavigate } from 'react-router-dom';
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
import useProjectRoleForm from '../hooks/useProjectRoleForm';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { CreateButton } from 'component/common/CreateButton/CreateButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
const CreateProjectRole = () => {
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const navigate = useNavigate();
const {
roleName,
roleDesc,
permissions,
checkedPermissions,
errors,
setRoleName,
setRoleDesc,
handlePermissionChange,
onToggleAllProjectPermissions: checkAllProjectPermissions,
onToggleAllEnvironmentPermissions: checkAllEnvironmentPermissions,
getProjectRolePayload,
validatePermissions,
validateName,
validateNameUniqueness,
clearErrors,
getRoleKey,
} = useProjectRoleForm();
const { addRole, loading } = useRolesApi();
const onSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
const validName = validateName();
const validPermissions = validatePermissions();
if (validName && validPermissions) {
const payload = getProjectRolePayload();
try {
await addRole(payload);
navigate('/admin/project-roles');
setToastData({
title: 'Project role created',
text: 'Now you can start assigning your project roles to project members.',
confetti: true,
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
const formatApiCode = () => {
return `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/roles' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
};
const onCancel = () => {
navigate(GO_BACK);
};
return (
<FormTemplate
loading={loading}
title="Create project role"
description="A project role can be
customised to limit access
to resources within a project"
documentationLink="https://docs.getunleash.io/reference/rbac#custom-project-roles"
documentationLinkLabel="Project roles documentation"
formatApiCode={formatApiCode}
>
<ProjectRoleForm
errors={errors}
permissions={permissions}
onSubmit={onSubmit}
onCancel={onCancel}
roleName={roleName}
setRoleName={setRoleName}
roleDesc={roleDesc}
setRoleDesc={setRoleDesc}
checkedPermissions={checkedPermissions}
handlePermissionChange={handlePermissionChange}
checkAllProjectPermissions={checkAllProjectPermissions}
checkAllEnvironmentPermissions={checkAllEnvironmentPermissions}
clearErrors={clearErrors}
validateNameUniqueness={validateNameUniqueness}
getRoleKey={getRoleKey}
>
<CreateButton name="role" permission={ADMIN} />
</ProjectRoleForm>
</FormTemplate>
);
};
export default CreateProjectRole;

View File

@ -1,114 +0,0 @@
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { useRole } from 'hooks/api/getters/useRole/useRole';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { useNavigate } from 'react-router-dom';
import useProjectRoleForm from '../hooks/useProjectRoleForm';
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { GO_BACK } from 'constants/navigate';
const EditProjectRole = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const roleId = useRequiredPathParam('id');
const { role, refetch } = useRole(roleId);
const navigate = useNavigate();
const {
roleName,
roleDesc,
permissions,
checkedPermissions,
errors,
setRoleName,
setRoleDesc,
handlePermissionChange,
onToggleAllProjectPermissions,
onToggleAllEnvironmentPermissions,
getProjectRolePayload,
validatePermissions,
validateName,
clearErrors,
getRoleKey,
} = useProjectRoleForm(role?.name, role?.description, role?.permissions);
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/roles/${role?.id}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
};
const { updateRole, loading } = useRolesApi();
const onSubmit = async (e: Event) => {
e.preventDefault();
const payload = getProjectRolePayload();
const validName = validateName();
const validPermissions = validatePermissions();
if (validName && validPermissions) {
try {
await updateRole(+roleId, payload);
refetch();
navigate('/admin/project-roles');
setToastData({
type: 'success',
title: 'Project role updated',
text: 'Your role changes will automatically be applied to the users with this role.',
confetti: true,
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
const onCancel = () => {
navigate(GO_BACK);
};
return (
<FormTemplate
loading={loading}
title="Edit project role"
description="A project role can be
customised to limit access
to resources within a project"
documentationLink="https://docs.getunleash.io/reference/rbac#custom-project-roles"
documentationLinkLabel="Project roles documentation"
formatApiCode={formatApiCode}
>
<ProjectRoleForm
permissions={permissions}
onSubmit={onSubmit}
onCancel={onCancel}
roleName={roleName}
setRoleName={setRoleName}
roleDesc={roleDesc}
setRoleDesc={setRoleDesc}
checkedPermissions={checkedPermissions}
handlePermissionChange={handlePermissionChange}
checkAllProjectPermissions={onToggleAllProjectPermissions}
checkAllEnvironmentPermissions={
onToggleAllEnvironmentPermissions
}
errors={errors}
clearErrors={clearErrors}
getRoleKey={getRoleKey}
>
<UpdateButton permission={ADMIN} />
</ProjectRoleForm>
</FormTemplate>
);
};
export default EditProjectRole;

View File

@ -1,148 +0,0 @@
import React, { Dispatch, FC, ReactNode, SetStateAction } from 'react';
import {
Topic as TopicIcon,
CloudCircle as CloudCircleIcon,
} from '@mui/icons-material';
import { Box, Button, TextField, Typography } from '@mui/material';
import Input from 'component/common/Input/Input';
import { PermissionAccordion } from './PermissionAccordion/PermissionAccordion';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
IPermission,
IProjectEnvironmentPermissions,
IPermissions,
} from 'interfaces/permissions';
import { ICheckedPermission } from '../hooks/useProjectRoleForm';
interface IProjectRoleForm {
roleName: string;
roleDesc: string;
checkedPermissions: ICheckedPermission;
errors: { [key: string]: string };
children: ReactNode;
permissions:
| IPermissions
| {
project: IPermission[];
environments: IProjectEnvironmentPermissions[];
};
setRoleName: Dispatch<SetStateAction<string>>;
setRoleDesc: Dispatch<SetStateAction<string>>;
handlePermissionChange: (permission: IPermission) => void;
checkAllProjectPermissions: () => void;
checkAllEnvironmentPermissions: (envName: string) => void;
onSubmit: (e: any) => void;
onCancel: () => void;
clearErrors: () => void;
validateNameUniqueness?: () => void;
getRoleKey: (permission: { id: number; environment?: string }) => string;
}
const ProjectRoleForm: FC<IProjectRoleForm> = ({
children,
roleName,
roleDesc,
checkedPermissions,
errors,
permissions,
onSubmit,
onCancel,
setRoleName,
setRoleDesc,
handlePermissionChange,
checkAllProjectPermissions,
checkAllEnvironmentPermissions,
validateNameUniqueness,
clearErrors,
getRoleKey,
}: IProjectRoleForm) => {
const { project, environments } = permissions;
return (
<form onSubmit={onSubmit}>
<Box sx={{ maxWidth: '400px' }}>
<Typography sx={{ mb: 1 }}>What is your role name?</Typography>
<Input
label="Role name"
value={roleName}
onChange={e => setRoleName(e.target.value)}
error={Boolean(errors.name)}
errorText={errors.name}
onFocus={() => clearErrors()}
onBlur={validateNameUniqueness}
autoFocus
sx={{ width: '100%', marginBottom: '1rem' }}
/>
<Typography sx={{ mb: 1 }}>What is this role for?</Typography>
<TextField
label="Role description"
variant="outlined"
multiline
maxRows={4}
value={roleDesc}
onChange={e => setRoleDesc(e.target.value)}
sx={{ width: '100%', marginBottom: '1rem' }}
/>
</Box>
<div>
<ConditionallyRender
condition={Boolean(errors.permissions)}
show={
<Typography variant="body2" color="error.main">
You must select at least one permission for a role.
</Typography>
}
/>
</div>
<PermissionAccordion
isInitiallyExpanded
title="Project permissions"
Icon={<TopicIcon color="disabled" sx={{ mr: 1 }} />}
permissions={project}
checkedPermissions={checkedPermissions}
onPermissionChange={(permission: IPermission) =>
handlePermissionChange(permission)
}
onCheckAll={checkAllProjectPermissions}
getRoleKey={getRoleKey}
context="project"
/>
<div>
{environments.map(environment => (
<PermissionAccordion
title={environment.name}
Icon={
<CloudCircleIcon sx={{ mr: 1 }} color="disabled" />
}
permissions={environment.permissions}
key={environment.name}
checkedPermissions={checkedPermissions}
onPermissionChange={(permission: IPermission) =>
handlePermissionChange(permission)
}
onCheckAll={() =>
checkAllEnvironmentPermissions(environment.name)
}
getRoleKey={getRoleKey}
context="environment"
/>
))}
</div>
<Box
sx={{
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
}}
>
{children}
<Button onClick={onCancel} sx={{ marginLeft: 2 }}>
Cancel
</Button>
</Box>
</form>
);
};
export default ProjectRoleForm;

View File

@ -1,74 +0,0 @@
import { Alert, styled } from '@mui/material';
import React from 'react';
import { IProjectRole } from 'interfaces/role';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import Input from 'component/common/Input/Input';
interface IProjectRoleDeleteConfirmProps {
role: IProjectRole;
open: boolean;
setDialogue: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteRole: (id: number) => Promise<void>;
confirmName: string;
setConfirmName: React.Dispatch<React.SetStateAction<string>>;
}
const DeleteParagraph = styled('p')(({ theme }) => ({
marginTop: theme.spacing(3),
}));
const RoleDeleteInput = styled(Input)(({ theme }) => ({
marginTop: theme.spacing(2),
}));
const ProjectRoleDeleteConfirm = ({
role,
open,
setDialogue,
handleDeleteRole,
confirmName,
setConfirmName,
}: IProjectRoleDeleteConfirmProps) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setConfirmName(e.currentTarget.value);
const handleCancel = () => {
setDialogue(false);
setConfirmName('');
};
const formId = 'delete-project-role-confirmation-form';
return (
<Dialogue
title="Are you sure you want to delete this role?"
open={open}
primaryButtonText="Delete project role"
secondaryButtonText="Cancel"
onClick={() => handleDeleteRole(role.id)}
disabledPrimaryButton={role?.name !== confirmName}
onClose={handleCancel}
formId={formId}
>
<Alert severity="error">
Danger. Deleting this role will result in removing all
permissions that are active in this environment across all
feature toggles.
</Alert>
<DeleteParagraph>
In order to delete this role, please enter the name of the role
in the textfield below: <strong>{role?.name}</strong>
</DeleteParagraph>
<form id={formId}>
<RoleDeleteInput
autoFocus
onChange={handleChange}
value={confirmName}
label="Role name"
/>
</form>
</Dialogue>
);
};
export default ProjectRoleDeleteConfirm;

View File

@ -1,270 +0,0 @@
import { useMemo, useState } from 'react';
import {
Table,
SortableTableHeader,
TableBody,
TableCell,
TableRow,
TablePlaceholder,
} from 'component/common/Table';
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
import { IProjectRole } from 'interfaces/role';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import useToast from 'hooks/useToast';
import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm';
import { formatUnknownError } from 'utils/formatUnknownError';
import { Box, Button, useMediaQuery } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { Delete, Edit, SupervisedUserCircle } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { PageContent } from 'component/common/PageContent/PageContent';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { sortTypes } from 'utils/sortTypes';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import theme from 'themes/theme';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { Search } from 'component/common/Search/Search';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
const BUILTIN_ROLE_TYPE = 'project';
const ProjectRoleList = () => {
const navigate = useNavigate();
const { projectRoles: data, refetch, loading } = useRoles();
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const { removeRole } = useRolesApi();
const [currentRole, setCurrentRole] = useState<IProjectRole | null>(null);
const [delDialog, setDelDialog] = useState(false);
const [confirmName, setConfirmName] = useState('');
const { setToastData, setToastApiError } = useToast();
const deleteProjectRole = async () => {
if (!currentRole?.id) return;
try {
await removeRole(currentRole?.id);
refetch();
setToastData({
type: 'success',
title: 'Successfully deleted role',
text: 'Your role is now deleted',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
setDelDialog(false);
setConfirmName('');
};
const columns = useMemo(
() => [
{
id: 'Icon',
Cell: () => (
<IconCell
icon={<SupervisedUserCircle color="disabled" />}
/>
),
disableGlobalFilter: true,
},
{
Header: 'Project role',
accessor: 'name',
},
{
Header: 'Description',
accessor: 'description',
width: '90%',
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({
row: {
original: { id, type, name, description },
},
}: any) => (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<PermissionIconButton
data-loading
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
navigate(`/admin/project-roles/${id}/edit`);
}}
permission={ADMIN}
tooltipProps={{
title:
type === BUILTIN_ROLE_TYPE
? 'You cannot edit role'
: 'Edit role',
}}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
setCurrentRole({
id,
name,
description,
} as IProjectRole);
setDelDialog(true);
}}
permission={ADMIN}
tooltipProps={{
title:
type === BUILTIN_ROLE_TYPE
? 'You cannot remove role'
: 'Remove role',
}}
>
<Delete />
</PermissionIconButton>
</Box>
),
width: 100,
disableGlobalFilter: true,
disableSortBy: true,
},
],
[navigate]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
}),
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
state: { globalFilter },
setGlobalFilter,
setHiddenColumns,
} = useTable(
{
columns: columns as any[], // TODO: fix after `react-table` v8 update
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
defaultColumn: {
Cell: HighlightCell,
},
},
useGlobalFilter,
useSortBy
);
useConditionallyHiddenColumns(
[
{
condition: isExtraSmallScreen,
columns: ['Icon'],
},
],
setHiddenColumns,
columns
);
let count =
data.length < rows.length
? `(${data.length} of ${rows.length})`
: `(${rows.length})`;
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Project roles ${count}`}
actions={
<>
<Search
initialValue={globalFilter}
onChange={setGlobalFilter}
/>
<PageHeader.Divider />
<Button
variant="contained"
color="primary"
onClick={() =>
navigate('/admin/project-roles/new')
}
>
New project role
</Button>
</>
}
/>
}
>
<SearchHighlightProvider value={globalFilter}>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={globalFilter?.length > 0}
show={
<TablePlaceholder>
No project roles found matching &ldquo;
{globalFilter}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No project roles available. Get started by
adding one.
</TablePlaceholder>
}
/>
}
/>
<ProjectRoleDeleteConfirm
role={currentRole!}
open={delDialog}
setDialogue={setDelDialog}
handleDeleteRole={deleteProjectRole}
confirmName={confirmName}
setConfirmName={setConfirmName}
/>
</PageContent>
);
};
export default ProjectRoleList;

View File

@ -1,10 +0,0 @@
import { makeStyles } from 'tss-react/mui';
export const useStyles = makeStyles()(theme => ({
rolesListBody: {
padding: theme.spacing(4),
paddingBottom: '4rem',
minHeight: '50vh',
position: 'relative',
},
}));

View File

@ -1,22 +0,0 @@
import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import ProjectRoleList from './ProjectRoleList/ProjectRoleList';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
const ProjectRoles = () => {
const { hasAccess } = useContext(AccessContext);
return (
<div>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<ProjectRoleList />}
elseShow={<AdminAlert />}
/>
</div>
);
};
export default ProjectRoles;

View File

@ -1,187 +0,0 @@
import { useEffect, useState } from 'react';
import { IPermission } from 'interfaces/permissions';
import cloneDeep from 'lodash.clonedeep';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { formatUnknownError } from 'utils/formatUnknownError';
export interface ICheckedPermission {
[key: string]: IPermission;
}
const getRoleKey = (permission: {
id: number;
environment?: string;
}): string => {
return permission.environment
? `${permission.id}-${permission.environment}`
: `${permission.id}`;
};
const useProjectRoleForm = (
initialRoleName = '',
initialRoleDesc = '',
initialCheckedPermissions: IPermission[] = []
) => {
const { permissions } = usePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const [roleName, setRoleName] = useState(initialRoleName);
const [roleDesc, setRoleDesc] = useState(initialRoleDesc);
const [checkedPermissions, setCheckedPermissions] =
useState<ICheckedPermission>({});
useEffect(() => {
if (initialCheckedPermissions.length > 0) {
setCheckedPermissions(
initialCheckedPermissions?.reduce(
(
acc: { [key: string]: IPermission },
curr: IPermission
) => {
acc[getRoleKey(curr)] = curr;
return acc;
},
{}
)
);
}
}, [initialCheckedPermissions?.length]);
const [errors, setErrors] = useState({});
const { validateRole } = useRolesApi();
useEffect(() => {
setRoleName(initialRoleName);
}, [initialRoleName]);
useEffect(() => {
setRoleDesc(initialRoleDesc);
}, [initialRoleDesc]);
const handlePermissionChange = (permission: IPermission) => {
let checkedPermissionsCopy = cloneDeep(checkedPermissions);
if (checkedPermissionsCopy[getRoleKey(permission)]) {
delete checkedPermissionsCopy[getRoleKey(permission)];
} else {
checkedPermissionsCopy[getRoleKey(permission)] = { ...permission };
}
setCheckedPermissions(checkedPermissionsCopy);
};
const onToggleAllProjectPermissions = () => {
const { project } = permissions;
let checkedPermissionsCopy = cloneDeep(checkedPermissions);
const allChecked = project.every(
(permission: IPermission) =>
checkedPermissionsCopy[getRoleKey(permission)]
);
if (allChecked) {
project.forEach((permission: IPermission) => {
delete checkedPermissionsCopy[getRoleKey(permission)];
});
} else {
project.forEach((permission: IPermission) => {
checkedPermissionsCopy[getRoleKey(permission)] = {
...permission,
};
});
}
setCheckedPermissions(checkedPermissionsCopy);
};
const onToggleAllEnvironmentPermissions = (envName: string) => {
const { environments } = permissions;
const checkedPermissionsCopy = cloneDeep(checkedPermissions);
const env = environments.find(env => env.name === envName);
if (!env) return;
const allChecked = env.permissions.every(
(permission: IPermission) =>
checkedPermissionsCopy[getRoleKey(permission)]
);
if (allChecked) {
env.permissions.forEach((permission: IPermission) => {
delete checkedPermissionsCopy[getRoleKey(permission)];
});
} else {
env.permissions.forEach((permission: IPermission) => {
checkedPermissionsCopy[getRoleKey(permission)] = {
...permission,
};
});
}
setCheckedPermissions(checkedPermissionsCopy);
};
const getProjectRolePayload = () => ({
name: roleName,
description: roleDesc,
permissions: Object.values(checkedPermissions),
});
const validateNameUniqueness = async () => {
const payload = getProjectRolePayload();
try {
await validateRole(payload);
} catch (error: unknown) {
setErrors(prev => ({ ...prev, name: formatUnknownError(error) }));
}
};
const validateName = () => {
if (roleName.length === 0) {
setErrors(prev => ({ ...prev, name: 'Name can not be empty.' }));
return false;
}
return true;
};
const validatePermissions = () => {
if (Object.keys(checkedPermissions).length === 0) {
setErrors(prev => ({
...prev,
permissions: 'You must include at least one permission.',
}));
return false;
}
return true;
};
const clearErrors = () => {
setErrors({});
};
return {
roleName,
roleDesc,
errors,
checkedPermissions,
permissions,
setRoleName,
setRoleDesc,
handlePermissionChange,
onToggleAllProjectPermissions,
onToggleAllEnvironmentPermissions,
getProjectRolePayload,
validatePermissions,
validateName,
clearErrors,
validateNameUniqueness,
getRoleKey,
};
};
export default useProjectRoleForm;

View File

@ -13,20 +13,19 @@ import {
Typography,
} from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import { IPermission } from 'interfaces/permissions';
import { ICheckedPermissions, IPermission } from 'interfaces/permissions';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm';
import { getRoleKey } from 'utils/permissions';
interface IEnvironmentPermissionAccordionProps {
permissions: IPermission[];
checkedPermissions: ICheckedPermission;
checkedPermissions: ICheckedPermissions;
title: string;
Icon: ReactNode;
isInitiallyExpanded?: boolean;
context: string;
onPermissionChange: (permission: IPermission) => void;
onCheckAll: () => void;
getRoleKey?: (permission: { id: number; environment?: string }) => string;
}
const AccordionHeader = styled(Box)(({ theme }) => ({
@ -52,7 +51,6 @@ export const PermissionAccordion: VFC<IEnvironmentPermissionAccordionProps> = ({
context,
onPermissionChange,
onCheckAll,
getRoleKey = permission => permission.id.toString(),
}) => {
const [expanded, setExpanded] = useState(isInitiallyExpanded);
const permissionMap = useMemo(
@ -141,35 +139,32 @@ export const PermissionAccordion: VFC<IEnvironmentPermissionAccordionProps> = ({
all {context} permissions
</Button>
<Box>
{permissions?.map((permission: IPermission) => {
return (
<FormControlLabel
sx={{
minWidth: {
sm: '300px',
xs: 'auto',
},
}}
key={getRoleKey(permission)}
control={
<Checkbox
checked={
checkedPermissions[
getRoleKey(permission)
]
? true
: false
}
onChange={() =>
onPermissionChange(permission)
}
color="primary"
/>
}
label={permission.displayName}
/>
);
})}
{permissions?.map((permission: IPermission) => (
<FormControlLabel
sx={{
minWidth: {
sm: '300px',
xs: 'auto',
},
}}
data-testid={getRoleKey(permission)}
key={getRoleKey(permission)}
control={
<Checkbox
checked={Boolean(
checkedPermissions[
getRoleKey(permission)
]
)}
onChange={() =>
onPermissionChange(permission)
}
color="primary"
/>
}
label={permission.displayName}
/>
))}
</Box>
</AccordionDetails>
</Accordion>

View File

@ -1,11 +1,28 @@
import { styled } from '@mui/material';
import Input from 'component/common/Input/Input';
import { PermissionAccordion } from 'component/admin/projectRoles/ProjectRoleForm/PermissionAccordion/PermissionAccordion';
import { Person as UserIcon } from '@mui/icons-material';
import { PermissionAccordion } from './PermissionAccordion/PermissionAccordion';
import {
Person as UserIcon,
Topic as TopicIcon,
CloudCircle as CloudCircleIcon,
} from '@mui/icons-material';
import { ICheckedPermissions, IPermission } from 'interfaces/permissions';
import { IRoleFormErrors } from './useRoleForm';
import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions';
import { toggleAllPermissions, togglePermission } from 'utils/permissions';
import {
flattenProjectPermissions,
getCategorizedProjectPermissions,
getCategorizedRootPermissions,
toggleAllPermissions,
togglePermission,
} from 'utils/permissions';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import { PredefinedRoleType } from 'interfaces/role';
import {
ENVIRONMENT_PERMISSION_TYPE,
PROJECT_PERMISSION_TYPE,
PROJECT_ROLE_TYPES,
ROOT_ROLE_TYPE,
} from '@server/util/constants';
const StyledInputDescription = styled('p')(({ theme }) => ({
display: 'flex',
@ -22,6 +39,7 @@ const StyledInput = styled(Input)(({ theme }) => ({
}));
interface IRoleFormProps {
type?: PredefinedRoleType;
name: string;
onSetName: (name: string) => void;
description: string;
@ -30,34 +48,35 @@ interface IRoleFormProps {
setCheckedPermissions: React.Dispatch<
React.SetStateAction<ICheckedPermissions>
>;
permissions: IPermission[];
errors: IRoleFormErrors;
}
export const RoleForm = ({
type = ROOT_ROLE_TYPE,
name,
onSetName,
description,
setDescription,
checkedPermissions,
setCheckedPermissions,
permissions,
errors,
}: IRoleFormProps) => {
const categorizedPermissions = permissions.map(permission => {
const category = ROOT_PERMISSION_CATEGORIES.find(category =>
category.permissions.includes(permission.name)
);
return {
category: category ? category.label : 'Other',
permission,
};
const { permissions } = usePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const categories = new Set(
categorizedPermissions.map(({ category }) => category).sort()
);
const isProjectRole = PROJECT_ROLE_TYPES.includes(type);
const categories = isProjectRole
? getCategorizedProjectPermissions(
flattenProjectPermissions(
permissions.project,
permissions.environments
)
)
: getCategorizedRootPermissions(permissions.root);
const onPermissionChange = (permission: IPermission) => {
const newCheckedPermissions = togglePermission(
@ -67,14 +86,10 @@ export const RoleForm = ({
setCheckedPermissions(newCheckedPermissions);
};
const onCheckAll = (category: string) => {
const categoryPermissions = categorizedPermissions
.filter(({ category: pCategory }) => pCategory === category)
.map(({ permission }) => permission);
const onCheckAll = (permissions: IPermission[]) => {
const newCheckedPermissions = toggleAllPermissions(
checkedPermissions,
categoryPermissions
permissions
);
setCheckedPermissions(newCheckedPermissions);
@ -108,22 +123,26 @@ export const RoleForm = ({
<StyledInputDescription>
What is your role allowed to do?
</StyledInputDescription>
{[...categories].map(category => (
{categories.map(({ label, type, permissions }) => (
<PermissionAccordion
key={category}
title={`${category} permissions`}
context={category.toLowerCase()}
Icon={<UserIcon color="disabled" sx={{ mr: 1 }} />}
permissions={categorizedPermissions
.filter(
({ category: pCategory }) => pCategory === category
key={label}
title={`${label} permissions`}
context={label.toLowerCase()}
Icon={
type === PROJECT_PERMISSION_TYPE ? (
<TopicIcon color="disabled" sx={{ mr: 1 }} />
) : type === ENVIRONMENT_PERMISSION_TYPE ? (
<CloudCircleIcon color="disabled" sx={{ mr: 1 }} />
) : (
<UserIcon color="disabled" sx={{ mr: 1 }} />
)
.map(({ permission }) => permission)}
}
permissions={permissions}
checkedPermissions={checkedPermissions}
onPermissionChange={(permission: IPermission) =>
onPermissionChange(permission)
}
onCheckAll={() => onCheckAll(category)}
onCheckAll={() => onCheckAll(permissions)}
/>
))}
</div>

View File

@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { IPermission, ICheckedPermissions } from 'interfaces/permissions';
import IRole from 'interfaces/role';
import IRole, { PredefinedRoleType } from 'interfaces/role';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
import { permissionsToCheckedPermissions } from 'utils/permissions';
import { ROOT_ROLE_TYPE } from '@server/util/constants';
enum ErrorField {
NAME = 'name',
@ -39,10 +40,10 @@ export const useRoleForm = (
setCheckedPermissions(newCheckedPermissions);
}, [initialPermissions.length]);
const getRolePayload = (type: 'root-custom' | 'custom' = 'custom') => ({
const getRolePayload = (type: PredefinedRoleType = ROOT_ROLE_TYPE) => ({
name,
description,
type,
type: type === ROOT_ROLE_TYPE ? 'root-custom' : 'custom',
permissions: Object.values(checkedPermissions),
});

View File

@ -10,7 +10,8 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import { FormEvent } from 'react';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { useRole } from 'hooks/api/getters/useRole/useRole';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import { PredefinedRoleType } from 'interfaces/role';
import { ROOT_ROLE_TYPE } from '@server/util/constants';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -30,12 +31,18 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
}));
interface IRoleModalProps {
type?: PredefinedRoleType;
roleId?: number;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
export const RoleModal = ({
type = ROOT_ROLE_TYPE,
roleId,
open,
setOpen,
}: IRoleModalProps) => {
const { role, refetch: refetchRole } = useRole(roleId?.toString());
const {
@ -56,18 +63,9 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
} = useRoleForm(role?.name, role?.description, role?.permissions);
const { refetch: refetchRoles } = useRoles();
const { addRole, updateRole, loading } = useRolesApi();
const { permissions } = usePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const rootPermissions = permissions.root.filter(
({ name }) => name !== 'ADMIN'
);
const editing = role !== undefined;
const isValid =
isNameUnique(name) &&
@ -75,7 +73,7 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
isNotEmpty(description) &&
hasPermissions(checkedPermissions);
const payload = getRolePayload('root-custom');
const payload = getRolePayload(type);
const formatApiCode = () => {
return `curl --location --request ${editing ? 'PUT' : 'POST'} '${
@ -121,32 +119,34 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
}
};
const titleCasedType = type[0].toUpperCase() + type.slice(1);
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={editing ? 'Edit role' : 'New role'}
label={editing ? `Edit ${type} role` : `New ${type} role`}
>
<FormTemplate
loading={loading}
modal
title={editing ? 'Edit role' : 'New role'}
description="Roles allow you to control access to global root resources. Besides the built-in roles, you can create and manage custom roles to fit your needs."
documentationLink="https://docs.getunleash.io/reference/rbac#standard-roles"
title={editing ? `Edit ${type} role` : `New ${type} role`}
description={`${titleCasedType} roles allow you to control access to ${type} resources. Besides the built-in ${type} roles, you can create and manage custom ${type} roles to fit your needs.`}
documentationLink="https://docs.getunleash.io/reference/rbac"
documentationLinkLabel="Roles documentation"
formatApiCode={formatApiCode}
>
<StyledForm onSubmit={onSubmit}>
<RoleForm
type={type}
name={name}
onSetName={onSetName}
description={description}
setDescription={setDescription}
checkedPermissions={checkedPermissions}
setCheckedPermissions={setCheckedPermissions}
permissions={rootPermissions}
errors={errors}
/>
<StyledButtonContainer>

View File

@ -4,15 +4,88 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { RolesTable } from './RolesTable/RolesTable';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
import { PageContent } from 'component/common/PageContent/PageContent';
import { Tab, Tabs, styled } from '@mui/material';
import { Route, Routes, useLocation } from 'react-router-dom';
import { CenteredNavLink } from '../menu/CenteredNavLink';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { PROJECT_ROLE_TYPE } from '@server/util/constants';
const StyledPageContent = styled(PageContent)(({ theme }) => ({
'.page-header': {
padding: 0,
},
}));
const tabs = [
{
label: 'Root',
path: '/admin/roles',
},
{
label: 'Project',
path: '/admin/roles/project-roles',
},
];
export const Roles = () => {
const { uiConfig } = useUiConfig();
const { hasAccess } = useContext(AccessContext);
const { pathname } = useLocation();
if (!uiConfig.flags.customRootRoles) {
return (
<div>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<RolesTable type={PROJECT_ROLE_TYPE} />}
elseShow={<AdminAlert />}
/>
</div>
);
}
return (
<div>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<RolesTable />}
show={
<StyledPageContent
headerClass="page-header"
bodyClass="page-body"
header={
<Tabs
value={pathname}
indicatorColor="primary"
textColor="primary"
variant="scrollable"
allowScrollButtonsMobile
>
{tabs.map(({ label, path }) => (
<Tab
key={label}
value={path}
label={
<CenteredNavLink to={path}>
<span>{label}</span>
</CenteredNavLink>
}
/>
))}
</Tabs>
}
>
<Routes>
<Route
path="project-roles"
element={
<RolesTable type={PROJECT_ROLE_TYPE} />
}
/>
<Route path="*" element={<RolesTable />} />
</Routes>
</StyledPageContent>
}
elseShow={<AdminAlert />}
/>
</div>

View File

@ -4,6 +4,7 @@ import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import IRole from 'interfaces/role';
import { useRole } from 'hooks/api/getters/useRole/useRole';
import { RoleDescription } from 'component/common/RoleDescription/RoleDescription';
import { PREDEFINED_ROLE_TYPES } from '@server/util/constants';
interface IRolePermissionsCellProps {
row: { original: IRole };
@ -15,7 +16,7 @@ export const RolePermissionsCell: VFC<IRolePermissionsCellProps> = ({
const { original: rowRole } = row;
const { role } = useRole(rowRole.id.toString());
if (!role || role.type === 'root') return null;
if (!role || PREDEFINED_ROLE_TYPES.includes(role.type)) return null;
return (
<TextCell>

View File

@ -1,5 +1,6 @@
import { Delete, Edit } from '@mui/icons-material';
import { Box, styled } from '@mui/material';
import { PREDEFINED_ROLE_TYPES } from '@server/util/constants';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import IRole from 'interfaces/role';
@ -10,8 +11,6 @@ const StyledBox = styled(Box)(() => ({
justifyContent: 'center',
}));
const DEFAULT_ROOT_ROLE = 'root';
interface IRolesActionsCellProps {
role: IRole;
onEdit: (event: React.SyntheticEvent) => void;
@ -23,7 +22,7 @@ export const RolesActionsCell: VFC<IRolesActionsCellProps> = ({
onEdit,
onDelete,
}) => {
const defaultRole = role.type === DEFAULT_ROOT_ROLE;
const defaultRole = PREDEFINED_ROLE_TYPES.includes(role.type);
return (
<StyledBox>

View File

@ -3,6 +3,7 @@ import { Badge } from 'component/common/Badge/Badge';
import { styled } from '@mui/material';
import IRole from 'interfaces/role';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { PREDEFINED_ROLE_TYPES } from '@server/util/constants';
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
@ -18,7 +19,7 @@ export const RolesCell = ({ role }: IRolesCellProps) => (
subtitle={role.description}
afterTitle={
<ConditionallyRender
condition={role.type === 'root'}
condition={PREDEFINED_ROLE_TYPES.includes(role.type)}
show={<StyledBadge color="success">Predefined</StyledBadge>}
/>
}

View File

@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import IRole from 'interfaces/role';
import IRole, { PredefinedRoleType } from 'interfaces/role';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { PageContent } from 'component/common/PageContent/PageContent';
@ -24,11 +24,16 @@ import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
import { RoleModal } from '../RoleModal/RoleModal';
import { RolePermissionsCell } from './RolePermissionsCell/RolePermissionsCell';
import { ROOT_ROLE_TYPE } from '@server/util/constants';
export const RolesTable = () => {
interface IRolesTableProps {
type?: PredefinedRoleType;
}
export const RolesTable = ({ type = ROOT_ROLE_TYPE }: IRolesTableProps) => {
const { setToastData, setToastApiError } = useToast();
const { roles, refetch, loading } = useRoles();
const { roles, projectRoles, refetch, loading } = useRoles();
const { removeRole } = useRolesApi();
const [searchValue, setSearchValue] = useState('');
@ -114,7 +119,11 @@ export const RolesTable = () => {
hiddenColumns: ['description'],
});
const { data, getSearchText } = useSearch(columns, searchValue, roles);
const { data, getSearchText } = useSearch(
columns,
searchValue,
type === ROOT_ROLE_TYPE ? roles : projectRoles
);
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
{
@ -145,12 +154,14 @@ export const RolesTable = () => {
columns
);
const titledCaseType = type[0].toUpperCase() + type.slice(1);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Roles (${rows.length})`}
title={`${titledCaseType} roles (${rows.length})`}
actions={
<>
<ConditionallyRender
@ -173,7 +184,7 @@ export const RolesTable = () => {
setModalOpen(true);
}}
>
New role
New {type} role
</Button>
</>
}
@ -204,20 +215,22 @@ export const RolesTable = () => {
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No roles found matching &ldquo;
No {type} roles found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No roles available. Get started by adding one.
No {type} roles available. Get started by adding
one.
</TablePlaceholder>
}
/>
}
/>
<RoleModal
type={type}
roleId={selectedRole?.id}
open={modalOpen}
setOpen={setModalOpen}

View File

@ -1,7 +1,14 @@
import { SxProps, Theme, styled } from '@mui/material';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions';
import { useRole } from 'hooks/api/getters/useRole/useRole';
import {
PREDEFINED_ROLE_TYPES,
PROJECT_ROLE_TYPES,
} from '@server/util/constants';
import {
getCategorizedProjectPermissions,
getCategorizedRootPermissions,
} from 'utils/permissions';
const StyledDescription = styled('div', {
shouldForwardProp: prop => prop !== 'tooltip',
@ -49,22 +56,13 @@ export const RoleDescription = ({
if (!role) return null;
const { name, description, permissions } = role;
const { name, description, permissions, type } = role;
const categorizedPermissions = [...new Set(permissions)].map(permission => {
const category = ROOT_PERMISSION_CATEGORIES.find(category =>
category.permissions.includes(permission.name)
);
const isProjectRole = PROJECT_ROLE_TYPES.includes(type);
return {
category: category ? category.label : 'Other',
permission,
};
});
const categories = new Set(
categorizedPermissions.map(({ category }) => category).sort()
);
const categories = isProjectRole
? getCategorizedProjectPermissions(permissions)
: getCategorizedRootPermissions(permissions);
return (
<StyledDescription tooltip={tooltip} {...rest}>
@ -75,22 +73,18 @@ export const RoleDescription = ({
{description}
</StyledDescriptionSubHeader>
<ConditionallyRender
condition={
categorizedPermissions.length > 0 && role.type !== 'root'
}
condition={!PREDEFINED_ROLE_TYPES.includes(role.type)}
show={() =>
[...categories].map(category => (
<StyledDescriptionBlock key={category}>
categories.map(({ label, permissions }) => (
<StyledDescriptionBlock key={label}>
<StyledDescriptionHeader>
{category}
{label}
</StyledDescriptionHeader>
{categorizedPermissions
.filter(({ category: c }) => c === category)
.map(({ permission }) => (
<p key={permission.id}>
{permission.displayName}
</p>
))}
{permissions.map(permission => (
<p key={permission.id}>
{permission.displayName}
</p>
))}
</StyledDescriptionBlock>
))
}

View File

@ -464,15 +464,8 @@ export const adminMenuRoutes: INavigationMenuItem[] = [
flag: UG,
},
{
path: '/admin/roles',
path: '/admin/roles/*',
title: 'Roles',
flag: 'customRootRoles',
menu: { adminSettings: true, mode: ['enterprise'] },
},
{
path: '/admin/project-roles',
title: 'Project roles',
flag: RE,
menu: { adminSettings: true, mode: ['enterprise'] },
},
{

View File

@ -4,10 +4,12 @@ import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig';
const ROOT_ROLE = 'root';
const ROOT_ROLES = [ROOT_ROLE, 'root-custom'];
const PROJECT_ROLES = ['project', 'custom'];
import {
PROJECT_ROLE_TYPES,
ROOT_ROLE_TYPE,
ROOT_ROLE_TYPES,
PREDEFINED_ROLE_TYPES,
} from '@server/util/constants';
export const useRoles = () => {
const { isEnterprise, uiConfig } = useUiConfig();
@ -34,7 +36,7 @@ export const useRoles = () => {
if (!isEnterprise()) {
return {
roles: ossData?.rootRoles
.filter(({ type }: IRole) => type === ROOT_ROLE)
.filter(({ type }: IRole) => type === ROOT_ROLE_TYPE)
.sort(sortRoles) as IRole[],
projectRoles: [],
loading: !ossError && !ossData,
@ -46,12 +48,14 @@ export const useRoles = () => {
roles: (data?.roles
.filter(({ type }: IRole) =>
uiConfig.flags.customRootRoles
? ROOT_ROLES.includes(type)
: type === ROOT_ROLE
? ROOT_ROLE_TYPES.includes(type)
: type === ROOT_ROLE_TYPE
)
.sort(sortRoles) ?? []) as IRole[],
projectRoles: (data?.roles
.filter(({ type }: IRole) => PROJECT_ROLES.includes(type))
.filter(({ type }: IRole) =>
PROJECT_ROLE_TYPES.includes(type)
)
.sort(sortRoles) ?? []) as IProjectRole[],
loading: !error && !data,
refetch: () => mutate(),
@ -68,9 +72,15 @@ const fetcher = (path: string) => {
};
export const sortRoles = (a: IRole, b: IRole) => {
if (a.type === 'root' && b.type !== 'root') {
if (
PREDEFINED_ROLE_TYPES.includes(a.type) &&
!PREDEFINED_ROLE_TYPES.includes(b.type)
) {
return -1;
} else if (a.type !== 'root' && b.type === 'root') {
} else if (
!PREDEFINED_ROLE_TYPES.includes(a.type) &&
PREDEFINED_ROLE_TYPES.includes(b.type)
) {
return 1;
} else {
return a.name.localeCompare(b.name);

View File

@ -1,7 +1,19 @@
import {
ROOT_PERMISSION_TYPE,
PROJECT_PERMISSION_TYPE,
ENVIRONMENT_PERMISSION_TYPE,
} from '@server/util/constants';
export type PermissionType =
| typeof ROOT_PERMISSION_TYPE
| typeof PROJECT_PERMISSION_TYPE
| typeof ENVIRONMENT_PERMISSION_TYPE;
export interface IPermission {
id: number;
name: string;
displayName: string;
type: PermissionType;
environment?: string;
}
@ -19,3 +31,9 @@ export interface IProjectEnvironmentPermissions {
export interface ICheckedPermissions {
[key: string]: IPermission;
}
export interface IPermissionCategory {
label: string;
type: PermissionType;
permissions: IPermission[];
}

View File

@ -1,5 +1,10 @@
import { ROOT_ROLE_TYPE, PROJECT_ROLE_TYPE } from '@server/util/constants';
import { IPermission } from './permissions';
export type PredefinedRoleType =
| typeof ROOT_ROLE_TYPE
| typeof PROJECT_ROLE_TYPE;
interface IRole {
id: number;
name: string;

View File

@ -1,7 +1,17 @@
import { IPermission, ICheckedPermissions } from 'interfaces/permissions';
import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions';
import {
ENVIRONMENT_PERMISSION_TYPE,
PROJECT_PERMISSION_TYPE,
} from '@server/util/constants';
import {
IPermission,
ICheckedPermissions,
IPermissionCategory,
IProjectEnvironmentPermissions,
} from 'interfaces/permissions';
import cloneDeep from 'lodash.clonedeep';
const getRoleKey = (permission: IPermission): string => {
export const getRoleKey = (permission: IPermission): string => {
return permission.environment
? `${permission.id}-${permission.environment}`
: `${permission.id}`;
@ -61,3 +71,101 @@ export const toggleAllPermissions = (
return checkedPermissionsCopy;
};
export const getCategorizedRootPermissions = (permissions: IPermission[]) => {
const rootPermissions = permissions.filter(({ name }) => name !== 'ADMIN');
return rootPermissions
.reduce((categories: IPermissionCategory[], permission) => {
const categoryLabel =
ROOT_PERMISSION_CATEGORIES.find(category =>
category.permissions.includes(permission.name)
)?.label || 'Other';
const category = categories.find(
({ label }) => label === categoryLabel
);
if (category) {
category.permissions.push(permission);
} else {
categories.push({
label: categoryLabel,
type: 'root',
permissions: [permission],
});
}
return categories;
}, [])
.sort(sortCategories);
};
export const getCategorizedProjectPermissions = (
permissions: IPermission[]
) => {
const projectPermissions = permissions.filter(
({ type }) => type === PROJECT_PERMISSION_TYPE
);
const environmentPermissions = permissions.filter(
({ type }) => type === ENVIRONMENT_PERMISSION_TYPE
);
const categories = [];
if (projectPermissions.length) {
categories.push({
label: 'Project',
type: 'project',
permissions: projectPermissions,
});
}
categories.push(
...environmentPermissions.reduce(
(categories: IPermissionCategory[], permission) => {
const categoryLabel = permission.environment;
const category = categories.find(
({ label }) => label === categoryLabel
);
if (category) {
category.permissions.push(permission);
} else {
categories.push({
label: categoryLabel!,
type: 'environment',
permissions: [permission],
});
}
return categories;
},
[]
)
);
return categories;
};
export const flattenProjectPermissions = (
projectPermissions: IPermission[],
environmentPermissions: IProjectEnvironmentPermissions[]
) => [
...projectPermissions,
...environmentPermissions.flatMap(({ permissions }) => permissions),
];
const sortCategories = (
{ label: aLabel }: IPermissionCategory,
{ label: bLabel }: IPermissionCategory
) => {
if (aLabel === 'Other' && bLabel !== 'Other') {
return 1;
} else if (aLabel !== 'Other' && bLabel === 'Other') {
return -1;
} else {
return aLabel.localeCompare(bLabel);
}
};

View File

@ -7,8 +7,13 @@ export const ROOT_PERMISSION_TYPE = 'root';
export const ENVIRONMENT_PERMISSION_TYPE = 'environment';
export const PROJECT_PERMISSION_TYPE = 'project';
export const ROOT_ROLE_TYPE = 'root';
export const PROJECT_ROLE_TYPE = 'project';
export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom';
export const CUSTOM_PROJECT_ROLE_TYPE = 'custom';
export const PREDEFINED_ROLE_TYPES = [ROOT_ROLE_TYPE, PROJECT_ROLE_TYPE];
export const ROOT_ROLE_TYPES = [ROOT_ROLE_TYPE, CUSTOM_ROOT_ROLE_TYPE];
export const PROJECT_ROLE_TYPES = [PROJECT_ROLE_TYPE, CUSTOM_PROJECT_ROLE_TYPE];
/* CONTEXT FIELD OPERATORS */