mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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