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

feat: roles unification ()

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
frontend/src
component
admin
common/RoleDescription
menu
hooks/api/getters/useRoles
interfaces
utils
src/lib/util

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,8 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import { FormEvent } from 'react'; import { FormEvent } from 'react';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { useRole } from 'hooks/api/getters/useRole/useRole'; 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')(() => ({ const StyledForm = styled('form')(() => ({
display: 'flex', display: 'flex',
@ -30,12 +31,18 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
})); }));
interface IRoleModalProps { interface IRoleModalProps {
type?: PredefinedRoleType;
roleId?: number; roleId?: number;
open: boolean; open: boolean;
setOpen: React.Dispatch<React.SetStateAction<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 { role, refetch: refetchRole } = useRole(roleId?.toString());
const { const {
@ -56,18 +63,9 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
} = useRoleForm(role?.name, role?.description, role?.permissions); } = useRoleForm(role?.name, role?.description, role?.permissions);
const { refetch: refetchRoles } = useRoles(); const { refetch: refetchRoles } = useRoles();
const { addRole, updateRole, loading } = useRolesApi(); const { addRole, updateRole, loading } = useRolesApi();
const { permissions } = usePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig(); const { uiConfig } = useUiConfig();
const rootPermissions = permissions.root.filter(
({ name }) => name !== 'ADMIN'
);
const editing = role !== undefined; const editing = role !== undefined;
const isValid = const isValid =
isNameUnique(name) && isNameUnique(name) &&
@ -75,7 +73,7 @@ export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
isNotEmpty(description) && isNotEmpty(description) &&
hasPermissions(checkedPermissions); hasPermissions(checkedPermissions);
const payload = getRolePayload('root-custom'); const payload = getRolePayload(type);
const formatApiCode = () => { const formatApiCode = () => {
return `curl --location --request ${editing ? 'PUT' : 'POST'} '${ 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 ( return (
<SidebarModal <SidebarModal
open={open} open={open}
onClose={() => { onClose={() => {
setOpen(false); setOpen(false);
}} }}
label={editing ? 'Edit role' : 'New role'} label={editing ? `Edit ${type} role` : `New ${type} role`}
> >
<FormTemplate <FormTemplate
loading={loading} loading={loading}
modal modal
title={editing ? 'Edit role' : 'New role'} title={editing ? `Edit ${type} role` : `New ${type} 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." 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#standard-roles" documentationLink="https://docs.getunleash.io/reference/rbac"
documentationLinkLabel="Roles documentation" documentationLinkLabel="Roles documentation"
formatApiCode={formatApiCode} formatApiCode={formatApiCode}
> >
<StyledForm onSubmit={onSubmit}> <StyledForm onSubmit={onSubmit}>
<RoleForm <RoleForm
type={type}
name={name} name={name}
onSetName={onSetName} onSetName={onSetName}
description={description} description={description}
setDescription={setDescription} setDescription={setDescription}
checkedPermissions={checkedPermissions} checkedPermissions={checkedPermissions}
setCheckedPermissions={setCheckedPermissions} setCheckedPermissions={setCheckedPermissions}
permissions={rootPermissions}
errors={errors} errors={errors}
/> />
<StyledButtonContainer> <StyledButtonContainer>

View File

@ -4,15 +4,88 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { ADMIN } from 'component/providers/AccessProvider/permissions'; import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { RolesTable } from './RolesTable/RolesTable'; import { RolesTable } from './RolesTable/RolesTable';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert'; 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 = () => { export const Roles = () => {
const { uiConfig } = useUiConfig();
const { hasAccess } = useContext(AccessContext); 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 ( return (
<div> <div>
<ConditionallyRender <ConditionallyRender
condition={hasAccess(ADMIN)} 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 />} elseShow={<AdminAlert />}
/> />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,10 +4,12 @@ import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler'; import handleErrorResponses from '../httpErrorResponseHandler';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
import useUiConfig from '../useUiConfig/useUiConfig'; import useUiConfig from '../useUiConfig/useUiConfig';
import {
const ROOT_ROLE = 'root'; PROJECT_ROLE_TYPES,
const ROOT_ROLES = [ROOT_ROLE, 'root-custom']; ROOT_ROLE_TYPE,
const PROJECT_ROLES = ['project', 'custom']; ROOT_ROLE_TYPES,
PREDEFINED_ROLE_TYPES,
} from '@server/util/constants';
export const useRoles = () => { export const useRoles = () => {
const { isEnterprise, uiConfig } = useUiConfig(); const { isEnterprise, uiConfig } = useUiConfig();
@ -34,7 +36,7 @@ export const useRoles = () => {
if (!isEnterprise()) { if (!isEnterprise()) {
return { return {
roles: ossData?.rootRoles roles: ossData?.rootRoles
.filter(({ type }: IRole) => type === ROOT_ROLE) .filter(({ type }: IRole) => type === ROOT_ROLE_TYPE)
.sort(sortRoles) as IRole[], .sort(sortRoles) as IRole[],
projectRoles: [], projectRoles: [],
loading: !ossError && !ossData, loading: !ossError && !ossData,
@ -46,12 +48,14 @@ export const useRoles = () => {
roles: (data?.roles roles: (data?.roles
.filter(({ type }: IRole) => .filter(({ type }: IRole) =>
uiConfig.flags.customRootRoles uiConfig.flags.customRootRoles
? ROOT_ROLES.includes(type) ? ROOT_ROLE_TYPES.includes(type)
: type === ROOT_ROLE : type === ROOT_ROLE_TYPE
) )
.sort(sortRoles) ?? []) as IRole[], .sort(sortRoles) ?? []) as IRole[],
projectRoles: (data?.roles projectRoles: (data?.roles
.filter(({ type }: IRole) => PROJECT_ROLES.includes(type)) .filter(({ type }: IRole) =>
PROJECT_ROLE_TYPES.includes(type)
)
.sort(sortRoles) ?? []) as IProjectRole[], .sort(sortRoles) ?? []) as IProjectRole[],
loading: !error && !data, loading: !error && !data,
refetch: () => mutate(), refetch: () => mutate(),
@ -68,9 +72,15 @@ const fetcher = (path: string) => {
}; };
export const sortRoles = (a: IRole, b: IRole) => { 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; 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; return 1;
} else { } else {
return a.name.localeCompare(b.name); 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 { export interface IPermission {
id: number; id: number;
name: string; name: string;
displayName: string; displayName: string;
type: PermissionType;
environment?: string; environment?: string;
} }
@ -19,3 +31,9 @@ export interface IProjectEnvironmentPermissions {
export interface ICheckedPermissions { export interface ICheckedPermissions {
[key: string]: IPermission; [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'; import { IPermission } from './permissions';
export type PredefinedRoleType =
| typeof ROOT_ROLE_TYPE
| typeof PROJECT_ROLE_TYPE;
interface IRole { interface IRole {
id: number; id: number;
name: string; 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'; import cloneDeep from 'lodash.clonedeep';
const getRoleKey = (permission: IPermission): string => { export const getRoleKey = (permission: IPermission): string => {
return permission.environment return permission.environment
? `${permission.id}-${permission.environment}` ? `${permission.id}-${permission.environment}`
: `${permission.id}`; : `${permission.id}`;
@ -61,3 +71,101 @@ export const toggleAllPermissions = (
return checkedPermissionsCopy; 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 ENVIRONMENT_PERMISSION_TYPE = 'environment';
export const PROJECT_PERMISSION_TYPE = 'project'; 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_ROOT_ROLE_TYPE = 'root-custom';
export const CUSTOM_PROJECT_ROLE_TYPE = '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 */ /* CONTEXT FIELD OPERATORS */