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.  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:  ### Create and edit role Here's how the role form looks like (create / edit):  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:  ~~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:  What should the correct behavior be? ### Role selector I added a new easy-to-use role selector component that is present in: - Users  - Service Accounts  - Groups  ### 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:  I'm not listing all the permissions of predefined roles. Those simply show the description in the tooltip:  ### Role badge Groups is a bit different, since it uses a list of cards, so I added yet another component - Role badge:  I'm using this same component on the profile tab:  ## 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:
parent
1bd182d02a
commit
bb026c0ba1
@ -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 />} />
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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.',
|
||||
|
@ -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',
|
||||
|
@ -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(
|
||||
|
@ -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[];
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
138
frontend/src/component/admin/roles/RoleForm/RoleForm.tsx
Normal file
138
frontend/src/component/admin/roles/RoleForm/RoleForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
140
frontend/src/component/admin/roles/RoleForm/useRoleForm.ts
Normal file
140
frontend/src/component/admin/roles/RoleForm/useRoleForm.ts
Normal 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,
|
||||
};
|
||||
};
|
164
frontend/src/component/admin/roles/RoleModal/RoleModal.tsx
Normal file
164
frontend/src/component/admin/roles/RoleModal/RoleModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
20
frontend/src/component/admin/roles/Roles.tsx
Normal file
20
frontend/src/component/admin/roles/Roles.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
233
frontend/src/component/admin/roles/RolesTable/RolesTable.tsx
Normal file
233
frontend/src/component/admin/roles/RolesTable/RolesTable.tsx
Normal 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 “
|
||||
{searchValue}
|
||||
”
|
||||
</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>
|
||||
);
|
||||
};
|
@ -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={
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
},
|
||||
{
|
||||
|
@ -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={
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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>;
|
||||
};
|
||||
|
27
frontend/src/component/common/RoleBadge/RoleBadge.tsx
Normal file
27
frontend/src/component/common/RoleBadge/RoleBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
71
frontend/src/component/common/RoleSelect/RoleSelect.tsx
Normal file
71
frontend/src/component/common/RoleSelect/RoleSelect.tsx
Normal 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} />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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)}
|
||||
|
@ -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>;
|
||||
};
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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'] },
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -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;
|
77
frontend/src/hooks/api/actions/useRolesApi/useRolesApi.ts
Normal file
77
frontend/src/hooks/api/actions/useRolesApi/useRolesApi.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
67
frontend/src/hooks/api/getters/useRole/useRole.ts
Normal file
67
frontend/src/hooks/api/getters/useRole/useRole.ts
Normal 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());
|
||||
};
|
78
frontend/src/hooks/api/getters/useRoles/useRoles.ts
Normal file
78
frontend/src/hooks/api/getters/useRoles/useRoles.ts
Normal 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);
|
||||
}
|
||||
};
|
@ -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
|
||||
|
21
frontend/src/interfaces/permissions.ts
Normal file
21
frontend/src/interfaces/permissions.ts
Normal 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;
|
||||
}
|
@ -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[];
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -54,6 +54,7 @@ export interface IFlags {
|
||||
segmentContextFieldUsage?: boolean;
|
||||
disableNotifications?: boolean;
|
||||
advancedPlayground?: boolean;
|
||||
customRootRoles?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -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,
|
||||
|
@ -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 })
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
);
|
||||
};
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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.',
|
||||
);
|
||||
|
@ -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 = {
|
||||
|
@ -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',
|
||||
}
|
||||
|
||||
|
@ -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],
|
||||
},
|
||||
];
|
||||
|
@ -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,
|
||||
|
@ -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 */
|
||||
|
||||
|
@ -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);
|
||||
|
2
src/test/fixtures/access-service-mock.ts
vendored
2
src/test/fixtures/access-service-mock.ts
vendored
@ -20,7 +20,7 @@ class AccessServiceMock extends AccessService {
|
||||
roleStore: undefined,
|
||||
environmentStore: undefined,
|
||||
},
|
||||
{ getLogger: noLoggerProvider },
|
||||
{ getLogger: noLoggerProvider, flagResolver: undefined },
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
5
src/test/fixtures/fake-access-store.ts
vendored
5
src/test/fixtures/fake-access-store.ts
vendored
@ -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);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user