1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

feat: custom root roles (#3975)

## About the changes
Implements custom root roles, encompassing a lot of different areas of
the project, and slightly refactoring the current roles logic. It
includes quite a clean up.

This feature itself is behind a flag: `customRootRoles`

This feature covers root roles in:
 - Users;
 - Service Accounts;
 - Groups;

Apologies in advance. I may have gotten a bit carried away 🙈 

### Roles

We now have a new admin tab called "Roles" where we can see all root
roles and manage custom ones. We are not allowed to edit or remove
*predefined* roles.

![image](https://github.com/Unleash/unleash/assets/14320932/1ad8695c-8c3f-440d-ac32-39746720d588)
This meant slightly pushing away the existing roles to `project-roles`
instead. One idea we want to explore in the future is to unify both
types of roles in the UI instead of having 2 separate tabs. This
includes modernizing project roles to fit more into our current design
and decisions.

Hovering the permissions cell expands detailed information about the
role:

![image](https://github.com/Unleash/unleash/assets/14320932/81c4aae7-8b4d-4cb4-92d1-8f1bc3ef1f2a)

### Create and edit role

Here's how the role form looks like (create / edit):

![image](https://github.com/Unleash/unleash/assets/14320932/85baec29-bb10-48c5-a207-b3e9a8de838a)
Here I categorized permissions so it's easier to visualize and manage
from a UX perspective.

I'm using the same endpoint as before. I tried to unify the logic and
get rid of the `projectRole` specific hooks. What distinguishes custom
root roles from custom project roles is the extra `root-custom` type we
see on the payload. By default we assume `custom` (custom project role)
instead, which should help in terms of backwards compatibility.

### Delete role

When we delete a custom role we try to help the end user make an
informed decision by listing all the entities which currently use this
custom root role:

![image](https://github.com/Unleash/unleash/assets/14320932/352ed529-76be-47a8-88da-5e924fb191d4)
~~As mentioned in the screenshot, when deleting a custom role, we demote
all entities associated with it to the predefined `Viewer` role.~~
**EDIT**: Apparently we currently block this from the API
(access-service deleteRole) with a message:

![image](https://github.com/Unleash/unleash/assets/14320932/82a8e50f-8dc5-4c18-a2ba-54e2ae91b91c)
What should the correct behavior be?

### Role selector

I added a new easy-to-use role selector component that is present in:
 - Users 

![image](https://github.com/Unleash/unleash/assets/14320932/76953139-7fb6-437e-b3fa-ace1d9187674)
 - Service Accounts

![image](https://github.com/Unleash/unleash/assets/14320932/2b80bd55-9abb-4883-b715-15650ae752ea)
- Groups

![image](https://github.com/Unleash/unleash/assets/14320932/ab438f7c-2245-4779-b157-2da1689fe402)

### Role description

I also added a new role description component that you can see below the
dropdown in the selector component, but it's also used to better
describe each role in the respective tables:

![image](https://github.com/Unleash/unleash/assets/14320932/a3eecac1-2a34-4500-a68c-e3f62ebfa782)

I'm not listing all the permissions of predefined roles. Those simply
show the description in the tooltip:

![image](https://github.com/Unleash/unleash/assets/14320932/7e5b2948-45f0-4472-8311-bf533409ba6c)

### Role badge

Groups is a bit different, since it uses a list of cards, so I added yet
another component - Role badge:

![image](https://github.com/Unleash/unleash/assets/14320932/1d62c3db-072a-4c97-b86f-1d8ebdd3523e)

I'm using this same component on the profile tab:

![image](https://github.com/Unleash/unleash/assets/14320932/214272db-a828-444e-8846-4f39b9456bc6)

## Discussion points
- Are we being defensive enough with the use of the flag? Should we
cover more?
 - Are we breaking backwards compatibility in any way?
 - What should we do when removing a role? Block or demote?
- Maybe some existing permission-related issues will surface with this
change: Are we being specific enough with our permissions? A lot of
places are simply checking for `ADMIN`;
- We may want to get rid of the API roles coupling we have with the
users and SAs and instead use the new hooks (e.g. `useRoles`)
explicitly;
 - We should update the docs;
- Maybe we could allow the user to add a custom role directly from the
role selector component;

---------

Co-authored-by: Gastón Fournier <gaston@getunleash.io>
This commit is contained in:
Nuno Góis 2023-06-14 14:40:40 +01:00 committed by GitHub
parent 1bd182d02a
commit bb026c0ba1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 2036 additions and 490 deletions

View File

@ -15,6 +15,7 @@ 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';
@ -27,8 +28,11 @@ export const Admin = () => (
<AdminMenu />
<Routes>
<Route path="users" element={<UsersAdmin />} />
<Route path="create-project-role" element={<CreateProjectRole />} />
<Route path="roles/:id/edit" element={<EditProjectRole />} />
<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 />} />
@ -42,7 +46,8 @@ export const Admin = () => (
element={<EditGroupContainer />}
/>
<Route path="groups/:groupId" element={<Group />} />
<Route path="roles" element={<ProjectRoles />} />
<Route path="roles" element={<Roles />} />
<Route path="project-roles" element={<ProjectRoles />} />
<Route path="instance" element={<InstanceAdmin />} />
<Route path="network/*" element={<Network />} />
<Route path="maintenance" element={<MaintenanceAdmin />} />

View File

@ -1,5 +1,5 @@
import React, { FC } from 'react';
import { Autocomplete, Box, Button, styled, TextField } from '@mui/material';
import { Box, Button, styled } from '@mui/material';
import { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds';
import Input from 'component/common/Input/Input';
import { IGroupUser } from 'interfaces/group';
@ -10,9 +10,10 @@ import { ItemList } from 'component/common/ItemList/ItemList';
import useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings';
import { Link } from 'react-router-dom';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import { IProjectRole } from 'interfaces/role';
import IRole from 'interfaces/role';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { RoleSelect } from 'component/common/RoleSelect/RoleSelect';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -74,15 +75,6 @@ const StyledAutocompleteWrapper = styled('div')(({ theme }) => ({
},
}));
const StyledRoleOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > span:last-of-type': {
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
},
}));
interface IGroupForm {
name: string;
description: string;
@ -128,24 +120,10 @@ export const GroupForm: FC<IGroupForm> = ({
const groupRootRolesEnabled = Boolean(uiConfig.flags.groupRootRoles);
const roleIdToRole = (rootRoleId: number | null): IProjectRole | null => {
return (
roles.find((role: IProjectRole) => role.id === rootRoleId) || null
);
const roleIdToRole = (rootRoleId: number | null): IRole | null => {
return roles.find((role: IRole) => role.id === rootRoleId) || null;
};
const renderRoleOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: IProjectRole
) => (
<li {...props}>
<StyledRoleOption>
<span>{option.name}</span>
<span>{option.description}</span>
</StyledRoleOption>
</li>
);
return (
<StyledForm onSubmit={handleSubmit}>
<div>
@ -214,23 +192,12 @@ export const GroupForm: FC<IGroupForm> = ({
</Box>
</StyledInputDescription>
<StyledAutocompleteWrapper>
<Autocomplete
<RoleSelect
data-testid="GROUP_ROOT_ROLE"
size="small"
openOnFocus
value={roleIdToRole(rootRole)}
onChange={(_, newValue) =>
setRootRole(newValue?.id || null)
setValue={role =>
setRootRole(role?.id || null)
}
options={roles.filter(
(role: IProjectRole) =>
role.name !== 'Viewer'
)}
renderOption={renderRoleOption}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField {...params} label="Role" />
)}
/>
</StyledAutocompleteWrapper>
</>

View File

@ -6,7 +6,7 @@ import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars';
import { Badge } from 'component/common/Badge/Badge';
import { GroupCardActions } from './GroupCardActions/GroupCardActions';
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
import { IProjectRole } from 'interfaces/role';
import { RoleBadge } from 'component/common/RoleBadge/RoleBadge';
const StyledLink = styled(Link)(({ theme }) => ({
textDecoration: 'none',
@ -86,14 +86,12 @@ const InfoBadgeDescription = styled('span')(({ theme }) => ({
interface IGroupCardProps {
group: IGroup;
rootRoles: IProjectRole[];
onEditUsers: (group: IGroup) => void;
onRemoveGroup: (group: IGroup) => void;
}
export const GroupCard = ({
group,
rootRoles,
onEditUsers,
onRemoveGroup,
}: IGroupCardProps) => {
@ -117,17 +115,7 @@ export const GroupCard = ({
show={
<InfoBadgeDescription>
<p>Root role:</p>
<Badge
color="success"
icon={<TopicOutlinedIcon />}
>
{
rootRoles.find(
(role: IProjectRole) =>
role.id === group.rootRole
)?.name
}
</Badge>
<RoleBadge roleId={group.rootRole!} />
</InfoBadgeDescription>
}
/>

View File

@ -18,8 +18,6 @@ import { Add } from '@mui/icons-material';
import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers';
import { RemoveGroup } from '../RemoveGroup/RemoveGroup';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { IProjectRole } from 'interfaces/role';
type PageQueryType = Partial<Record<'search', string>>;
@ -51,7 +49,6 @@ export const GroupsList: VFC = () => {
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || ''
);
const { roles } = useUsers();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
@ -85,10 +82,6 @@ export const GroupsList: VFC = () => {
setRemoveOpen(true);
};
const getBindableRootRoles = () => {
return roles.filter((role: IProjectRole) => role.type === 'root');
};
return (
<PageContent
isLoading={loading}
@ -141,7 +134,6 @@ export const GroupsList: VFC = () => {
<Grid key={group.id} item xs={12} md={6}>
<GroupCard
group={group}
rootRoles={getBindableRootRoles()}
onEditUsers={onEditUsers}
onRemoveGroup={onRemoveGroup}
/>

View File

@ -55,11 +55,21 @@ function AdminMenu() {
}
/>
)}
{flags.RE && (
{flags.customRootRoles && (
<Tab
value="roles"
label={
<CenteredNavLink to="/admin/roles">
<span>Roles</span>
</CenteredNavLink>
}
/>
)}
{flags.RE && (
<Tab
value="project-roles"
label={
<CenteredNavLink to="/admin/project-roles">
<span>Project roles</span>
</CenteredNavLink>
}

View File

@ -1,5 +1,5 @@
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { useNavigate } from 'react-router-dom';
import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm';
import useProjectRoleForm from '../hooks/useProjectRoleForm';
@ -33,7 +33,7 @@ const CreateProjectRole = () => {
getRoleKey,
} = useProjectRoleForm();
const { createRole, loading } = useProjectRolesApi();
const { addRole, loading } = useRolesApi();
const onSubmit = async (e: Event) => {
e.preventDefault();
@ -44,8 +44,8 @@ const CreateProjectRole = () => {
if (validName && validPermissions) {
const payload = getProjectRolePayload();
try {
await createRole(payload);
navigate('/admin/roles');
await addRole(payload);
navigate('/admin/project-roles');
setToastData({
title: 'Project role created',
text: 'Now you can start assigning your project roles to project members.',

View File

@ -1,8 +1,8 @@
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole';
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';
@ -15,8 +15,8 @@ import { GO_BACK } from 'constants/navigate';
const EditProjectRole = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const projectId = useRequiredPathParam('id');
const { role } = useProjectRole(projectId);
const roleId = useRequiredPathParam('id');
const { role, refetch } = useRole(roleId);
const navigate = useNavigate();
const {
@ -35,19 +35,18 @@ const EditProjectRole = () => {
validateName,
clearErrors,
getRoleKey,
} = useProjectRoleForm(role.name, role.description, role?.permissions);
} = useProjectRoleForm(role?.name, role?.description, role?.permissions);
const formatApiCode = () => {
return `curl --location --request PUT '${
uiConfig.unleashUrl
}/api/admin/roles/${role.id}' \\
}/api/admin/roles/${role?.id}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
};
const { refetch } = useProjectRole(projectId);
const { editRole, loading } = useProjectRolesApi();
const { updateRole, loading } = useRolesApi();
const onSubmit = async (e: Event) => {
e.preventDefault();
@ -58,9 +57,9 @@ const EditProjectRole = () => {
if (validName && validPermissions) {
try {
await editRole(projectId, payload);
await updateRole(+roleId, payload);
refetch();
navigate('/admin/roles');
navigate('/admin/project-roles');
setToastData({
type: 'success',
title: 'Project role updated',

View File

@ -13,7 +13,7 @@ import {
Typography,
} from '@mui/material';
import { ExpandMore } from '@mui/icons-material';
import { IPermission } from 'interfaces/project';
import { IPermission } from 'interfaces/permissions';
import StringTruncator from 'component/common/StringTruncator/StringTruncator';
import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm';
@ -23,10 +23,10 @@ interface IEnvironmentPermissionAccordionProps {
title: string;
Icon: ReactNode;
isInitiallyExpanded?: boolean;
context: 'project' | 'environment';
context: string;
onPermissionChange: (permission: IPermission) => void;
onCheckAll: () => void;
getRoleKey: (permission: { id: number; environment?: string }) => string;
getRoleKey?: (permission: { id: number; environment?: string }) => string;
}
const AccordionHeader = styled(Box)(({ theme }) => ({
@ -52,7 +52,7 @@ export const PermissionAccordion: VFC<IEnvironmentPermissionAccordionProps> = ({
context,
onPermissionChange,
onCheckAll,
getRoleKey,
getRoleKey = permission => permission.id.toString(),
}) => {
const [expanded, setExpanded] = useState(isInitiallyExpanded);
const permissionMap = useMemo(

View File

@ -10,8 +10,8 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import {
IPermission,
IProjectEnvironmentPermissions,
IProjectRolePermissions,
} from 'interfaces/project';
IPermissions,
} from 'interfaces/permissions';
import { ICheckedPermission } from '../hooks/useProjectRoleForm';
interface IProjectRoleForm {
@ -21,7 +21,7 @@ interface IProjectRoleForm {
errors: { [key: string]: string };
children: ReactNode;
permissions:
| IProjectRolePermissions
| IPermissions
| {
project: IPermission[];
environments: IProjectEnvironmentPermissions[];

View File

@ -9,9 +9,9 @@ import {
} from 'component/common/Table';
import { useTable, useGlobalFilter, useSortBy } from 'react-table';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import useProjectRoles from 'hooks/api/getters/useProjectRoles/useProjectRoles';
import IRole, { IProjectRole } from 'interfaces/role';
import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
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';
@ -30,19 +30,15 @@ import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { Search } from 'component/common/Search/Search';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
const ROOTROLE = 'root';
const BUILTIN_ROLE_TYPE = 'project';
const ProjectRoleList = () => {
const navigate = useNavigate();
const { roles, refetch, loading } = useProjectRoles();
const { projectRoles: data, refetch, loading } = useRoles();
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const paginationFilter = (role: IRole) => role?.type !== ROOTROLE;
const data = roles.filter(paginationFilter);
const { deleteRole } = useProjectRolesApi();
const { removeRole } = useRolesApi();
const [currentRole, setCurrentRole] = useState<IProjectRole | null>(null);
const [delDialog, setDelDialog] = useState(false);
const [confirmName, setConfirmName] = useState('');
@ -51,7 +47,7 @@ const ProjectRoleList = () => {
const deleteProjectRole = async () => {
if (!currentRole?.id) return;
try {
await deleteRole(currentRole?.id);
await removeRole(currentRole?.id);
refetch();
setToastData({
type: 'success',
@ -99,7 +95,7 @@ const ProjectRoleList = () => {
data-loading
disabled={type === BUILTIN_ROLE_TYPE}
onClick={() => {
navigate(`/admin/roles/${id}/edit`);
navigate(`/admin/project-roles/${id}/edit`);
}}
permission={ADMIN}
tooltipProps={{
@ -208,7 +204,7 @@ const ProjectRoleList = () => {
variant="contained"
color="primary"
onClick={() =>
navigate('/admin/create-project-role')
navigate('/admin/project-roles/new')
}
>
New project role

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { IPermission } from 'interfaces/project';
import { IPermission } from 'interfaces/permissions';
import cloneDeep from 'lodash.clonedeep';
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi';
import { formatUnknownError } from 'utils/formatUnknownError';
export interface ICheckedPermission {
@ -23,7 +23,7 @@ const useProjectRoleForm = (
initialRoleDesc = '',
initialCheckedPermissions: IPermission[] = []
) => {
const { permissions } = useProjectRolePermissions({
const { permissions } = usePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
@ -53,7 +53,7 @@ const useProjectRoleForm = (
const [errors, setErrors] = useState({});
const { validateRole } = useProjectRolesApi();
const { validateRole } = useRolesApi();
useEffect(() => {
setRoleName(initialRoleName);

View File

@ -0,0 +1,138 @@
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 { ICheckedPermissions, IPermission } from 'interfaces/permissions';
import { IRoleFormErrors } from './useRoleForm';
import { ROOT_PERMISSION_CATEGORIES } from '@server/types/permissions';
import cloneDeep from 'lodash.clonedeep';
const StyledInputDescription = styled('p')(({ theme }) => ({
display: 'flex',
color: theme.palette.text.primary,
marginBottom: theme.spacing(1),
'&:not(:first-of-type)': {
marginTop: theme.spacing(4),
},
}));
const StyledInput = styled(Input)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
}));
interface IRoleFormProps {
name: string;
onSetName: (name: string) => void;
description: string;
setDescription: React.Dispatch<React.SetStateAction<string>>;
checkedPermissions: ICheckedPermissions;
setCheckedPermissions: React.Dispatch<
React.SetStateAction<ICheckedPermissions>
>;
handlePermissionChange: (permission: IPermission) => void;
permissions: IPermission[];
errors: IRoleFormErrors;
}
export const RoleForm = ({
name,
onSetName,
description,
setDescription,
checkedPermissions,
setCheckedPermissions,
handlePermissionChange,
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 categories = new Set(
categorizedPermissions.map(({ category }) => category).sort()
);
const onToggleAllPermissions = (category: string) => {
let checkedPermissionsCopy = cloneDeep(checkedPermissions);
const categoryPermissions = categorizedPermissions
.filter(({ category: pCategory }) => pCategory === category)
.map(({ permission }) => permission);
const allChecked = categoryPermissions.every(
(permission: IPermission) => checkedPermissionsCopy[permission.id]
);
if (allChecked) {
categoryPermissions.forEach((permission: IPermission) => {
delete checkedPermissionsCopy[permission.id];
});
} else {
categoryPermissions.forEach((permission: IPermission) => {
checkedPermissionsCopy[permission.id] = {
...permission,
};
});
}
setCheckedPermissions(checkedPermissionsCopy);
};
return (
<div>
<StyledInputDescription>
What is your new role name?
</StyledInputDescription>
<StyledInput
autoFocus
label="Role name"
error={Boolean(errors.name)}
errorText={errors.name}
value={name}
onChange={e => onSetName(e.target.value)}
autoComplete="off"
required
/>
<StyledInputDescription>
What is your new role description?
</StyledInputDescription>
<StyledInput
label="Role description"
value={description}
onChange={e => setDescription(e.target.value)}
autoComplete="off"
required
/>
<StyledInputDescription>
What is your role allowed to do?
</StyledInputDescription>
{[...categories].map(category => (
<PermissionAccordion
key={category}
title={`${category} permissions`}
context={category.toLowerCase()}
Icon={<UserIcon color="disabled" sx={{ mr: 1 }} />}
permissions={categorizedPermissions
.filter(
({ category: pCategory }) => pCategory === category
)
.map(({ permission }) => permission)}
checkedPermissions={checkedPermissions}
onPermissionChange={(permission: IPermission) =>
handlePermissionChange(permission)
}
onCheckAll={() => onToggleAllPermissions(category)}
/>
))}
</div>
);
};

View File

@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { IPermission, ICheckedPermissions } from 'interfaces/permissions';
import cloneDeep from 'lodash.clonedeep';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import IRole from 'interfaces/role';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
enum ErrorField {
NAME = 'name',
}
export interface IRoleFormErrors {
[ErrorField.NAME]?: string;
}
export const useRoleForm = (
initialName = '',
initialDescription = '',
initialPermissions: IPermission[] = []
) => {
const { roles } = useRoles();
const { permissions } = usePermissions({
revalidateIfStale: false,
revalidateOnReconnect: false,
revalidateOnFocus: false,
});
const rootPermissions = permissions.root.filter(
({ name }) => name !== 'ADMIN'
);
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [checkedPermissions, setCheckedPermissions] =
useState<ICheckedPermissions>({});
useEffect(() => {
setCheckedPermissions(
initialPermissions.reduce(
(acc: { [key: string]: IPermission }, curr: IPermission) => {
acc[curr.id] = curr;
return acc;
},
{}
)
);
}, [initialPermissions.length]);
const [errors, setErrors] = useState<IRoleFormErrors>({});
useEffect(() => {
setName(initialName);
}, [initialName]);
useEffect(() => {
setDescription(initialDescription);
}, [initialDescription]);
const handlePermissionChange = (permission: IPermission) => {
let checkedPermissionsCopy = cloneDeep(checkedPermissions);
if (checkedPermissionsCopy[permission.id]) {
delete checkedPermissionsCopy[permission.id];
} else {
checkedPermissionsCopy[permission.id] = { ...permission };
}
setCheckedPermissions(checkedPermissionsCopy);
};
const onToggleAllPermissions = () => {
let checkedPermissionsCopy = cloneDeep(checkedPermissions);
const allChecked = rootPermissions.every(
(permission: IPermission) => checkedPermissionsCopy[permission.id]
);
if (allChecked) {
rootPermissions.forEach((permission: IPermission) => {
delete checkedPermissionsCopy[permission.id];
});
} else {
rootPermissions.forEach((permission: IPermission) => {
checkedPermissionsCopy[permission.id] = {
...permission,
};
});
}
setCheckedPermissions(checkedPermissionsCopy);
};
const getRolePayload = () => ({
name,
description,
type: 'root-custom',
permissions: Object.values(checkedPermissions),
});
const isNameUnique = (name: string) => {
return !roles.some(
(existingRole: IRole) =>
existingRole.name !== initialName &&
existingRole.name.toLowerCase() === name.toLowerCase()
);
};
const isNotEmpty = (value: string) => value.length;
const hasPermissions = (permissions: ICheckedPermissions) =>
Object.keys(permissions).length > 0;
const clearError = (field: ErrorField) => {
setErrors(errors => ({ ...errors, [field]: undefined }));
};
const setError = (field: ErrorField, error: string) => {
setErrors(errors => ({ ...errors, [field]: error }));
};
return {
name,
description,
errors,
checkedPermissions,
rootPermissions,
setName,
setDescription,
setCheckedPermissions,
handlePermissionChange,
onToggleAllPermissions,
getRolePayload,
clearError,
setError,
isNameUnique,
isNotEmpty,
hasPermissions,
ErrorField,
};
};

View File

@ -0,0 +1,164 @@
import { Button, styled } from '@mui/material';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { useRoleForm } from '../RoleForm/useRoleForm';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { RoleForm } from '../RoleForm/RoleForm';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
import useToast from 'hooks/useToast';
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';
const StyledForm = styled('form')(() => ({
display: 'flex',
flexDirection: 'column',
height: '100%',
}));
const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
paddingTop: theme.spacing(4),
}));
const StyledCancelButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(3),
}));
interface IRoleModalProps {
roleId?: number;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const RoleModal = ({ roleId, open, setOpen }: IRoleModalProps) => {
const { role, refetch: refetchRole } = useRole(roleId?.toString());
const {
name,
setName,
description,
setDescription,
checkedPermissions,
setCheckedPermissions,
handlePermissionChange,
getRolePayload,
isNameUnique,
isNotEmpty,
hasPermissions,
rootPermissions,
errors,
setError,
clearError,
ErrorField,
} = useRoleForm(role?.name, role?.description, role?.permissions);
const { refetch: refetchRoles } = useRoles();
const { addRole, updateRole, loading } = useRolesApi();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const editing = role !== undefined;
const isValid =
isNameUnique(name) &&
isNotEmpty(name) &&
isNotEmpty(description) &&
hasPermissions(checkedPermissions);
const formatApiCode = () => {
return `curl --location --request ${editing ? 'PUT' : 'POST'} '${
uiConfig.unleashUrl
}/api/admin/roles${editing ? `/${role.id}` : ''}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getRolePayload(), undefined, 2)}'`;
};
const onSetName = (name: string) => {
clearError(ErrorField.NAME);
if (!isNameUnique(name)) {
setError(ErrorField.NAME, 'A role with that name already exists.');
}
setName(name);
};
const refetch = () => {
refetchRoles();
refetchRole();
};
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isValid) return;
try {
if (editing) {
await updateRole(role.id, getRolePayload());
} else {
await addRole(getRolePayload());
}
setToastData({
title: `Role ${editing ? 'updated' : 'added'} successfully`,
type: 'success',
});
refetch();
setOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
return (
<SidebarModal
open={open}
onClose={() => {
setOpen(false);
}}
label={editing ? 'Edit role' : 'New 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"
documentationLinkLabel="Roles documentation"
formatApiCode={formatApiCode}
>
<StyledForm onSubmit={onSubmit}>
<RoleForm
name={name}
onSetName={onSetName}
description={description}
setDescription={setDescription}
checkedPermissions={checkedPermissions}
setCheckedPermissions={setCheckedPermissions}
handlePermissionChange={handlePermissionChange}
permissions={rootPermissions}
errors={errors}
/>
<StyledButtonContainer>
<Button
type="submit"
variant="contained"
color="primary"
disabled={!isValid}
>
{editing ? 'Save' : 'Add'} role
</Button>
<StyledCancelButton
onClick={() => {
setOpen(false);
}}
>
Cancel
</StyledCancelButton>
</StyledButtonContainer>
</StyledForm>
</FormTemplate>
</SidebarModal>
);
};

View File

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

View File

@ -0,0 +1,127 @@
import { Alert, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import IRole from 'interfaces/role';
import { RoleDeleteDialogUsers } from './RoleDeleteDialogUsers/RoleDeleteDialogUsers';
import { RoleDeleteDialogServiceAccounts } from './RoleDeleteDialogServiceAccounts/RoleDeleteDialogServiceAccounts';
import { useGroups } from 'hooks/api/getters/useGroups/useGroups';
import { RoleDeleteDialogGroups } from './RoleDeleteDialogGroups/RoleDeleteDialogGroups';
const StyledTableContainer = styled('div')(({ theme }) => ({
marginTop: theme.spacing(1.5),
}));
const StyledLabel = styled('p')(({ theme }) => ({
marginTop: theme.spacing(3),
}));
interface IRoleDeleteDialogProps {
role?: IRole;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
onConfirm: (role: IRole) => void;
}
export const RoleDeleteDialog = ({
role,
open,
setOpen,
onConfirm,
}: IRoleDeleteDialogProps) => {
const { users } = useUsers();
const { serviceAccounts } = useServiceAccounts();
const { groups } = useGroups();
const roleUsers = users.filter(({ rootRole }) => rootRole === role?.id);
const roleServiceAccounts = serviceAccounts.filter(
({ rootRole }) => rootRole === role?.id
);
const roleGroups = groups?.filter(({ rootRole }) => rootRole === role?.id);
const entitiesWithRole = Boolean(
roleUsers.length || roleServiceAccounts.length || roleGroups?.length
);
return (
<Dialogue
title="Delete role?"
open={open}
primaryButtonText="Delete role"
secondaryButtonText="Cancel"
disabledPrimaryButton={entitiesWithRole}
onClick={() => onConfirm(role!)}
onClose={() => {
setOpen(false);
}}
>
<ConditionallyRender
condition={entitiesWithRole}
show={
<>
<Alert severity="error">
You are not allowed to delete a role that is
currently in use. Please change the role of the
following entities first:
</Alert>
<ConditionallyRender
condition={Boolean(roleUsers.length)}
show={
<>
<StyledLabel>
Users ({roleUsers.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogUsers
users={roleUsers}
/>
</StyledTableContainer>
</>
}
/>
<ConditionallyRender
condition={Boolean(roleServiceAccounts.length)}
show={
<>
<StyledLabel>
Service accounts (
{roleServiceAccounts.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogServiceAccounts
serviceAccounts={
roleServiceAccounts
}
/>
</StyledTableContainer>
</>
}
/>
<ConditionallyRender
condition={Boolean(roleGroups?.length)}
show={
<>
<StyledLabel>
Groups ({roleGroups?.length}):
</StyledLabel>
<StyledTableContainer>
<RoleDeleteDialogGroups
groups={roleGroups!}
/>
</StyledTableContainer>
</>
}
/>
</>
}
elseShow={
<p>
You are about to delete role:{' '}
<strong>{role?.name}</strong>
</p>
}
/>
</Dialogue>
);
};

View File

@ -0,0 +1,84 @@
import { VirtualizedTable } from 'component/common/Table';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { useMemo, useState } from 'react';
import { useTable, useSortBy, useFlexLayout, Column } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { IGroup } from 'interfaces/group';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string>
>;
interface IRoleDeleteDialogGroupsProps {
groups: IGroup[];
}
export const RoleDeleteDialogGroups = ({
groups,
}: IRoleDeleteDialogGroupsProps) => {
const [initialState] = useState(() => ({
sortBy: [{ id: 'createdAt' }],
}));
const columns = useMemo(
() =>
[
{
id: 'name',
Header: 'Name',
accessor: (row: any) => row.name || '',
minWidth: 200,
Cell: ({ row: { original: group } }: any) => (
<HighlightCell
value={group.name}
subtitle={group.description}
/>
),
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
width: 120,
maxWidth: 120,
},
{
id: 'users',
Header: 'Users',
accessor: (row: IGroup) =>
row.users.length === 1
? '1 user'
: `${row.users.length} users`,
Cell: TextCell,
maxWidth: 150,
},
] as Column<IGroup>[],
[]
);
const { headerGroups, rows, prepareRow } = useTable(
{
columns,
data: groups,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
},
useSortBy,
useFlexLayout
);
return (
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
);
};

View File

@ -0,0 +1,109 @@
import { VirtualizedTable } from 'component/common/Table';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { useMemo, useState } from 'react';
import { useTable, useSortBy, useFlexLayout, Column } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { IServiceAccount } from 'interfaces/service-account';
import { ServiceAccountTokensCell } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokensCell/ServiceAccountTokensCell';
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string>
>;
interface IRoleDeleteDialogServiceAccountsProps {
serviceAccounts: IServiceAccount[];
}
export const RoleDeleteDialogServiceAccounts = ({
serviceAccounts,
}: IRoleDeleteDialogServiceAccountsProps) => {
const [initialState] = useState(() => ({
sortBy: [{ id: 'seenAt' }],
}));
const columns = useMemo(
() =>
[
{
id: 'name',
Header: 'Name',
accessor: (row: any) => row.name || '',
minWidth: 200,
Cell: ({ row: { original: serviceAccount } }: any) => (
<HighlightCell
value={serviceAccount.name}
subtitle={serviceAccount.username}
/>
),
},
{
id: 'tokens',
Header: 'Tokens',
accessor: (row: IServiceAccount) =>
row.tokens
?.map(({ description }) => description)
.join('\n') || '',
Cell: ({
row: { original: serviceAccount },
value,
}: {
row: { original: IServiceAccount };
value: string;
}) => (
<ServiceAccountTokensCell
serviceAccount={serviceAccount}
value={value}
/>
),
maxWidth: 100,
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
width: 120,
maxWidth: 120,
},
{
id: 'seenAt',
Header: 'Last seen',
accessor: (row: IServiceAccount) =>
row.tokens.sort((a, b) => {
const aSeenAt = new Date(a.seenAt || 0);
const bSeenAt = new Date(b.seenAt || 0);
return bSeenAt?.getTime() - aSeenAt?.getTime();
})[0]?.seenAt,
Cell: TimeAgoCell,
sortType: 'date',
maxWidth: 150,
},
] as Column<IServiceAccount>[],
[]
);
const { headerGroups, rows, prepareRow } = useTable(
{
columns,
data: serviceAccounts,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
},
useSortBy,
useFlexLayout
);
return (
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
);
};

View File

@ -0,0 +1,88 @@
import { VirtualizedTable } from 'component/common/Table';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { useMemo, useState } from 'react';
import { useTable, useSortBy, useFlexLayout, Column } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { IUser } from 'interfaces/user';
export type PageQueryType = Partial<
Record<'sort' | 'order' | 'search', string>
>;
interface IRoleDeleteDialogUsersProps {
users: IUser[];
}
export const RoleDeleteDialogUsers = ({
users,
}: IRoleDeleteDialogUsersProps) => {
const [initialState] = useState(() => ({
sortBy: [{ id: 'last-login' }],
}));
const columns = useMemo(
() =>
[
{
id: 'name',
Header: 'Name',
accessor: (row: any) => row.name || '',
minWidth: 200,
Cell: ({ row: { original: user } }: any) => (
<HighlightCell
value={user.name}
subtitle={user.email || user.username}
/>
),
},
{
Header: 'Created',
accessor: 'createdAt',
Cell: DateCell,
sortType: 'date',
width: 120,
maxWidth: 120,
},
{
id: 'last-login',
Header: 'Last login',
accessor: (row: any) => row.seenAt || '',
Cell: ({ row: { original: user } }: any) => (
<TimeAgoCell
value={user.seenAt}
emptyText="Never"
title={date => `Last login: ${date}`}
/>
),
sortType: 'date',
maxWidth: 150,
},
] as Column<IUser>[],
[]
);
const { headerGroups, rows, prepareRow } = useTable(
{
columns,
data: users,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
},
useSortBy,
useFlexLayout
);
return (
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
);
};

View File

@ -0,0 +1,31 @@
import { VFC } from 'react';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
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';
interface IRolePermissionsCellProps {
row: { original: IRole };
}
export const RolePermissionsCell: VFC<IRolePermissionsCellProps> = ({
row,
}) => {
const { original: rowRole } = row;
const { role } = useRole(rowRole.id.toString());
if (!role || role.type === 'root') return null;
return (
<TextCell>
<TooltipLink
tooltip={<RoleDescription roleId={rowRole.id} tooltip />}
>
{role.permissions?.length === 1
? '1 permission'
: `${role.permissions?.length} permissions`}
</TooltipLink>
</TextCell>
);
};

View File

@ -0,0 +1,58 @@
import { Delete, Edit } from '@mui/icons-material';
import { Box, styled } from '@mui/material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import IRole from 'interfaces/role';
import { VFC } from 'react';
const StyledBox = styled(Box)(() => ({
display: 'flex',
justifyContent: 'center',
}));
const DEFAULT_ROOT_ROLE = 'root';
interface IRolesActionsCellProps {
role: IRole;
onEdit: (event: React.SyntheticEvent) => void;
onDelete: (event: React.SyntheticEvent) => void;
}
export const RolesActionsCell: VFC<IRolesActionsCellProps> = ({
role,
onEdit,
onDelete,
}) => {
const defaultRole = role.type === DEFAULT_ROOT_ROLE;
return (
<StyledBox>
<PermissionIconButton
data-loading
onClick={onEdit}
permission={ADMIN}
disabled={defaultRole}
tooltipProps={{
title: defaultRole
? 'You cannot edit a predefined role'
: 'Edit role',
}}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
data-loading
onClick={onDelete}
permission={ADMIN}
disabled={defaultRole}
tooltipProps={{
title: defaultRole
? 'You cannot remove a predefined role'
: 'Remove role',
}}
>
<Delete />
</PermissionIconButton>
</StyledBox>
);
};

View File

@ -0,0 +1,26 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
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';
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
interface IRolesCellProps {
role: IRole;
}
export const RolesCell = ({ role }: IRolesCellProps) => (
<HighlightCell
value={role.name}
subtitle={role.description}
afterTitle={
<ConditionallyRender
condition={role.type === 'root'}
show={<StyledBadge color="success">Predefined</StyledBadge>}
/>
}
/>
);

View File

@ -0,0 +1,233 @@
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 useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { Button, useMediaQuery } from '@mui/material';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { useFlexLayout, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { useSearch } from 'hooks/useSearch';
import { IconCell } from 'component/common/Table/cells/IconCell/IconCell';
import { SupervisedUserCircle } from '@mui/icons-material';
import { RolesActionsCell } from './RolesActionsCell/RolesActionsCell';
import { RolesCell } from './RolesCell/RolesCell';
import { RoleDeleteDialog } from './RoleDeleteDialog/RoleDeleteDialog';
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';
export const RolesTable = () => {
const { setToastData, setToastApiError } = useToast();
const { roles, refetch, loading } = useRoles();
const { removeRole } = useRolesApi();
const [searchValue, setSearchValue] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<IRole>();
const onDeleteConfirm = async (role: IRole) => {
try {
await removeRole(role.id);
setToastData({
title: `${role.name} has been deleted`,
type: 'success',
});
refetch();
setDeleteOpen(false);
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const columns = useMemo(
() => [
{
id: 'Icon',
Cell: () => (
<IconCell
icon={<SupervisedUserCircle color="disabled" />}
/>
),
disableGlobalFilter: true,
maxWidth: 50,
},
{
Header: 'Role',
accessor: 'name',
Cell: ({ row: { original: role } }: any) => (
<RolesCell role={role} />
),
searchable: true,
minWidth: 100,
},
{
id: 'permissions',
Header: 'Permissions',
Cell: RolePermissionsCell,
maxWidth: 140,
},
{
Header: 'Actions',
id: 'Actions',
align: 'center',
Cell: ({ row: { original: role } }: any) => (
<RolesActionsCell
role={role}
onEdit={() => {
setSelectedRole(role);
setModalOpen(true);
}}
onDelete={() => {
setSelectedRole(role);
setDeleteOpen(true);
}}
/>
),
width: 150,
disableSortBy: true,
},
// Always hidden -- for search
{
accessor: 'description',
Header: 'Description',
searchable: true,
},
],
[]
);
const [initialState] = useState({
sortBy: [{ id: 'name' }],
hiddenColumns: ['description'],
});
const { data, getSearchText } = useSearch(columns, searchValue, roles);
const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable(
{
columns: columns as any,
data,
initialState,
sortTypes,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
defaultColumn: {
Cell: TextCell,
},
},
useSortBy,
useFlexLayout
);
useConditionallyHiddenColumns(
[
{
condition: isSmallScreen,
columns: ['Icon'],
},
],
setHiddenColumns,
columns
);
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Roles (${rows.length})`}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
<PageHeader.Divider />
</>
}
/>
<Button
variant="contained"
color="primary"
onClick={() => {
setSelectedRole(undefined);
setModalOpen(true);
}}
>
New role
</Button>
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<SearchHighlightProvider value={getSearchText(searchValue)}>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={rows.length === 0}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No roles found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No roles available. Get started by adding one.
</TablePlaceholder>
}
/>
}
/>
<RoleModal
roleId={selectedRole?.id}
open={modalOpen}
setOpen={setModalOpen}
/>
<RoleDeleteDialog
role={selectedRole}
open={deleteOpen}
setOpen={setDeleteOpen}
onConfirm={onDeleteConfirm}
/>
</PageContent>
);
};

View File

@ -6,7 +6,6 @@ import {
Radio,
RadioGroup,
styled,
Typography,
} from '@mui/material';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
@ -33,6 +32,8 @@ import { useServiceAccountTokensApi } from 'hooks/api/actions/useServiceAccountT
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens';
import { IServiceAccount } from 'interfaces/service-account';
import { RoleSelect } from 'component/common/RoleSelect/RoleSelect';
import IRole from 'interfaces/role';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -59,14 +60,9 @@ const StyledInput = styled(Input)(({ theme }) => ({
maxWidth: theme.spacing(50),
}));
const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({
margin: theme.spacing(0.5, 0),
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(2),
}));
const StyledRoleRadio = styled(Radio)(({ theme }) => ({
marginRight: theme.spacing(2),
const StyledRoleSelect = styled(RoleSelect)(({ theme }) => ({
width: '100%',
maxWidth: theme.spacing(50),
}));
const StyledSecondaryContainer = styled('div')(({ theme }) => ({
@ -133,7 +129,7 @@ export const ServiceAccountModal = ({
const [name, setName] = useState('');
const [username, setUsername] = useState('');
const [rootRole, setRootRole] = useState(1);
const [rootRole, setRootRole] = useState<IRole | null>(null);
const [tokenGeneration, setTokenGeneration] = useState<TokenGeneration>(
TokenGeneration.LATER
);
@ -160,7 +156,9 @@ export const ServiceAccountModal = ({
useEffect(() => {
setName(serviceAccount?.name || '');
setUsername(serviceAccount?.username || '');
setRootRole(serviceAccount?.rootRole || 1);
setRootRole(
roles.find(({ id }) => id === serviceAccount?.rootRole) || null
);
setTokenGeneration(TokenGeneration.LATER);
setErrors({});
@ -173,7 +171,7 @@ export const ServiceAccountModal = ({
const getServiceAccountPayload = (): IServiceAccountPayload => ({
name,
username,
rootRole,
rootRole: rootRole?.id || 0,
});
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
@ -226,6 +224,7 @@ export const ServiceAccountModal = ({
(serviceAccount: IServiceAccount) =>
serviceAccount.username === value
);
const isRoleValid = rootRole !== null;
const isPATValid =
tokenGeneration === TokenGeneration.LATER ||
(isNotEmpty(patDescription) && patExpiresAt > new Date());
@ -233,6 +232,7 @@ export const ServiceAccountModal = ({
isNotEmpty(name) &&
isNotEmpty(username) &&
(editing || isUnique(username)) &&
isRoleValid &&
isPATValid;
const suggestUsername = () => {
@ -305,39 +305,11 @@ export const ServiceAccountModal = ({
<StyledInputDescription>
What is your service account allowed to do?
</StyledInputDescription>
<FormControl>
<RadioGroup
name="rootRole"
value={rootRole || ''}
onChange={e => setRootRole(+e.target.value)}
data-loading
>
{roles
.sort((a, b) => (a.name < b.name ? -1 : 1))
.map(role => (
<StyledRoleBox
key={`role-${role.id}`}
labelPlacement="end"
label={
<div>
<strong>{role.name}</strong>
<Typography variant="body2">
{role.description}
</Typography>
</div>
}
control={
<StyledRoleRadio
checked={
role.id === rootRole
}
/>
}
value={role.id}
/>
))}
</RadioGroup>
</FormControl>
<StyledRoleSelect
value={rootRole}
setValue={setRootRole}
required
/>
<ConditionallyRender
condition={!editing}
show={

View File

@ -14,7 +14,7 @@ const StyledItem = styled(Typography)(({ theme }) => ({
interface IServiceAccountTokensCellProps {
serviceAccount: IServiceAccount;
value: string;
onCreateToken: () => void;
onCreateToken?: () => void;
}
export const ServiceAccountTokensCell: VFC<IServiceAccountTokensCellProps> = ({
@ -24,8 +24,10 @@ export const ServiceAccountTokensCell: VFC<IServiceAccountTokensCellProps> = ({
}) => {
const { searchQuery } = useSearchHighlightContext();
if (!serviceAccount.tokens || serviceAccount.tokens.length === 0)
return <LinkCell title="Create token" onClick={onCreateToken} />;
if (!serviceAccount.tokens || serviceAccount.tokens.length === 0) {
if (!onCreateToken) return <TextCell>0 tokens</TextCell>;
else return <LinkCell title="Create token" onClick={onCreateToken} />;
}
return (
<TextCell>

View File

@ -28,6 +28,7 @@ import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAc
import { ServiceAccountTokensCell } from './ServiceAccountTokensCell/ServiceAccountTokensCell';
import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
import { IServiceAccount } from 'interfaces/service-account';
import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell';
export const ServiceAccountsTable = () => {
const { setToastData, setToastApiError } = useToast();
@ -92,6 +93,9 @@ export const ServiceAccountsTable = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
Cell: ({ row: { original: serviceAccount }, value }: any) => (
<RoleCell value={value} roleId={serviceAccount.rootRole} />
),
maxWidth: 120,
},
{

View File

@ -1,19 +1,11 @@
import Input from 'component/common/Input/Input';
import {
FormControlLabel,
Button,
RadioGroup,
FormControl,
Typography,
Radio,
Switch,
styled,
} from '@mui/material';
import { Button, FormControl, Typography, Switch, styled } from '@mui/material';
import React from 'react';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { EDIT } from 'constants/misc';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { RoleSelect } from 'component/common/RoleSelect/RoleSelect';
import IRole from 'interfaces/role';
const StyledForm = styled('form')(() => ({
display: 'flex',
@ -38,16 +30,6 @@ const StyledRoleSubtitle = styled(Typography)(({ theme }) => ({
margin: theme.spacing(1, 0),
}));
const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({
margin: theme.spacing(0.5, 0),
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(2),
}));
const StyledRoleRadio = styled(Radio)(({ theme }) => ({
marginRight: theme.spacing(2),
}));
const StyledFlexRow = styled('div')(() => ({
display: 'flex',
alignItems: 'center',
@ -66,12 +48,12 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({
interface IUserForm {
email: string;
name: string;
rootRole: number;
rootRole: IRole | null;
sendEmail: boolean;
setEmail: React.Dispatch<React.SetStateAction<string>>;
setName: React.Dispatch<React.SetStateAction<string>>;
setSendEmail: React.Dispatch<React.SetStateAction<boolean>>;
setRootRole: React.Dispatch<React.SetStateAction<number>>;
setRootRole: React.Dispatch<React.SetStateAction<IRole | null>>;
handleSubmit: (e: any) => void;
handleCancel: () => void;
errors: { [key: string]: string };
@ -95,19 +77,8 @@ const UserForm: React.FC<IUserForm> = ({
clearErrors,
mode,
}) => {
const { roles } = useUsers();
const { uiConfig } = useUiConfig();
// @ts-expect-error
const sortRoles = (a, b) => {
if (b.name[0] < a.name[0]) {
return 1;
} else if (a.name[0] < b.name[0]) {
return -1;
}
return 0;
};
return (
<StyledForm onSubmit={handleSubmit}>
<StyledContainer>
@ -132,39 +103,10 @@ const UserForm: React.FC<IUserForm> = ({
errorText={errors.email}
onFocus={() => clearErrors()}
/>
<FormControl>
<StyledRoleSubtitle variant="subtitle1" data-loading>
What is your team member allowed to do?
</StyledRoleSubtitle>
<RadioGroup
name="rootRole"
value={rootRole || ''}
onChange={e => setRootRole(+e.target.value)}
data-loading
>
{/* @ts-expect-error */}
{roles.sort(sortRoles).map(role => (
<StyledRoleBox
key={`role-${role.id}`}
labelPlacement="end"
label={
<div>
<strong>{role.name}</strong>
<Typography variant="body2">
{role.description}
</Typography>
</div>
}
control={
<StyledRoleRadio
checked={role.id === rootRole}
/>
}
value={role.id}
/>
))}
</RadioGroup>
</FormControl>
<StyledRoleSubtitle variant="subtitle1" data-loading>
What is your team member allowed to do?
</StyledRoleSubtitle>
<RoleSelect value={rootRole} setValue={setRootRole} required />
<ConditionallyRender
condition={mode !== EDIT && Boolean(uiConfig?.emailEnabled)}
show={

View File

@ -34,6 +34,7 @@ import { Search } from 'component/common/Search/Search';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { UserLimitWarning } from './UserLimitWarning/UserLimitWarning';
import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell';
const UsersList = () => {
const navigate = useNavigate();
@ -126,6 +127,9 @@ const UsersList = () => {
accessor: (row: any) =>
roles.find((role: IRole) => role.id === row.rootRole)
?.name || '',
Cell: ({ row: { original: user }, value }: any) => (
<RoleCell value={value} roleId={user.rootRole} />
),
disableGlobalFilter: true,
maxWidth: 120,
},

View File

@ -1,17 +1,22 @@
import { useEffect, useState } from 'react';
import { useUsers } from 'hooks/api/getters/useUsers/useUsers';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import IRole from 'interfaces/role';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
const useCreateUserForm = (
initialName = '',
initialEmail = '',
initialRootRole = 1
initialRootRole = null
) => {
const { uiConfig } = useUiConfig();
const { roles } = useRoles();
const [name, setName] = useState(initialName);
const [email, setEmail] = useState(initialEmail);
const [sendEmail, setSendEmail] = useState(false);
const [rootRole, setRootRole] = useState(initialRootRole);
const [rootRole, setRootRole] = useState<IRole | null>(
roles.find(({ id }) => id === initialRootRole) || null
);
const [errors, setErrors] = useState({});
const { users } = useUsers();
@ -29,7 +34,7 @@ const useCreateUserForm = (
}, [uiConfig?.emailEnabled]);
useEffect(() => {
setRootRole(initialRootRole);
setRootRole(roles.find(({ id }) => id === initialRootRole) || null);
}, [initialRootRole]);
const getAddUserPayload = () => {
@ -37,7 +42,7 @@ const useCreateUserForm = (
name: name,
email: email,
sendEmail: sendEmail,
rootRole: rootRole,
rootRole: rootRole?.id || 0,
};
};
@ -54,7 +59,6 @@ const useCreateUserForm = (
};
const validateEmail = () => {
// @ts-expect-error
if (users.some(user => user['email'] === email)) {
setErrors(prev => ({ ...prev, email: 'Email already exists' }));
return false;

View File

@ -65,6 +65,7 @@ export interface IHtmlTooltipProps extends TooltipProps {
fontSize?: string;
}
export const HtmlTooltip = (props: IHtmlTooltipProps) => (
<StyledHtmlTooltip {...props}>{props.children}</StyledHtmlTooltip>
);
export const HtmlTooltip = (props: IHtmlTooltipProps) => {
if (!Boolean(props.title)) return props.children;
return <StyledHtmlTooltip {...props}>{props.children}</StyledHtmlTooltip>;
};

View File

@ -0,0 +1,27 @@
import { Badge } from 'component/common/Badge/Badge';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { useRole } from 'hooks/api/getters/useRole/useRole';
import { Person as UserIcon } from '@mui/icons-material';
import { RoleDescription } from 'component/common/RoleDescription/RoleDescription';
interface IRoleBadgeProps {
roleId: number;
}
export const RoleBadge = ({ roleId }: IRoleBadgeProps) => {
const { role } = useRole(roleId.toString());
if (!role) return null;
return (
<HtmlTooltip title={<RoleDescription roleId={roleId} tooltip />}>
<Badge
color="success"
icon={<UserIcon />}
sx={{ cursor: 'pointer' }}
>
{role.name}
</Badge>
</HtmlTooltip>
);
};

View File

@ -0,0 +1,100 @@
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';
const StyledDescription = styled('div', {
shouldForwardProp: prop => prop !== 'tooltip',
})<{ tooltip?: boolean }>(({ theme, tooltip }) => ({
width: '100%',
maxWidth: theme.spacing(50),
padding: tooltip ? theme.spacing(1) : theme.spacing(3),
backgroundColor: tooltip
? theme.palette.background.paper
: theme.palette.neutral.light,
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
borderRadius: theme.shape.borderRadiusMedium,
}));
const StyledDescriptionBlock = styled('div')(({ theme }) => ({
marginTop: theme.spacing(2),
}));
const StyledDescriptionHeader = styled('p')(({ theme }) => ({
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallBody,
fontWeight: theme.fontWeight.bold,
marginBottom: theme.spacing(1),
}));
const StyledDescriptionSubHeader = styled('p')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
marginTop: theme.spacing(1),
}));
interface IRoleDescriptionProps {
roleId: number;
tooltip?: boolean;
className?: string;
sx?: SxProps<Theme>;
}
export const RoleDescription = ({
roleId,
tooltip,
...rest
}: IRoleDescriptionProps) => {
const { role } = useRole(roleId.toString());
if (!role) return null;
const { name, description, permissions } = role;
const categorizedPermissions = [...new Set(permissions)].map(permission => {
const category = ROOT_PERMISSION_CATEGORIES.find(category =>
category.permissions.includes(permission.name)
);
return {
category: category ? category.label : 'Other',
permission,
};
});
const categories = new Set(
categorizedPermissions.map(({ category }) => category).sort()
);
return (
<StyledDescription tooltip={tooltip} {...rest}>
<StyledDescriptionHeader sx={{ mb: 0 }}>
{name}
</StyledDescriptionHeader>
<StyledDescriptionSubHeader>
{description}
</StyledDescriptionSubHeader>
<ConditionallyRender
condition={
categorizedPermissions.length > 0 && role.type !== 'root'
}
show={() =>
[...categories].map(category => (
<StyledDescriptionBlock key={category}>
<StyledDescriptionHeader>
{category}
</StyledDescriptionHeader>
{categorizedPermissions
.filter(({ category: c }) => c === category)
.map(({ permission }) => (
<p key={permission.id}>
{permission.displayName}
</p>
))}
</StyledDescriptionBlock>
))
}
/>
</StyledDescription>
);
};

View File

@ -0,0 +1,71 @@
import {
Autocomplete,
AutocompleteProps,
TextField,
styled,
} from '@mui/material';
import { useRoles } from 'hooks/api/getters/useRoles/useRoles';
import IRole from 'interfaces/role';
import { RoleDescription } from '../RoleDescription/RoleDescription';
import { ConditionallyRender } from '../ConditionallyRender/ConditionallyRender';
const StyledRoleOption = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
'& > span:last-of-type': {
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
},
}));
interface IRoleSelectProps
extends Partial<AutocompleteProps<IRole, false, false, false>> {
value: IRole | null;
setValue: (role: IRole | null) => void;
required?: boolean;
}
export const RoleSelect = ({
value,
setValue,
required,
...rest
}: IRoleSelectProps) => {
const { roles } = useRoles();
const renderRoleOption = (
props: React.HTMLAttributes<HTMLLIElement>,
option: IRole
) => (
<li {...props}>
<StyledRoleOption>
<span>{option.name}</span>
<span>{option.description}</span>
</StyledRoleOption>
</li>
);
return (
<>
<Autocomplete
openOnFocus
size="small"
value={value}
onChange={(_, role) => setValue(role || null)}
options={roles}
renderOption={renderRoleOption}
getOptionLabel={option => option.name}
renderInput={params => (
<TextField {...params} label="Role" required={required} />
)}
{...rest}
/>
<ConditionallyRender
condition={Boolean(value)}
show={() => (
<RoleDescription sx={{ marginTop: 1 }} roleId={value!.id} />
)}
/>
</>
);
};

View File

@ -7,6 +7,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
interface IHighlightCellProps {
value: string;
subtitle?: string;
afterTitle?: React.ReactNode;
}
const StyledContainer = styled(Box)(({ theme }) => ({
@ -40,6 +41,7 @@ const StyledSubtitle = styled('span')(({ theme }) => ({
export const HighlightCell: VFC<IHighlightCellProps> = ({
value,
subtitle,
afterTitle,
}) => {
const { searchQuery } = useSearchHighlightContext();
@ -53,6 +55,7 @@ export const HighlightCell: VFC<IHighlightCellProps> = ({
data-loading
>
<Highlighter search={searchQuery}>{value}</Highlighter>
{afterTitle}
</StyledTitle>
<ConditionallyRender
condition={Boolean(subtitle)}

View File

@ -0,0 +1,28 @@
import { VFC } from 'react';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { RoleDescription } from 'component/common/RoleDescription/RoleDescription';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface IRoleCellProps {
roleId: number;
value: string;
}
export const RoleCell: VFC<IRoleCellProps> = ({ roleId, value }) => {
const { isEnterprise, uiConfig } = useUiConfig();
if (isEnterprise() && uiConfig.flags.customRootRoles) {
return (
<TextCell>
<TooltipLink
tooltip={<RoleDescription roleId={roleId} tooltip />}
>
{value}
</TooltipLink>
</TextCell>
);
}
return <TextCell>{value}</TextCell>;
};

View File

@ -9,7 +9,7 @@ import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmen
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
@ -25,7 +25,7 @@ const CreateEnvironment = () => {
const { environments } = useEnvironments();
const canCreateMoreEnvs = environments.length < ENV_LIMIT;
const { createEnvironment, loading } = useEnvironmentApi();
const { refetch } = useProjectRolePermissions();
const { refetch } = usePermissions();
const {
name,
setName,

View File

@ -2,7 +2,7 @@ import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { UpdateButton } from 'component/common/UpdateButton/UpdateButton';
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import useEnvironment from 'hooks/api/getters/useEnvironment/useEnvironment';
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { useNavigate } from 'react-router-dom';
@ -23,7 +23,7 @@ const EditEnvironment = () => {
const navigate = useNavigate();
const { name, type, setName, setType, errors, clearErrors } =
useEnvironmentForm(environment.name, environment.type);
const { refetch } = useProjectRolePermissions();
const { refetch } = usePermissions();
const editPayload = () => {
return {

View File

@ -4,7 +4,7 @@ import { useState } from 'react';
import { IEnvironment } from 'interfaces/environments';
import { formatUnknownError } from 'utils/formatUnknownError';
import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi';
import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions';
import usePermissions from 'hooks/api/getters/usePermissions/usePermissions';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import useToast from 'hooks/useToast';
import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover';
@ -25,7 +25,7 @@ export const EnvironmentActionCell = ({
const navigate = useNavigate();
const { setToastApiError, setToastData } = useToast();
const { environments, refetchEnvironments } = useEnvironments();
const { refetch: refetchPermissions } = useProjectRolePermissions();
const { refetch: refetchPermissions } = usePermissions();
const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } =
useEnvironmentApi();

View File

@ -465,6 +465,12 @@ export const adminMenuRoutes: INavigationMenuItem[] = [
},
{
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

@ -1,6 +1,6 @@
import { styled, SxProps, Theme } from '@mui/material';
import { ForwardedRef, forwardRef, useMemo, VFC } from 'react';
import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole';
import { useRole } from 'hooks/api/getters/useRole/useRole';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess';
import { ProjectRoleDescriptionProjectPermissions } from './ProjectRoleDescriptionProjectPermissions/ProjectRoleDescriptionProjectPermissions';
@ -64,13 +64,13 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> =
}: IProjectRoleDescriptionProps,
ref: ForwardedRef<HTMLDivElement>
) => {
const { role } = useProjectRole(roleId.toString());
const { role } = useRole(roleId.toString());
const { access } = useProjectAccess(projectId);
const accessRole = access?.roles.find(role => role.id === roleId);
const environments = useMemo(() => {
const environments = new Set<string>();
role.permissions
role?.permissions
?.filter((permission: any) => permission.environment)
.forEach((permission: any) => {
environments.add(permission.environment);
@ -79,7 +79,7 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> =
}, [role]);
const projectPermissions = useMemo(() => {
return role.permissions?.filter(
return role?.permissions?.filter(
(permission: any) => !permission.environment
);
}, [role]);
@ -92,7 +92,9 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> =
ref={ref}
>
<ConditionallyRender
condition={role.permissions?.length > 0}
condition={Boolean(
role?.permissions && role?.permissions?.length > 0
)}
show={
<>
<ConditionallyRender
@ -107,7 +109,7 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> =
<StyledDescriptionBlock>
<ProjectRoleDescriptionProjectPermissions
permissions={
role.permissions
role?.permissions || []
}
/>
</StyledDescriptionBlock>
@ -132,7 +134,8 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> =
environment
}
permissions={
role.permissions
role?.permissions ||
[]
}
/>
</StyledDescriptionBlock>

View File

@ -18,6 +18,7 @@ export const CREATE_ADDON = 'CREATE_ADDON';
export const UPDATE_ADDON = 'UPDATE_ADDON';
export const DELETE_ADDON = 'DELETE_ADDON';
export const CREATE_API_TOKEN = 'CREATE_API_TOKEN';
export const UPDATE_API_TOKEN = 'UPDATE_API_TOKEN';
export const DELETE_API_TOKEN = 'DELETE_API_TOKEN';
export const READ_API_TOKEN = 'READ_API_TOKEN';
export const DELETE_ENVIRONMENT = 'DELETE_ENVIRONMENT';

View File

@ -14,11 +14,11 @@ import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { IUser } from 'interfaces/user';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
import { useNavigate } from 'react-router-dom';
import { PageContent } from 'component/common/PageContent/PageContent';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { RoleBadge } from 'component/common/RoleBadge/RoleBadge';
const StyledHeader = styled('div')(({ theme }) => ({
display: 'flex',
@ -134,21 +134,17 @@ export const ProfileTab = ({ user }: IProfileTabProps) => {
<StyledSectionLabel>Access</StyledSectionLabel>
<StyledAccess>
<Box sx={{ width: '50%' }}>
<Typography variant="body2">Your root role</Typography>
<Tooltip
title={profile?.rootRole.description || ''}
arrow
placement="bottom-end"
describeChild
>
<Badge
color="success"
icon={<InfoOutlinedIcon />}
iconRight
>
{profile?.rootRole.name}
</Badge>
</Tooltip>
<ConditionallyRender
condition={Boolean(profile?.rootRole)}
show={() => (
<>
<Typography variant="body2">
Your root role
</Typography>
<RoleBadge roleId={profile?.rootRole.id!} />
</>
)}
/>
</Box>
<Box>
<Typography variant="body2">Projects</Typography>

View File

@ -1,88 +0,0 @@
import { IPermission } from 'interfaces/project';
import useAPI from '../useApi/useApi';
interface ICreateRolePayload {
name: string;
description: string;
permissions: IPermission[];
}
const useProjectRolesApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const createRole = async (payload: ICreateRolePayload) => {
const path = `api/admin/roles`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const editRole = async (id: string, payload: ICreateRolePayload) => {
const path = `api/admin/roles/${id}`;
const req = createRequest(path, {
method: 'PUT',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const validateRole = async (payload: ICreateRolePayload) => {
const path = `api/admin/roles/validate`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
const deleteRole = async (id: number) => {
const path = `api/admin/roles/${id}`;
const req = createRequest(path, {
method: 'DELETE',
});
try {
const res = await makeRequest(req.caller, req.id);
return res;
} catch (e) {
throw e;
}
};
return {
createRole,
deleteRole,
editRole,
validateRole,
errors,
loading,
};
};
export default useProjectRolesApi;

View File

@ -0,0 +1,77 @@
import { IPermission } from 'interfaces/permissions';
import useAPI from '../useApi/useApi';
interface IRolePayload {
name: string;
description: string;
permissions: IPermission[];
}
export const useRolesApi = () => {
const { loading, makeRequest, createRequest, errors } = useAPI({
propagateErrors: true,
});
const addRole = async (role: IRolePayload) => {
const requestId = 'addRole';
const req = createRequest(
'api/admin/roles',
{
method: 'POST',
body: JSON.stringify(role),
},
requestId
);
const response = await makeRequest(req.caller, req.id);
return await response.json();
};
const updateRole = async (roleId: number, role: IRolePayload) => {
const requestId = 'updateRole';
const req = createRequest(
`api/admin/roles/${roleId}`,
{
method: 'PUT',
body: JSON.stringify(role),
},
requestId
);
await makeRequest(req.caller, req.id);
};
const removeRole = async (roleId: number) => {
const requestId = 'removeRole';
const req = createRequest(
`api/admin/roles/${roleId}`,
{ method: 'DELETE' },
requestId
);
await makeRequest(req.caller, req.id);
};
const validateRole = async (payload: IRolePayload) => {
const requestId = 'validateRole';
const req = createRequest(
'api/admin/roles/validate',
{
method: 'POST',
body: JSON.stringify(payload),
},
requestId
);
await makeRequest(req.caller, req.id);
};
return {
addRole,
updateRole,
removeRole,
validateRole,
errors,
loading,
};
};

View File

@ -4,15 +4,16 @@ import { formatApiPath } from 'utils/formatPath';
import {
IProjectEnvironmentPermissions,
IProjectRolePermissions,
IPermissions,
IPermission,
} from 'interfaces/project';
} from 'interfaces/permissions';
import handleErrorResponses from '../httpErrorResponseHandler';
interface IUseProjectRolePermissions {
interface IUsePermissions {
permissions:
| IProjectRolePermissions
| IPermissions
| {
root: IPermission[];
project: IPermission[];
environments: IProjectEnvironmentPermissions[];
};
@ -21,9 +22,7 @@ interface IUseProjectRolePermissions {
error: any;
}
const useProjectRolePermissions = (
options: SWRConfiguration = {}
): IUseProjectRolePermissions => {
const usePermissions = (options: SWRConfiguration = {}): IUsePermissions => {
const fetcher = () => {
const path = formatApiPath(`api/admin/permissions`);
return fetch(path, {
@ -35,7 +34,7 @@ const useProjectRolePermissions = (
const KEY = `api/admin/permissions`;
const { data, error } = useSWR<{ permissions: IProjectRolePermissions }>(
const { data, error } = useSWR<{ permissions: IPermissions }>(
KEY,
fetcher,
options
@ -51,11 +50,15 @@ const useProjectRolePermissions = (
}, [data, error]);
return {
permissions: data?.permissions || { project: [], environments: [] },
permissions: data?.permissions || {
root: [],
project: [],
environments: [],
},
error,
loading,
refetch,
};
};
export default useProjectRolePermissions;
export default usePermissions;

View File

@ -1,41 +0,0 @@
import { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useEnterpriseSWR } from '../useEnterpriseSWR/useEnterpriseSWR';
const useProjectRole = (id: string, options: SWRConfiguration = {}) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/roles/${id}`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('project role'))
.then(res => res.json());
};
const { data, error } = useEnterpriseSWR(
{},
`api/admin/roles/${id}`,
fetcher,
options
);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(`api/admin/roles/${id}`);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
role: data ? data : {},
error,
loading,
refetch,
};
};
export default useProjectRole;

View File

@ -1,35 +0,0 @@
import useSWR, { mutate, SWRConfiguration } from 'swr';
import { useState, useEffect } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
const useProjectRoles = (options: SWRConfiguration = {}) => {
const fetcher = () => {
const path = formatApiPath(`api/admin/roles`);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('project roles'))
.then(res => res.json());
};
const { data, error } = useSWR(`api/admin/roles`, fetcher, options);
const [loading, setLoading] = useState(!error && !data);
const refetch = () => {
mutate(`api/admin/roles`);
};
useEffect(() => {
setLoading(!error && !data);
}, [data, error]);
return {
roles: data?.roles || [],
error,
loading,
refetch,
};
};
export default useProjectRoles;

View File

@ -0,0 +1,67 @@
import { SWRConfiguration } from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import IRole from 'interfaces/role';
import useUiConfig from '../useUiConfig/useUiConfig';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
export interface IUseRoleOutput {
role?: IRole;
refetch: () => void;
loading: boolean;
error?: Error;
}
export const useRole = (
id?: string,
options: SWRConfiguration = {}
): IUseRoleOutput => {
const { isEnterprise } = useUiConfig();
const { data, error, mutate } = useConditionalSWR(
Boolean(id) && isEnterprise(),
undefined,
formatApiPath(`api/admin/roles/${id}`),
fetcher,
options
);
const {
data: ossData,
error: ossError,
mutate: ossMutate,
} = useConditionalSWR(
Boolean(id) && !isEnterprise(),
{ rootRoles: [] },
formatApiPath(`api/admin/user-admin`),
fetcher,
options
);
return useMemo(() => {
if (!isEnterprise()) {
return {
role: ((ossData?.rootRoles ?? []) as IRole[]).find(
({ id: rId }) => rId === +id!
),
loading: !ossError && !ossData,
refetch: () => ossMutate(),
error: ossError,
};
} else {
return {
role: data as IRole,
loading: !error && !data,
refetch: () => mutate(),
error,
};
}
}, [data, error, mutate, ossData, ossError, ossMutate]);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Role'))
.then(res => res.json());
};

View File

@ -0,0 +1,78 @@
import IRole, { IProjectRole } from 'interfaces/role';
import { useMemo } from 'react';
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'];
export const useRoles = () => {
const { isEnterprise, uiConfig } = useUiConfig();
const { data, error, mutate } = useConditionalSWR(
isEnterprise(),
{ roles: [], projectRoles: [] },
formatApiPath(`api/admin/roles`),
fetcher
);
const {
data: ossData,
error: ossError,
mutate: ossMutate,
} = useConditionalSWR(
!isEnterprise(),
{ rootRoles: [] },
formatApiPath(`api/admin/user-admin`),
fetcher
);
return useMemo(() => {
if (!isEnterprise()) {
return {
roles: ossData?.rootRoles
.filter(({ type }: IRole) => type === ROOT_ROLE)
.sort(sortRoles) as IRole[],
projectRoles: [],
loading: !ossError && !ossData,
refetch: () => ossMutate(),
error: ossError,
};
} else {
return {
roles: (data?.roles
.filter(({ type }: IRole) =>
uiConfig.flags.customRootRoles
? ROOT_ROLES.includes(type)
: type === ROOT_ROLE
)
.sort(sortRoles) ?? []) as IRole[],
projectRoles: (data?.roles
.filter(({ type }: IRole) => PROJECT_ROLES.includes(type))
.sort(sortRoles) ?? []) as IProjectRole[],
loading: !error && !data,
refetch: () => mutate(),
error,
};
}
}, [data, error, mutate, ossData, ossError, ossMutate]);
};
const fetcher = (path: string) => {
return fetch(path)
.then(handleErrorResponses('Roles'))
.then(res => res.json());
};
export const sortRoles = (a: IRole, b: IRole) => {
if (a.type === 'root' && b.type !== 'root') {
return -1;
} else if (a.type !== 'root' && b.type === 'root') {
return 1;
} else {
return a.name.localeCompare(b.name);
}
};

View File

@ -2,8 +2,18 @@ import useSWR from 'swr';
import { useMemo } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { IUser } from 'interfaces/user';
import IRole from 'interfaces/role';
export const useUsers = () => {
interface IUseUsersOutput {
users: IUser[];
roles: IRole[];
loading: boolean;
refetch: () => void;
error?: Error;
}
export const useUsers = (): IUseUsersOutput => {
const { data, error, mutate } = useSWR(
formatApiPath(`api/admin/user-admin`),
fetcher

View File

@ -0,0 +1,21 @@
export interface IPermission {
id: number;
name: string;
displayName: string;
environment?: string;
}
export interface IPermissions {
root: IPermission[];
project: IPermission[];
environments: IProjectEnvironmentPermissions[];
}
export interface IProjectEnvironmentPermissions {
name: string;
permissions: IPermission[];
}
export interface ICheckedPermissions {
[key: string]: IPermission;
}

View File

@ -34,20 +34,3 @@ export interface IProjectHealthReport extends IProject {
activeCount: number;
updatedAt: string;
}
export interface IPermission {
id: number;
name: string;
displayName: string;
environment?: string;
}
export interface IProjectRolePermissions {
project: IPermission[];
environments: IProjectEnvironmentPermissions[];
}
export interface IProjectEnvironmentPermissions {
name: string;
permissions: IPermission[];
}

View File

@ -1,9 +1,12 @@
import { IPermission } from './permissions';
interface IRole {
id: number;
name: string;
project: string | null;
description: string;
type: string;
permissions?: IPermission[];
}
export interface IProjectRole {

View File

@ -54,6 +54,7 @@ export interface IFlags {
segmentContextFieldUsage?: boolean;
disableNotifications?: boolean;
advancedPlayground?: boolean;
customRootRoles?: boolean;
}
export interface IVersionInfo {

View File

@ -71,6 +71,7 @@ exports[`should create default config 1`] = `
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"cleanClientApi": false,
"customRootRoles": false,
"demo": false,
"disableBulkToggle": false,
"disableNotifications": false,
@ -105,6 +106,7 @@ exports[`should create default config 1`] = `
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"cleanClientApi": false,
"customRootRoles": false,
"demo": false,
"disableBulkToggle": false,
"disableNotifications": false,

View File

@ -101,6 +101,7 @@ export class AccessStore implements IAccessStore {
.select(['id', 'permission', 'type', 'display_name'])
.where('type', 'project')
.orWhere('type', 'environment')
.orWhere('type', 'root')
.from(`${T.PERMISSIONS} as p`);
return rows.map(this.mapPermission);
}
@ -172,7 +173,7 @@ export class AccessStore implements IAccessStore {
}
mapUserPermission(row: IPermissionRow): IUserPermission {
let project: string = undefined;
let project: string | undefined = undefined;
// Since the editor should have access to the default project,
// we map the project to the project and environment specific
// permissions that are connected to the editor role.
@ -425,11 +426,11 @@ export class AccessStore implements IAccessStore {
async removeRolesOfTypeForUser(
userId: number,
roleType: string,
roleTypes: string[],
): Promise<void> {
const rolesToRemove = this.db(T.ROLES)
.select('id')
.where({ type: roleType });
.whereIn('type', roleTypes);
return this.db(T.ROLE_USER)
.where({ user_id: userId })

View File

@ -160,7 +160,7 @@ export default class RoleStore implements IRoleStore {
return this.db
.select(['id', 'name', 'type', 'description'])
.from<IRole>(T.ROLES)
.where('type', 'root');
.whereIn('type', ['root', 'root-custom']);
}
async removeRolesForProject(projectId: string): Promise<void> {
@ -177,7 +177,7 @@ export default class RoleStore implements IRoleStore {
.distinctOn('user_id')
.from(`${T.ROLES} AS r`)
.leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id')
.where('r.type', '=', 'root');
.whereIn('r.type', ['root', 'root-custom']);
return rows.map((row) => ({
roleId: Number(row.id),

View File

@ -17,7 +17,7 @@ export const createAccessService = (
db: Db,
config: IUnleashConfig,
): AccessService => {
const { eventBus, getLogger } = config;
const { eventBus, getLogger, flagResolver } = config;
const eventStore = new EventStore(db, getLogger);
const groupStore = new GroupStore(db);
const accountStore = new AccountStore(db, getLogger);
@ -31,7 +31,7 @@ export const createAccessService = (
return new AccessService(
{ accessStore, accountStore, roleStore, environmentStore },
{ getLogger },
{ getLogger, flagResolver },
groupService,
);
};
@ -39,7 +39,7 @@ export const createAccessService = (
export const createFakeAccessService = (
config: IUnleashConfig,
): AccessService => {
const { getLogger } = config;
const { getLogger, flagResolver } = config;
const eventStore = new FakeEventStore();
const groupStore = new FakeGroupStore();
const accountStore = new FakeAccountStore();
@ -53,7 +53,7 @@ export const createFakeAccessService = (
return new AccessService(
{ accessStore, accountStore, roleStore, environmentStore },
{ getLogger },
{ getLogger, flagResolver },
groupService,
);
};

View File

@ -91,7 +91,7 @@ export const createFeatureToggleService = (
);
const accessService = new AccessService(
{ accessStore, accountStore, roleStore, environmentStore },
{ getLogger },
{ getLogger, flagResolver },
groupService,
);
const segmentService = new SegmentService(
@ -145,7 +145,7 @@ export const createFakeFeatureToggleService = (
);
const accessService = new AccessService(
{ accessStore, accountStore, roleStore, environmentStore },
{ getLogger },
{ getLogger, flagResolver },
groupService,
);
const segmentService = new SegmentService(

View File

@ -523,7 +523,6 @@ export default class UserAdminController extends Controller {
req: Request,
res: Response<AdminCountSchema>,
): Promise<void> {
console.log('user-admin controller');
const adminCount = await this.accountService.getAdminCount();
this.openApiService.respondWithValidation(

View File

@ -3,10 +3,15 @@ import getLogger from '../../test/fixtures/no-logger';
import createStores from '../../test/fixtures/store';
import { AccessService, IRoleValidation } from './access-service';
import { GroupService } from './group-service';
import { createTestConfig } from '../../test/config/test-config';
function getSetup(withNameInUse: boolean) {
const stores = createStores();
const config = createTestConfig({
getLogger,
});
stores.roleStore = {
...stores.roleStore,
async nameInUse(): Promise<boolean> {
@ -14,13 +19,7 @@ function getSetup(withNameInUse: boolean) {
},
};
return {
accessService: new AccessService(
stores,
{
getLogger,
},
{} as GroupService,
),
accessService: new AccessService(stores, config, {} as GroupService),
stores,
};
}

View File

@ -25,12 +25,18 @@ import NameExistsError from '../error/name-exists-error';
import { IEnvironmentStore } from 'lib/types/stores/environment-store';
import RoleInUseError from '../error/role-in-use-error';
import { roleSchema } from '../schema/role-schema';
import { ALL_ENVS, ALL_PROJECTS, CUSTOM_ROLE_TYPE } from '../util/constants';
import {
ALL_ENVS,
ALL_PROJECTS,
CUSTOM_ROOT_ROLE_TYPE,
CUSTOM_PROJECT_ROLE_TYPE,
} from '../util/constants';
import { DEFAULT_PROJECT } from '../types/project';
import InvalidOperationError from '../error/invalid-operation-error';
import BadDataError from '../error/bad-data-error';
import { IGroupModelWithProjectRole } from '../types/group';
import { GroupService } from './group-service';
import { IFlagResolver, IUnleashConfig } from 'lib/types';
const { ADMIN } = permissions;
@ -45,6 +51,7 @@ const PROJECT_ADMIN = [
interface IRoleCreation {
name: string;
description: string;
type?: 'root-custom' | 'custom';
permissions?: IPermission[];
}
@ -58,6 +65,7 @@ interface IRoleUpdate {
id: number;
name: string;
description: string;
type?: 'root-custom' | 'custom';
permissions?: IPermission[];
}
@ -76,6 +84,8 @@ export class AccessService {
private logger: Logger;
private flagResolver: IFlagResolver;
constructor(
{
accessStore,
@ -86,7 +96,10 @@ export class AccessService {
IUnleashStores,
'accessStore' | 'accountStore' | 'roleStore' | 'environmentStore'
>,
{ getLogger }: { getLogger: Function },
{
getLogger,
flagResolver,
}: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
groupService: GroupService,
) {
this.store = accessStore;
@ -95,6 +108,7 @@ export class AccessService {
this.groupService = groupService;
this.environmentStore = environmentStore;
this.logger = getLogger('/services/access-service.ts');
this.flagResolver = flagResolver;
}
/**
@ -158,6 +172,10 @@ export class AccessService {
const bindablePermissions = await this.store.getAvailablePermissions();
const environments = await this.environmentStore.getAll();
const rootPermissions = bindablePermissions.filter(
({ type }) => type === 'root',
);
const projectPermissions = bindablePermissions.filter((x) => {
return x.type === 'project';
});
@ -176,6 +194,7 @@ export class AccessService {
});
return {
root: rootPermissions,
project: projectPermissions,
environments: allEnvironmentPermissions,
};
@ -225,10 +244,10 @@ export class AccessService {
const newRootRole = await this.resolveRootRole(role);
if (newRootRole) {
try {
await this.store.removeRolesOfTypeForUser(
userId,
await this.store.removeRolesOfTypeForUser(userId, [
RoleType.ROOT,
);
RoleType.ROOT_CUSTOM,
]);
await this.store.addUserToRole(
userId,
@ -467,38 +486,81 @@ export class AccessService {
}
async createRole(role: IRoleCreation): Promise<ICustomRole> {
// CUSTOM_PROJECT_ROLE_TYPE is assumed by default for backward compatibility
const roleType =
role.type === CUSTOM_ROOT_ROLE_TYPE
? CUSTOM_ROOT_ROLE_TYPE
: CUSTOM_PROJECT_ROLE_TYPE;
if (
roleType === CUSTOM_ROOT_ROLE_TYPE &&
!this.flagResolver.isEnabled('customRootRoles')
) {
throw new InvalidOperationError(
'Custom root roles are not enabled.',
);
}
const baseRole = {
...(await this.validateRole(role)),
roleType: CUSTOM_ROLE_TYPE,
roleType,
};
const rolePermissions = role.permissions;
const newRole = await this.roleStore.create(baseRole);
if (rolePermissions) {
await this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
);
if (roleType === CUSTOM_ROOT_ROLE_TYPE) {
await this.store.addPermissionsToRole(
newRole.id,
rolePermissions.map(({ name }) => name),
);
} else {
await this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
);
}
}
return newRole;
}
async updateRole(role: IRoleUpdate): Promise<ICustomRole> {
const roleType =
role.type === CUSTOM_ROOT_ROLE_TYPE
? CUSTOM_ROOT_ROLE_TYPE
: CUSTOM_PROJECT_ROLE_TYPE;
if (
roleType === CUSTOM_ROOT_ROLE_TYPE &&
!this.flagResolver.isEnabled('customRootRoles')
) {
throw new InvalidOperationError(
'Custom root roles are not enabled.',
);
}
await this.validateRole(role, role.id);
const baseRole = {
id: role.id,
name: role.name,
description: role.description,
roleType: CUSTOM_ROLE_TYPE,
roleType,
};
const rolePermissions = role.permissions;
const newRole = await this.roleStore.update(baseRole);
if (rolePermissions) {
await this.store.wipePermissionsFromRole(newRole.id);
await this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
);
if (roleType === CUSTOM_ROOT_ROLE_TYPE) {
await this.store.addPermissionsToRole(
newRole.id,
rolePermissions.map(({ name }) => name),
);
} else {
await this.store.addEnvironmentPermissionsToRole(
newRole.id,
rolePermissions,
);
}
}
return newRole;
}
@ -532,7 +594,10 @@ export class AccessService {
async validateRoleIsNotBuiltIn(roleId: number): Promise<void> {
const role = await this.store.get(roleId);
if (role.type !== CUSTOM_ROLE_TYPE) {
if (
role.type !== CUSTOM_PROJECT_ROLE_TYPE &&
role.type !== CUSTOM_ROOT_ROLE_TYPE
) {
throw new InvalidOperationError(
'You cannot change built in roles.',
);

View File

@ -25,7 +25,8 @@ export type IFlagKey =
| 'experimentalExtendedTelemetry'
| 'segmentContextFieldUsage'
| 'disableNotifications'
| 'advancedPlayground';
| 'advancedPlayground'
| 'customRootRoles';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -118,6 +119,10 @@ const flags: IFlags = {
process.env.ADVANCED_PLAYGROUND,
false,
),
customRootRoles: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -272,6 +272,7 @@ export interface IRoleData {
}
export interface IAvailablePermissions {
root: IPermission[];
project: IPermission[];
environments: IEnvironmentPermission[];
}
@ -305,6 +306,7 @@ export enum RoleName {
export enum RoleType {
ROOT = 'root',
ROOT_CUSTOM = 'root-custom',
PROJECT = 'project',
}

View File

@ -46,3 +46,51 @@ export const SKIP_CHANGE_REQUEST = 'SKIP_CHANGE_REQUEST';
export const READ_PROJECT_API_TOKEN = 'READ_PROJECT_API_TOKEN';
export const CREATE_PROJECT_API_TOKEN = 'CREATE_PROJECT_API_TOKEN';
export const DELETE_PROJECT_API_TOKEN = 'DELETE_PROJECT_API_TOKEN';
export const ROOT_PERMISSION_CATEGORIES = [
{
label: 'Addon',
permissions: [CREATE_ADDON, UPDATE_ADDON, DELETE_ADDON],
},
{
label: 'API token',
permissions: [
READ_API_TOKEN,
CREATE_API_TOKEN,
UPDATE_API_TOKEN,
DELETE_API_TOKEN,
],
},
{
label: 'Application',
permissions: [UPDATE_APPLICATION],
},
{
label: 'Context field',
permissions: [
CREATE_CONTEXT_FIELD,
UPDATE_CONTEXT_FIELD,
DELETE_CONTEXT_FIELD,
],
},
{
label: 'Project',
permissions: [CREATE_PROJECT],
},
{
label: 'Role',
permissions: [READ_ROLE, UPDATE_ROLE],
},
{
label: 'Segment',
permissions: [CREATE_SEGMENT, UPDATE_SEGMENT, DELETE_SEGMENT],
},
{
label: 'Strategy',
permissions: [CREATE_STRATEGY, UPDATE_STRATEGY, DELETE_STRATEGY],
},
{
label: 'Tag type',
permissions: [UPDATE_TAG_TYPE, DELETE_TAG_TYPE],
},
];

View File

@ -120,7 +120,10 @@ export interface IAccessStore extends Store<IRole, number> {
projectId: string,
): Promise<void>;
removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void>;
removeRolesOfTypeForUser(
userId: number,
roleTypes: string[],
): Promise<void>;
addPermissionsToRole(
role_id: number,

View File

@ -7,7 +7,8 @@ export const ROOT_PERMISSION_TYPE = 'root';
export const ENVIRONMENT_PERMISSION_TYPE = 'environment';
export const PROJECT_PERMISSION_TYPE = 'project';
export const CUSTOM_ROLE_TYPE = 'custom';
export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom';
export const CUSTOM_PROJECT_ROLE_TYPE = 'custom';
/* CONTEXT FIELD OPERATORS */

View File

@ -219,7 +219,7 @@ beforeAll(async () => {
experimental: { environments: { enabled: true } },
});
groupService = new GroupService(stores, { getLogger });
accessService = new AccessService(stores, { getLogger }, groupService);
accessService = new AccessService(stores, config, groupService);
const roles = await accessService.getRootRoles();
editorRole = roles.find((r) => r.name === RoleName.EDITOR);
adminRole = roles.find((r) => r.name === RoleName.ADMIN);

View File

@ -20,7 +20,7 @@ class AccessServiceMock extends AccessService {
roleStore: undefined,
environmentStore: undefined,
},
{ getLogger: noLoggerProvider },
{ getLogger: noLoggerProvider, flagResolver: undefined },
undefined,
);
}

View File

@ -181,7 +181,10 @@ class AccessStoreMock implements IAccessStore {
return Promise.resolve([]);
}
removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void> {
removeRolesOfTypeForUser(
userId: number,
roleTypes: string[],
): Promise<void> {
return Promise.resolve(undefined);
}