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 { Network } from './network/Network'; | ||||||
| import CreateProjectRole from './projectRoles/CreateProjectRole/CreateProjectRole'; | import CreateProjectRole from './projectRoles/CreateProjectRole/CreateProjectRole'; | ||||||
| import EditProjectRole from './projectRoles/EditProjectRole/EditProjectRole'; | import EditProjectRole from './projectRoles/EditProjectRole/EditProjectRole'; | ||||||
|  | import { Roles } from './roles/Roles'; | ||||||
| import ProjectRoles from './projectRoles/ProjectRoles/ProjectRoles'; | import ProjectRoles from './projectRoles/ProjectRoles/ProjectRoles'; | ||||||
| import { ServiceAccounts } from './serviceAccounts/ServiceAccounts'; | import { ServiceAccounts } from './serviceAccounts/ServiceAccounts'; | ||||||
| import CreateUser from './users/CreateUser/CreateUser'; | import CreateUser from './users/CreateUser/CreateUser'; | ||||||
| @ -27,8 +28,11 @@ export const Admin = () => ( | |||||||
|         <AdminMenu /> |         <AdminMenu /> | ||||||
|         <Routes> |         <Routes> | ||||||
|             <Route path="users" element={<UsersAdmin />} /> |             <Route path="users" element={<UsersAdmin />} /> | ||||||
|             <Route path="create-project-role" element={<CreateProjectRole />} /> |             <Route path="project-roles/new" element={<CreateProjectRole />} /> | ||||||
|             <Route path="roles/:id/edit" element={<EditProjectRole />} /> |             <Route | ||||||
|  |                 path="project-roles/:id/edit" | ||||||
|  |                 element={<EditProjectRole />} | ||||||
|  |             /> | ||||||
|             <Route path="api" element={<ApiTokenPage />} /> |             <Route path="api" element={<ApiTokenPage />} /> | ||||||
|             <Route path="api/create-token" element={<CreateApiToken />} /> |             <Route path="api/create-token" element={<CreateApiToken />} /> | ||||||
|             <Route path="users/:id/edit" element={<EditUser />} /> |             <Route path="users/:id/edit" element={<EditUser />} /> | ||||||
| @ -42,7 +46,8 @@ export const Admin = () => ( | |||||||
|                 element={<EditGroupContainer />} |                 element={<EditGroupContainer />} | ||||||
|             /> |             /> | ||||||
|             <Route path="groups/:groupId" element={<Group />} /> |             <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="instance" element={<InstanceAdmin />} /> | ||||||
|             <Route path="network/*" element={<Network />} /> |             <Route path="network/*" element={<Network />} /> | ||||||
|             <Route path="maintenance" element={<MaintenanceAdmin />} /> |             <Route path="maintenance" element={<MaintenanceAdmin />} /> | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| import React, { FC } from 'react'; | 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 { UG_DESC_ID, UG_NAME_ID } from 'utils/testIds'; | ||||||
| import Input from 'component/common/Input/Input'; | import Input from 'component/common/Input/Input'; | ||||||
| import { IGroupUser } from 'interfaces/group'; | 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 useAuthSettings from 'hooks/api/getters/useAuthSettings/useAuthSettings'; | ||||||
| import { Link } from 'react-router-dom'; | import { Link } from 'react-router-dom'; | ||||||
| import { HelpIcon } from 'component/common/HelpIcon/HelpIcon'; | 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 { useUsers } from 'hooks/api/getters/useUsers/useUsers'; | ||||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
|  | import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; | ||||||
| 
 | 
 | ||||||
| const StyledForm = styled('form')(() => ({ | const StyledForm = styled('form')(() => ({ | ||||||
|     display: 'flex', |     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 { | interface IGroupForm { | ||||||
|     name: string; |     name: string; | ||||||
|     description: string; |     description: string; | ||||||
| @ -128,24 +120,10 @@ export const GroupForm: FC<IGroupForm> = ({ | |||||||
| 
 | 
 | ||||||
|     const groupRootRolesEnabled = Boolean(uiConfig.flags.groupRootRoles); |     const groupRootRolesEnabled = Boolean(uiConfig.flags.groupRootRoles); | ||||||
| 
 | 
 | ||||||
|     const roleIdToRole = (rootRoleId: number | null): IProjectRole | null => { |     const roleIdToRole = (rootRoleId: number | null): IRole | null => { | ||||||
|         return ( |         return roles.find((role: IRole) => role.id === rootRoleId) || null; | ||||||
|             roles.find((role: IProjectRole) => 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 ( |     return ( | ||||||
|         <StyledForm onSubmit={handleSubmit}> |         <StyledForm onSubmit={handleSubmit}> | ||||||
|             <div> |             <div> | ||||||
| @ -214,23 +192,12 @@ export const GroupForm: FC<IGroupForm> = ({ | |||||||
|                                 </Box> |                                 </Box> | ||||||
|                             </StyledInputDescription> |                             </StyledInputDescription> | ||||||
|                             <StyledAutocompleteWrapper> |                             <StyledAutocompleteWrapper> | ||||||
|                                 <Autocomplete |                                 <RoleSelect | ||||||
|                                     data-testid="GROUP_ROOT_ROLE" |                                     data-testid="GROUP_ROOT_ROLE" | ||||||
|                                     size="small" |  | ||||||
|                                     openOnFocus |  | ||||||
|                                     value={roleIdToRole(rootRole)} |                                     value={roleIdToRole(rootRole)} | ||||||
|                                     onChange={(_, newValue) => |                                     setValue={role => | ||||||
|                                         setRootRole(newValue?.id || null) |                                         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> |                             </StyledAutocompleteWrapper> | ||||||
|                         </> |                         </> | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ import { GroupCardAvatars } from './GroupCardAvatars/GroupCardAvatars'; | |||||||
| import { Badge } from 'component/common/Badge/Badge'; | import { Badge } from 'component/common/Badge/Badge'; | ||||||
| import { GroupCardActions } from './GroupCardActions/GroupCardActions'; | import { GroupCardActions } from './GroupCardActions/GroupCardActions'; | ||||||
| import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; | import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; | ||||||
| import { IProjectRole } from 'interfaces/role'; | import { RoleBadge } from 'component/common/RoleBadge/RoleBadge'; | ||||||
| 
 | 
 | ||||||
| const StyledLink = styled(Link)(({ theme }) => ({ | const StyledLink = styled(Link)(({ theme }) => ({ | ||||||
|     textDecoration: 'none', |     textDecoration: 'none', | ||||||
| @ -86,14 +86,12 @@ const InfoBadgeDescription = styled('span')(({ theme }) => ({ | |||||||
| 
 | 
 | ||||||
| interface IGroupCardProps { | interface IGroupCardProps { | ||||||
|     group: IGroup; |     group: IGroup; | ||||||
|     rootRoles: IProjectRole[]; |  | ||||||
|     onEditUsers: (group: IGroup) => void; |     onEditUsers: (group: IGroup) => void; | ||||||
|     onRemoveGroup: (group: IGroup) => void; |     onRemoveGroup: (group: IGroup) => void; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const GroupCard = ({ | export const GroupCard = ({ | ||||||
|     group, |     group, | ||||||
|     rootRoles, |  | ||||||
|     onEditUsers, |     onEditUsers, | ||||||
|     onRemoveGroup, |     onRemoveGroup, | ||||||
| }: IGroupCardProps) => { | }: IGroupCardProps) => { | ||||||
| @ -117,17 +115,7 @@ export const GroupCard = ({ | |||||||
|                         show={ |                         show={ | ||||||
|                             <InfoBadgeDescription> |                             <InfoBadgeDescription> | ||||||
|                                 <p>Root role:</p> |                                 <p>Root role:</p> | ||||||
|                                 <Badge |                                 <RoleBadge roleId={group.rootRole!} /> | ||||||
|                                     color="success" |  | ||||||
|                                     icon={<TopicOutlinedIcon />} |  | ||||||
|                                 > |  | ||||||
|                                     { |  | ||||||
|                                         rootRoles.find( |  | ||||||
|                                             (role: IProjectRole) => |  | ||||||
|                                                 role.id === group.rootRole |  | ||||||
|                                         )?.name |  | ||||||
|                                     } |  | ||||||
|                                 </Badge> |  | ||||||
|                             </InfoBadgeDescription> |                             </InfoBadgeDescription> | ||||||
|                         } |                         } | ||||||
|                     /> |                     /> | ||||||
|  | |||||||
| @ -18,8 +18,6 @@ import { Add } from '@mui/icons-material'; | |||||||
| import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds'; | import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds'; | ||||||
| import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers'; | import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers'; | ||||||
| import { RemoveGroup } from '../RemoveGroup/RemoveGroup'; | import { RemoveGroup } from '../RemoveGroup/RemoveGroup'; | ||||||
| import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; |  | ||||||
| import { IProjectRole } from 'interfaces/role'; |  | ||||||
| 
 | 
 | ||||||
| type PageQueryType = Partial<Record<'search', string>>; | type PageQueryType = Partial<Record<'search', string>>; | ||||||
| 
 | 
 | ||||||
| @ -51,7 +49,6 @@ export const GroupsList: VFC = () => { | |||||||
|     const [searchValue, setSearchValue] = useState( |     const [searchValue, setSearchValue] = useState( | ||||||
|         searchParams.get('search') || '' |         searchParams.get('search') || '' | ||||||
|     ); |     ); | ||||||
|     const { roles } = useUsers(); |  | ||||||
| 
 | 
 | ||||||
|     const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); |     const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); | ||||||
| 
 | 
 | ||||||
| @ -85,10 +82,6 @@ export const GroupsList: VFC = () => { | |||||||
|         setRemoveOpen(true); |         setRemoveOpen(true); | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const getBindableRootRoles = () => { |  | ||||||
|         return roles.filter((role: IProjectRole) => role.type === 'root'); |  | ||||||
|     }; |  | ||||||
| 
 |  | ||||||
|     return ( |     return ( | ||||||
|         <PageContent |         <PageContent | ||||||
|             isLoading={loading} |             isLoading={loading} | ||||||
| @ -141,7 +134,6 @@ export const GroupsList: VFC = () => { | |||||||
|                         <Grid key={group.id} item xs={12} md={6}> |                         <Grid key={group.id} item xs={12} md={6}> | ||||||
|                             <GroupCard |                             <GroupCard | ||||||
|                                 group={group} |                                 group={group} | ||||||
|                                 rootRoles={getBindableRootRoles()} |  | ||||||
|                                 onEditUsers={onEditUsers} |                                 onEditUsers={onEditUsers} | ||||||
|                                 onRemoveGroup={onRemoveGroup} |                                 onRemoveGroup={onRemoveGroup} | ||||||
|                             /> |                             /> | ||||||
|  | |||||||
| @ -55,11 +55,21 @@ function AdminMenu() { | |||||||
|                         } |                         } | ||||||
|                     /> |                     /> | ||||||
|                 )} |                 )} | ||||||
|                 {flags.RE && ( |                 {flags.customRootRoles && ( | ||||||
|                     <Tab |                     <Tab | ||||||
|                         value="roles" |                         value="roles" | ||||||
|                         label={ |                         label={ | ||||||
|                             <CenteredNavLink to="/admin/roles"> |                             <CenteredNavLink to="/admin/roles"> | ||||||
|  |                                 <span>Roles</span> | ||||||
|  |                             </CenteredNavLink> | ||||||
|  |                         } | ||||||
|  |                     /> | ||||||
|  |                 )} | ||||||
|  |                 {flags.RE && ( | ||||||
|  |                     <Tab | ||||||
|  |                         value="project-roles" | ||||||
|  |                         label={ | ||||||
|  |                             <CenteredNavLink to="/admin/project-roles"> | ||||||
|                                 <span>Project roles</span> |                                 <span>Project roles</span> | ||||||
|                             </CenteredNavLink> |                             </CenteredNavLink> | ||||||
|                         } |                         } | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | 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 { useNavigate } from 'react-router-dom'; | ||||||
| import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; | import ProjectRoleForm from '../ProjectRoleForm/ProjectRoleForm'; | ||||||
| import useProjectRoleForm from '../hooks/useProjectRoleForm'; | import useProjectRoleForm from '../hooks/useProjectRoleForm'; | ||||||
| @ -33,7 +33,7 @@ const CreateProjectRole = () => { | |||||||
|         getRoleKey, |         getRoleKey, | ||||||
|     } = useProjectRoleForm(); |     } = useProjectRoleForm(); | ||||||
| 
 | 
 | ||||||
|     const { createRole, loading } = useProjectRolesApi(); |     const { addRole, loading } = useRolesApi(); | ||||||
| 
 | 
 | ||||||
|     const onSubmit = async (e: Event) => { |     const onSubmit = async (e: Event) => { | ||||||
|         e.preventDefault(); |         e.preventDefault(); | ||||||
| @ -44,8 +44,8 @@ const CreateProjectRole = () => { | |||||||
|         if (validName && validPermissions) { |         if (validName && validPermissions) { | ||||||
|             const payload = getProjectRolePayload(); |             const payload = getProjectRolePayload(); | ||||||
|             try { |             try { | ||||||
|                 await createRole(payload); |                 await addRole(payload); | ||||||
|                 navigate('/admin/roles'); |                 navigate('/admin/project-roles'); | ||||||
|                 setToastData({ |                 setToastData({ | ||||||
|                     title: 'Project role created', |                     title: 'Project role created', | ||||||
|                     text: 'Now you can start assigning your project roles to project members.', |                     text: 'Now you can start assigning your project roles to project members.', | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | ||||||
| import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; | import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; | ||||||
| import { ADMIN } from 'component/providers/AccessProvider/permissions'; | import { ADMIN } from 'component/providers/AccessProvider/permissions'; | ||||||
| import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; | import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; | ||||||
| import useProjectRole from 'hooks/api/getters/useProjectRole/useProjectRole'; | import { useRole } from 'hooks/api/getters/useRole/useRole'; | ||||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
| import useToast from 'hooks/useToast'; | import useToast from 'hooks/useToast'; | ||||||
| import { useNavigate } from 'react-router-dom'; | import { useNavigate } from 'react-router-dom'; | ||||||
| @ -15,8 +15,8 @@ import { GO_BACK } from 'constants/navigate'; | |||||||
| const EditProjectRole = () => { | const EditProjectRole = () => { | ||||||
|     const { uiConfig } = useUiConfig(); |     const { uiConfig } = useUiConfig(); | ||||||
|     const { setToastData, setToastApiError } = useToast(); |     const { setToastData, setToastApiError } = useToast(); | ||||||
|     const projectId = useRequiredPathParam('id'); |     const roleId = useRequiredPathParam('id'); | ||||||
|     const { role } = useProjectRole(projectId); |     const { role, refetch } = useRole(roleId); | ||||||
| 
 | 
 | ||||||
|     const navigate = useNavigate(); |     const navigate = useNavigate(); | ||||||
|     const { |     const { | ||||||
| @ -35,19 +35,18 @@ const EditProjectRole = () => { | |||||||
|         validateName, |         validateName, | ||||||
|         clearErrors, |         clearErrors, | ||||||
|         getRoleKey, |         getRoleKey, | ||||||
|     } = useProjectRoleForm(role.name, role.description, role?.permissions); |     } = useProjectRoleForm(role?.name, role?.description, role?.permissions); | ||||||
| 
 | 
 | ||||||
|     const formatApiCode = () => { |     const formatApiCode = () => { | ||||||
|         return `curl --location --request PUT '${ |         return `curl --location --request PUT '${ | ||||||
|             uiConfig.unleashUrl |             uiConfig.unleashUrl | ||||||
|         }/api/admin/roles/${role.id}' \\ |         }/api/admin/roles/${role?.id}' \\ | ||||||
| --header 'Authorization: INSERT_API_KEY' \\ | --header 'Authorization: INSERT_API_KEY' \\ | ||||||
| --header 'Content-Type: application/json' \\ | --header 'Content-Type: application/json' \\ | ||||||
| --data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
 | --data-raw '${JSON.stringify(getProjectRolePayload(), undefined, 2)}'`;
 | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const { refetch } = useProjectRole(projectId); |     const { updateRole, loading } = useRolesApi(); | ||||||
|     const { editRole, loading } = useProjectRolesApi(); |  | ||||||
| 
 | 
 | ||||||
|     const onSubmit = async (e: Event) => { |     const onSubmit = async (e: Event) => { | ||||||
|         e.preventDefault(); |         e.preventDefault(); | ||||||
| @ -58,9 +57,9 @@ const EditProjectRole = () => { | |||||||
| 
 | 
 | ||||||
|         if (validName && validPermissions) { |         if (validName && validPermissions) { | ||||||
|             try { |             try { | ||||||
|                 await editRole(projectId, payload); |                 await updateRole(+roleId, payload); | ||||||
|                 refetch(); |                 refetch(); | ||||||
|                 navigate('/admin/roles'); |                 navigate('/admin/project-roles'); | ||||||
|                 setToastData({ |                 setToastData({ | ||||||
|                     type: 'success', |                     type: 'success', | ||||||
|                     title: 'Project role updated', |                     title: 'Project role updated', | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ import { | |||||||
|     Typography, |     Typography, | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import { ExpandMore } from '@mui/icons-material'; | import { ExpandMore } from '@mui/icons-material'; | ||||||
| import { IPermission } from 'interfaces/project'; | import { IPermission } from 'interfaces/permissions'; | ||||||
| import StringTruncator from 'component/common/StringTruncator/StringTruncator'; | import StringTruncator from 'component/common/StringTruncator/StringTruncator'; | ||||||
| import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm'; | import { ICheckedPermission } from 'component/admin/projectRoles/hooks/useProjectRoleForm'; | ||||||
| 
 | 
 | ||||||
| @ -23,10 +23,10 @@ interface IEnvironmentPermissionAccordionProps { | |||||||
|     title: string; |     title: string; | ||||||
|     Icon: ReactNode; |     Icon: ReactNode; | ||||||
|     isInitiallyExpanded?: boolean; |     isInitiallyExpanded?: boolean; | ||||||
|     context: 'project' | 'environment'; |     context: string; | ||||||
|     onPermissionChange: (permission: IPermission) => void; |     onPermissionChange: (permission: IPermission) => void; | ||||||
|     onCheckAll: () => void; |     onCheckAll: () => void; | ||||||
|     getRoleKey: (permission: { id: number; environment?: string }) => string; |     getRoleKey?: (permission: { id: number; environment?: string }) => string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const AccordionHeader = styled(Box)(({ theme }) => ({ | const AccordionHeader = styled(Box)(({ theme }) => ({ | ||||||
| @ -52,7 +52,7 @@ export const PermissionAccordion: VFC<IEnvironmentPermissionAccordionProps> = ({ | |||||||
|     context, |     context, | ||||||
|     onPermissionChange, |     onPermissionChange, | ||||||
|     onCheckAll, |     onCheckAll, | ||||||
|     getRoleKey, |     getRoleKey = permission => permission.id.toString(), | ||||||
| }) => { | }) => { | ||||||
|     const [expanded, setExpanded] = useState(isInitiallyExpanded); |     const [expanded, setExpanded] = useState(isInitiallyExpanded); | ||||||
|     const permissionMap = useMemo( |     const permissionMap = useMemo( | ||||||
|  | |||||||
| @ -10,8 +10,8 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit | |||||||
| import { | import { | ||||||
|     IPermission, |     IPermission, | ||||||
|     IProjectEnvironmentPermissions, |     IProjectEnvironmentPermissions, | ||||||
|     IProjectRolePermissions, |     IPermissions, | ||||||
| } from 'interfaces/project'; | } from 'interfaces/permissions'; | ||||||
| import { ICheckedPermission } from '../hooks/useProjectRoleForm'; | import { ICheckedPermission } from '../hooks/useProjectRoleForm'; | ||||||
| 
 | 
 | ||||||
| interface IProjectRoleForm { | interface IProjectRoleForm { | ||||||
| @ -21,7 +21,7 @@ interface IProjectRoleForm { | |||||||
|     errors: { [key: string]: string }; |     errors: { [key: string]: string }; | ||||||
|     children: ReactNode; |     children: ReactNode; | ||||||
|     permissions: |     permissions: | ||||||
|         | IProjectRolePermissions |         | IPermissions | ||||||
|         | { |         | { | ||||||
|               project: IPermission[]; |               project: IPermission[]; | ||||||
|               environments: IProjectEnvironmentPermissions[]; |               environments: IProjectEnvironmentPermissions[]; | ||||||
|  | |||||||
| @ -9,9 +9,9 @@ import { | |||||||
| } from 'component/common/Table'; | } from 'component/common/Table'; | ||||||
| import { useTable, useGlobalFilter, useSortBy } from 'react-table'; | import { useTable, useGlobalFilter, useSortBy } from 'react-table'; | ||||||
| import { ADMIN } from 'component/providers/AccessProvider/permissions'; | import { ADMIN } from 'component/providers/AccessProvider/permissions'; | ||||||
| import useProjectRoles from 'hooks/api/getters/useProjectRoles/useProjectRoles'; | import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; | ||||||
| import IRole, { IProjectRole } from 'interfaces/role'; | import { IProjectRole } from 'interfaces/role'; | ||||||
| import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; | import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; | ||||||
| import useToast from 'hooks/useToast'; | import useToast from 'hooks/useToast'; | ||||||
| import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm'; | import ProjectRoleDeleteConfirm from '../ProjectRoleDeleteConfirm/ProjectRoleDeleteConfirm'; | ||||||
| import { formatUnknownError } from 'utils/formatUnknownError'; | 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 { Search } from 'component/common/Search/Search'; | ||||||
| import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | ||||||
| 
 | 
 | ||||||
| const ROOTROLE = 'root'; |  | ||||||
| const BUILTIN_ROLE_TYPE = 'project'; | const BUILTIN_ROLE_TYPE = 'project'; | ||||||
| 
 | 
 | ||||||
| const ProjectRoleList = () => { | const ProjectRoleList = () => { | ||||||
|     const navigate = useNavigate(); |     const navigate = useNavigate(); | ||||||
|     const { roles, refetch, loading } = useProjectRoles(); |     const { projectRoles: data, refetch, loading } = useRoles(); | ||||||
| 
 | 
 | ||||||
|     const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); |     const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); | ||||||
| 
 | 
 | ||||||
|     const paginationFilter = (role: IRole) => role?.type !== ROOTROLE; |     const { removeRole } = useRolesApi(); | ||||||
|     const data = roles.filter(paginationFilter); |  | ||||||
| 
 |  | ||||||
|     const { deleteRole } = useProjectRolesApi(); |  | ||||||
|     const [currentRole, setCurrentRole] = useState<IProjectRole | null>(null); |     const [currentRole, setCurrentRole] = useState<IProjectRole | null>(null); | ||||||
|     const [delDialog, setDelDialog] = useState(false); |     const [delDialog, setDelDialog] = useState(false); | ||||||
|     const [confirmName, setConfirmName] = useState(''); |     const [confirmName, setConfirmName] = useState(''); | ||||||
| @ -51,7 +47,7 @@ const ProjectRoleList = () => { | |||||||
|     const deleteProjectRole = async () => { |     const deleteProjectRole = async () => { | ||||||
|         if (!currentRole?.id) return; |         if (!currentRole?.id) return; | ||||||
|         try { |         try { | ||||||
|             await deleteRole(currentRole?.id); |             await removeRole(currentRole?.id); | ||||||
|             refetch(); |             refetch(); | ||||||
|             setToastData({ |             setToastData({ | ||||||
|                 type: 'success', |                 type: 'success', | ||||||
| @ -99,7 +95,7 @@ const ProjectRoleList = () => { | |||||||
|                             data-loading |                             data-loading | ||||||
|                             disabled={type === BUILTIN_ROLE_TYPE} |                             disabled={type === BUILTIN_ROLE_TYPE} | ||||||
|                             onClick={() => { |                             onClick={() => { | ||||||
|                                 navigate(`/admin/roles/${id}/edit`); |                                 navigate(`/admin/project-roles/${id}/edit`); | ||||||
|                             }} |                             }} | ||||||
|                             permission={ADMIN} |                             permission={ADMIN} | ||||||
|                             tooltipProps={{ |                             tooltipProps={{ | ||||||
| @ -208,7 +204,7 @@ const ProjectRoleList = () => { | |||||||
|                                 variant="contained" |                                 variant="contained" | ||||||
|                                 color="primary" |                                 color="primary" | ||||||
|                                 onClick={() => |                                 onClick={() => | ||||||
|                                     navigate('/admin/create-project-role') |                                     navigate('/admin/project-roles/new') | ||||||
|                                 } |                                 } | ||||||
|                             > |                             > | ||||||
|                                 New project role |                                 New project role | ||||||
|  | |||||||
| @ -1,8 +1,8 @@ | |||||||
| import { useEffect, useState } from 'react'; | import { useEffect, useState } from 'react'; | ||||||
| import { IPermission } from 'interfaces/project'; | import { IPermission } from 'interfaces/permissions'; | ||||||
| import cloneDeep from 'lodash.clonedeep'; | import cloneDeep from 'lodash.clonedeep'; | ||||||
| import useProjectRolePermissions from 'hooks/api/getters/useProjectRolePermissions/useProjectRolePermissions'; | import usePermissions from 'hooks/api/getters/usePermissions/usePermissions'; | ||||||
| import useProjectRolesApi from 'hooks/api/actions/useProjectRolesApi/useProjectRolesApi'; | import { useRolesApi } from 'hooks/api/actions/useRolesApi/useRolesApi'; | ||||||
| import { formatUnknownError } from 'utils/formatUnknownError'; | import { formatUnknownError } from 'utils/formatUnknownError'; | ||||||
| 
 | 
 | ||||||
| export interface ICheckedPermission { | export interface ICheckedPermission { | ||||||
| @ -23,7 +23,7 @@ const useProjectRoleForm = ( | |||||||
|     initialRoleDesc = '', |     initialRoleDesc = '', | ||||||
|     initialCheckedPermissions: IPermission[] = [] |     initialCheckedPermissions: IPermission[] = [] | ||||||
| ) => { | ) => { | ||||||
|     const { permissions } = useProjectRolePermissions({ |     const { permissions } = usePermissions({ | ||||||
|         revalidateIfStale: false, |         revalidateIfStale: false, | ||||||
|         revalidateOnReconnect: false, |         revalidateOnReconnect: false, | ||||||
|         revalidateOnFocus: false, |         revalidateOnFocus: false, | ||||||
| @ -53,7 +53,7 @@ const useProjectRoleForm = ( | |||||||
| 
 | 
 | ||||||
|     const [errors, setErrors] = useState({}); |     const [errors, setErrors] = useState({}); | ||||||
| 
 | 
 | ||||||
|     const { validateRole } = useProjectRolesApi(); |     const { validateRole } = useRolesApi(); | ||||||
| 
 | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         setRoleName(initialRoleName); |         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, |     Radio, | ||||||
|     RadioGroup, |     RadioGroup, | ||||||
|     styled, |     styled, | ||||||
|     Typography, |  | ||||||
| } from '@mui/material'; | } from '@mui/material'; | ||||||
| import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | ||||||
| import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; | import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; | ||||||
| @ -33,6 +32,8 @@ import { useServiceAccountTokensApi } from 'hooks/api/actions/useServiceAccountT | |||||||
| import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; | import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; | ||||||
| import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens'; | import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens'; | ||||||
| import { IServiceAccount } from 'interfaces/service-account'; | import { IServiceAccount } from 'interfaces/service-account'; | ||||||
|  | import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; | ||||||
|  | import IRole from 'interfaces/role'; | ||||||
| 
 | 
 | ||||||
| const StyledForm = styled('form')(() => ({ | const StyledForm = styled('form')(() => ({ | ||||||
|     display: 'flex', |     display: 'flex', | ||||||
| @ -59,14 +60,9 @@ const StyledInput = styled(Input)(({ theme }) => ({ | |||||||
|     maxWidth: theme.spacing(50), |     maxWidth: theme.spacing(50), | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({ | const StyledRoleSelect = styled(RoleSelect)(({ theme }) => ({ | ||||||
|     margin: theme.spacing(0.5, 0), |     width: '100%', | ||||||
|     border: `1px solid ${theme.palette.divider}`, |     maxWidth: theme.spacing(50), | ||||||
|     padding: theme.spacing(2), |  | ||||||
| })); |  | ||||||
| 
 |  | ||||||
| const StyledRoleRadio = styled(Radio)(({ theme }) => ({ |  | ||||||
|     marginRight: theme.spacing(2), |  | ||||||
| })); | })); | ||||||
| 
 | 
 | ||||||
| const StyledSecondaryContainer = styled('div')(({ theme }) => ({ | const StyledSecondaryContainer = styled('div')(({ theme }) => ({ | ||||||
| @ -133,7 +129,7 @@ export const ServiceAccountModal = ({ | |||||||
| 
 | 
 | ||||||
|     const [name, setName] = useState(''); |     const [name, setName] = useState(''); | ||||||
|     const [username, setUsername] = useState(''); |     const [username, setUsername] = useState(''); | ||||||
|     const [rootRole, setRootRole] = useState(1); |     const [rootRole, setRootRole] = useState<IRole | null>(null); | ||||||
|     const [tokenGeneration, setTokenGeneration] = useState<TokenGeneration>( |     const [tokenGeneration, setTokenGeneration] = useState<TokenGeneration>( | ||||||
|         TokenGeneration.LATER |         TokenGeneration.LATER | ||||||
|     ); |     ); | ||||||
| @ -160,7 +156,9 @@ export const ServiceAccountModal = ({ | |||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         setName(serviceAccount?.name || ''); |         setName(serviceAccount?.name || ''); | ||||||
|         setUsername(serviceAccount?.username || ''); |         setUsername(serviceAccount?.username || ''); | ||||||
|         setRootRole(serviceAccount?.rootRole || 1); |         setRootRole( | ||||||
|  |             roles.find(({ id }) => id === serviceAccount?.rootRole) || null | ||||||
|  |         ); | ||||||
|         setTokenGeneration(TokenGeneration.LATER); |         setTokenGeneration(TokenGeneration.LATER); | ||||||
|         setErrors({}); |         setErrors({}); | ||||||
| 
 | 
 | ||||||
| @ -173,7 +171,7 @@ export const ServiceAccountModal = ({ | |||||||
|     const getServiceAccountPayload = (): IServiceAccountPayload => ({ |     const getServiceAccountPayload = (): IServiceAccountPayload => ({ | ||||||
|         name, |         name, | ||||||
|         username, |         username, | ||||||
|         rootRole, |         rootRole: rootRole?.id || 0, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { |     const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { | ||||||
| @ -226,6 +224,7 @@ export const ServiceAccountModal = ({ | |||||||
|             (serviceAccount: IServiceAccount) => |             (serviceAccount: IServiceAccount) => | ||||||
|                 serviceAccount.username === value |                 serviceAccount.username === value | ||||||
|         ); |         ); | ||||||
|  |     const isRoleValid = rootRole !== null; | ||||||
|     const isPATValid = |     const isPATValid = | ||||||
|         tokenGeneration === TokenGeneration.LATER || |         tokenGeneration === TokenGeneration.LATER || | ||||||
|         (isNotEmpty(patDescription) && patExpiresAt > new Date()); |         (isNotEmpty(patDescription) && patExpiresAt > new Date()); | ||||||
| @ -233,6 +232,7 @@ export const ServiceAccountModal = ({ | |||||||
|         isNotEmpty(name) && |         isNotEmpty(name) && | ||||||
|         isNotEmpty(username) && |         isNotEmpty(username) && | ||||||
|         (editing || isUnique(username)) && |         (editing || isUnique(username)) && | ||||||
|  |         isRoleValid && | ||||||
|         isPATValid; |         isPATValid; | ||||||
| 
 | 
 | ||||||
|     const suggestUsername = () => { |     const suggestUsername = () => { | ||||||
| @ -305,39 +305,11 @@ export const ServiceAccountModal = ({ | |||||||
|                         <StyledInputDescription> |                         <StyledInputDescription> | ||||||
|                             What is your service account allowed to do? |                             What is your service account allowed to do? | ||||||
|                         </StyledInputDescription> |                         </StyledInputDescription> | ||||||
|                         <FormControl> |                         <StyledRoleSelect | ||||||
|                             <RadioGroup |                             value={rootRole} | ||||||
|                                 name="rootRole" |                             setValue={setRootRole} | ||||||
|                                 value={rootRole || ''} |                             required | ||||||
|                                 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> |  | ||||||
|                         <ConditionallyRender |                         <ConditionallyRender | ||||||
|                             condition={!editing} |                             condition={!editing} | ||||||
|                             show={ |                             show={ | ||||||
|  | |||||||
| @ -14,7 +14,7 @@ const StyledItem = styled(Typography)(({ theme }) => ({ | |||||||
| interface IServiceAccountTokensCellProps { | interface IServiceAccountTokensCellProps { | ||||||
|     serviceAccount: IServiceAccount; |     serviceAccount: IServiceAccount; | ||||||
|     value: string; |     value: string; | ||||||
|     onCreateToken: () => void; |     onCreateToken?: () => void; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const ServiceAccountTokensCell: VFC<IServiceAccountTokensCellProps> = ({ | export const ServiceAccountTokensCell: VFC<IServiceAccountTokensCellProps> = ({ | ||||||
| @ -24,8 +24,10 @@ export const ServiceAccountTokensCell: VFC<IServiceAccountTokensCellProps> = ({ | |||||||
| }) => { | }) => { | ||||||
|     const { searchQuery } = useSearchHighlightContext(); |     const { searchQuery } = useSearchHighlightContext(); | ||||||
| 
 | 
 | ||||||
|     if (!serviceAccount.tokens || serviceAccount.tokens.length === 0) |     if (!serviceAccount.tokens || serviceAccount.tokens.length === 0) { | ||||||
|         return <LinkCell title="Create token" onClick={onCreateToken} />; |         if (!onCreateToken) return <TextCell>0 tokens</TextCell>; | ||||||
|  |         else return <LinkCell title="Create token" onClick={onCreateToken} />; | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <TextCell> |         <TextCell> | ||||||
|  | |||||||
| @ -28,6 +28,7 @@ import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAc | |||||||
| import { ServiceAccountTokensCell } from './ServiceAccountTokensCell/ServiceAccountTokensCell'; | import { ServiceAccountTokensCell } from './ServiceAccountTokensCell/ServiceAccountTokensCell'; | ||||||
| import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; | import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell'; | ||||||
| import { IServiceAccount } from 'interfaces/service-account'; | import { IServiceAccount } from 'interfaces/service-account'; | ||||||
|  | import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell'; | ||||||
| 
 | 
 | ||||||
| export const ServiceAccountsTable = () => { | export const ServiceAccountsTable = () => { | ||||||
|     const { setToastData, setToastApiError } = useToast(); |     const { setToastData, setToastApiError } = useToast(); | ||||||
| @ -92,6 +93,9 @@ export const ServiceAccountsTable = () => { | |||||||
|                 accessor: (row: any) => |                 accessor: (row: any) => | ||||||
|                     roles.find((role: IRole) => role.id === row.rootRole) |                     roles.find((role: IRole) => role.id === row.rootRole) | ||||||
|                         ?.name || '', |                         ?.name || '', | ||||||
|  |                 Cell: ({ row: { original: serviceAccount }, value }: any) => ( | ||||||
|  |                     <RoleCell value={value} roleId={serviceAccount.rootRole} /> | ||||||
|  |                 ), | ||||||
|                 maxWidth: 120, |                 maxWidth: 120, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|  | |||||||
| @ -1,19 +1,11 @@ | |||||||
| import Input from 'component/common/Input/Input'; | import Input from 'component/common/Input/Input'; | ||||||
| import { | import { Button, FormControl, Typography, Switch, styled } from '@mui/material'; | ||||||
|     FormControlLabel, |  | ||||||
|     Button, |  | ||||||
|     RadioGroup, |  | ||||||
|     FormControl, |  | ||||||
|     Typography, |  | ||||||
|     Radio, |  | ||||||
|     Switch, |  | ||||||
|     styled, |  | ||||||
| } from '@mui/material'; |  | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; |  | ||||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||||
| import { EDIT } from 'constants/misc'; | import { EDIT } from 'constants/misc'; | ||||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
|  | import { RoleSelect } from 'component/common/RoleSelect/RoleSelect'; | ||||||
|  | import IRole from 'interfaces/role'; | ||||||
| 
 | 
 | ||||||
| const StyledForm = styled('form')(() => ({ | const StyledForm = styled('form')(() => ({ | ||||||
|     display: 'flex', |     display: 'flex', | ||||||
| @ -38,16 +30,6 @@ const StyledRoleSubtitle = styled(Typography)(({ theme }) => ({ | |||||||
|     margin: theme.spacing(1, 0), |     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')(() => ({ | const StyledFlexRow = styled('div')(() => ({ | ||||||
|     display: 'flex', |     display: 'flex', | ||||||
|     alignItems: 'center', |     alignItems: 'center', | ||||||
| @ -66,12 +48,12 @@ const StyledCancelButton = styled(Button)(({ theme }) => ({ | |||||||
| interface IUserForm { | interface IUserForm { | ||||||
|     email: string; |     email: string; | ||||||
|     name: string; |     name: string; | ||||||
|     rootRole: number; |     rootRole: IRole | null; | ||||||
|     sendEmail: boolean; |     sendEmail: boolean; | ||||||
|     setEmail: React.Dispatch<React.SetStateAction<string>>; |     setEmail: React.Dispatch<React.SetStateAction<string>>; | ||||||
|     setName: React.Dispatch<React.SetStateAction<string>>; |     setName: React.Dispatch<React.SetStateAction<string>>; | ||||||
|     setSendEmail: React.Dispatch<React.SetStateAction<boolean>>; |     setSendEmail: React.Dispatch<React.SetStateAction<boolean>>; | ||||||
|     setRootRole: React.Dispatch<React.SetStateAction<number>>; |     setRootRole: React.Dispatch<React.SetStateAction<IRole | null>>; | ||||||
|     handleSubmit: (e: any) => void; |     handleSubmit: (e: any) => void; | ||||||
|     handleCancel: () => void; |     handleCancel: () => void; | ||||||
|     errors: { [key: string]: string }; |     errors: { [key: string]: string }; | ||||||
| @ -95,19 +77,8 @@ const UserForm: React.FC<IUserForm> = ({ | |||||||
|     clearErrors, |     clearErrors, | ||||||
|     mode, |     mode, | ||||||
| }) => { | }) => { | ||||||
|     const { roles } = useUsers(); |  | ||||||
|     const { uiConfig } = useUiConfig(); |     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 ( |     return ( | ||||||
|         <StyledForm onSubmit={handleSubmit}> |         <StyledForm onSubmit={handleSubmit}> | ||||||
|             <StyledContainer> |             <StyledContainer> | ||||||
| @ -132,39 +103,10 @@ const UserForm: React.FC<IUserForm> = ({ | |||||||
|                     errorText={errors.email} |                     errorText={errors.email} | ||||||
|                     onFocus={() => clearErrors()} |                     onFocus={() => clearErrors()} | ||||||
|                 /> |                 /> | ||||||
|                 <FormControl> |                 <StyledRoleSubtitle variant="subtitle1" data-loading> | ||||||
|                     <StyledRoleSubtitle variant="subtitle1" data-loading> |                     What is your team member allowed to do? | ||||||
|                         What is your team member allowed to do? |                 </StyledRoleSubtitle> | ||||||
|                     </StyledRoleSubtitle> |                 <RoleSelect value={rootRole} setValue={setRootRole} required /> | ||||||
|                     <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> |  | ||||||
|                 <ConditionallyRender |                 <ConditionallyRender | ||||||
|                     condition={mode !== EDIT && Boolean(uiConfig?.emailEnabled)} |                     condition={mode !== EDIT && Boolean(uiConfig?.emailEnabled)} | ||||||
|                     show={ |                     show={ | ||||||
|  | |||||||
| @ -34,6 +34,7 @@ import { Search } from 'component/common/Search/Search'; | |||||||
| import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; | import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; | ||||||
| import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; | ||||||
| import { UserLimitWarning } from './UserLimitWarning/UserLimitWarning'; | import { UserLimitWarning } from './UserLimitWarning/UserLimitWarning'; | ||||||
|  | import { RoleCell } from 'component/common/Table/cells/RoleCell/RoleCell'; | ||||||
| 
 | 
 | ||||||
| const UsersList = () => { | const UsersList = () => { | ||||||
|     const navigate = useNavigate(); |     const navigate = useNavigate(); | ||||||
| @ -126,6 +127,9 @@ const UsersList = () => { | |||||||
|                 accessor: (row: any) => |                 accessor: (row: any) => | ||||||
|                     roles.find((role: IRole) => role.id === row.rootRole) |                     roles.find((role: IRole) => role.id === row.rootRole) | ||||||
|                         ?.name || '', |                         ?.name || '', | ||||||
|  |                 Cell: ({ row: { original: user }, value }: any) => ( | ||||||
|  |                     <RoleCell value={value} roleId={user.rootRole} /> | ||||||
|  |                 ), | ||||||
|                 disableGlobalFilter: true, |                 disableGlobalFilter: true, | ||||||
|                 maxWidth: 120, |                 maxWidth: 120, | ||||||
|             }, |             }, | ||||||
|  | |||||||
| @ -1,17 +1,22 @@ | |||||||
| import { useEffect, useState } from 'react'; | import { useEffect, useState } from 'react'; | ||||||
| import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; | import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; | ||||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
|  | import IRole from 'interfaces/role'; | ||||||
|  | import { useRoles } from 'hooks/api/getters/useRoles/useRoles'; | ||||||
| 
 | 
 | ||||||
| const useCreateUserForm = ( | const useCreateUserForm = ( | ||||||
|     initialName = '', |     initialName = '', | ||||||
|     initialEmail = '', |     initialEmail = '', | ||||||
|     initialRootRole = 1 |     initialRootRole = null | ||||||
| ) => { | ) => { | ||||||
|     const { uiConfig } = useUiConfig(); |     const { uiConfig } = useUiConfig(); | ||||||
|  |     const { roles } = useRoles(); | ||||||
|     const [name, setName] = useState(initialName); |     const [name, setName] = useState(initialName); | ||||||
|     const [email, setEmail] = useState(initialEmail); |     const [email, setEmail] = useState(initialEmail); | ||||||
|     const [sendEmail, setSendEmail] = useState(false); |     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 [errors, setErrors] = useState({}); | ||||||
| 
 | 
 | ||||||
|     const { users } = useUsers(); |     const { users } = useUsers(); | ||||||
| @ -29,7 +34,7 @@ const useCreateUserForm = ( | |||||||
|     }, [uiConfig?.emailEnabled]); |     }, [uiConfig?.emailEnabled]); | ||||||
| 
 | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         setRootRole(initialRootRole); |         setRootRole(roles.find(({ id }) => id === initialRootRole) || null); | ||||||
|     }, [initialRootRole]); |     }, [initialRootRole]); | ||||||
| 
 | 
 | ||||||
|     const getAddUserPayload = () => { |     const getAddUserPayload = () => { | ||||||
| @ -37,7 +42,7 @@ const useCreateUserForm = ( | |||||||
|             name: name, |             name: name, | ||||||
|             email: email, |             email: email, | ||||||
|             sendEmail: sendEmail, |             sendEmail: sendEmail, | ||||||
|             rootRole: rootRole, |             rootRole: rootRole?.id || 0, | ||||||
|         }; |         }; | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
| @ -54,7 +59,6 @@ const useCreateUserForm = ( | |||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const validateEmail = () => { |     const validateEmail = () => { | ||||||
|         // @ts-expect-error
 |  | ||||||
|         if (users.some(user => user['email'] === email)) { |         if (users.some(user => user['email'] === email)) { | ||||||
|             setErrors(prev => ({ ...prev, email: 'Email already exists' })); |             setErrors(prev => ({ ...prev, email: 'Email already exists' })); | ||||||
|             return false; |             return false; | ||||||
|  | |||||||
| @ -65,6 +65,7 @@ export interface IHtmlTooltipProps extends TooltipProps { | |||||||
|     fontSize?: string; |     fontSize?: string; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const HtmlTooltip = (props: IHtmlTooltipProps) => ( | export const HtmlTooltip = (props: IHtmlTooltipProps) => { | ||||||
|     <StyledHtmlTooltip {...props}>{props.children}</StyledHtmlTooltip> |     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 { | interface IHighlightCellProps { | ||||||
|     value: string; |     value: string; | ||||||
|     subtitle?: string; |     subtitle?: string; | ||||||
|  |     afterTitle?: React.ReactNode; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const StyledContainer = styled(Box)(({ theme }) => ({ | const StyledContainer = styled(Box)(({ theme }) => ({ | ||||||
| @ -40,6 +41,7 @@ const StyledSubtitle = styled('span')(({ theme }) => ({ | |||||||
| export const HighlightCell: VFC<IHighlightCellProps> = ({ | export const HighlightCell: VFC<IHighlightCellProps> = ({ | ||||||
|     value, |     value, | ||||||
|     subtitle, |     subtitle, | ||||||
|  |     afterTitle, | ||||||
| }) => { | }) => { | ||||||
|     const { searchQuery } = useSearchHighlightContext(); |     const { searchQuery } = useSearchHighlightContext(); | ||||||
| 
 | 
 | ||||||
| @ -53,6 +55,7 @@ export const HighlightCell: VFC<IHighlightCellProps> = ({ | |||||||
|                 data-loading |                 data-loading | ||||||
|             > |             > | ||||||
|                 <Highlighter search={searchQuery}>{value}</Highlighter> |                 <Highlighter search={searchQuery}>{value}</Highlighter> | ||||||
|  |                 {afterTitle} | ||||||
|             </StyledTitle> |             </StyledTitle> | ||||||
|             <ConditionallyRender |             <ConditionallyRender | ||||||
|                 condition={Boolean(subtitle)} |                 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 useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
| import useToast from 'hooks/useToast'; | import useToast from 'hooks/useToast'; | ||||||
| import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; | 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||||
| import { PageContent } from 'component/common/PageContent/PageContent'; | import { PageContent } from 'component/common/PageContent/PageContent'; | ||||||
| import { ADMIN } from 'component/providers/AccessProvider/permissions'; | import { ADMIN } from 'component/providers/AccessProvider/permissions'; | ||||||
| @ -25,7 +25,7 @@ const CreateEnvironment = () => { | |||||||
|     const { environments } = useEnvironments(); |     const { environments } = useEnvironments(); | ||||||
|     const canCreateMoreEnvs = environments.length < ENV_LIMIT; |     const canCreateMoreEnvs = environments.length < ENV_LIMIT; | ||||||
|     const { createEnvironment, loading } = useEnvironmentApi(); |     const { createEnvironment, loading } = useEnvironmentApi(); | ||||||
|     const { refetch } = useProjectRolePermissions(); |     const { refetch } = usePermissions(); | ||||||
|     const { |     const { | ||||||
|         name, |         name, | ||||||
|         setName, |         setName, | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | |||||||
| import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; | import { UpdateButton } from 'component/common/UpdateButton/UpdateButton'; | ||||||
| import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; | import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; | ||||||
| import useEnvironment from 'hooks/api/getters/useEnvironment/useEnvironment'; | 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 useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||||
| import useToast from 'hooks/useToast'; | import useToast from 'hooks/useToast'; | ||||||
| import { useNavigate } from 'react-router-dom'; | import { useNavigate } from 'react-router-dom'; | ||||||
| @ -23,7 +23,7 @@ const EditEnvironment = () => { | |||||||
|     const navigate = useNavigate(); |     const navigate = useNavigate(); | ||||||
|     const { name, type, setName, setType, errors, clearErrors } = |     const { name, type, setName, setType, errors, clearErrors } = | ||||||
|         useEnvironmentForm(environment.name, environment.type); |         useEnvironmentForm(environment.name, environment.type); | ||||||
|     const { refetch } = useProjectRolePermissions(); |     const { refetch } = usePermissions(); | ||||||
| 
 | 
 | ||||||
|     const editPayload = () => { |     const editPayload = () => { | ||||||
|         return { |         return { | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ import { useState } from 'react'; | |||||||
| import { IEnvironment } from 'interfaces/environments'; | import { IEnvironment } from 'interfaces/environments'; | ||||||
| import { formatUnknownError } from 'utils/formatUnknownError'; | import { formatUnknownError } from 'utils/formatUnknownError'; | ||||||
| import useEnvironmentApi from 'hooks/api/actions/useEnvironmentApi/useEnvironmentApi'; | 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 { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; | ||||||
| import useToast from 'hooks/useToast'; | import useToast from 'hooks/useToast'; | ||||||
| import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover'; | import { EnvironmentActionCellPopover } from './EnvironmentActionCellPopover/EnvironmentActionCellPopover'; | ||||||
| @ -25,7 +25,7 @@ export const EnvironmentActionCell = ({ | |||||||
|     const navigate = useNavigate(); |     const navigate = useNavigate(); | ||||||
|     const { setToastApiError, setToastData } = useToast(); |     const { setToastApiError, setToastData } = useToast(); | ||||||
|     const { environments, refetchEnvironments } = useEnvironments(); |     const { environments, refetchEnvironments } = useEnvironments(); | ||||||
|     const { refetch: refetchPermissions } = useProjectRolePermissions(); |     const { refetch: refetchPermissions } = usePermissions(); | ||||||
|     const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } = |     const { deleteEnvironment, toggleEnvironmentOn, toggleEnvironmentOff } = | ||||||
|         useEnvironmentApi(); |         useEnvironmentApi(); | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -465,6 +465,12 @@ export const adminMenuRoutes: INavigationMenuItem[] = [ | |||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|         path: '/admin/roles', |         path: '/admin/roles', | ||||||
|  |         title: 'Roles', | ||||||
|  |         flag: 'customRootRoles', | ||||||
|  |         menu: { adminSettings: true, mode: ['enterprise'] }, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |         path: '/admin/project-roles', | ||||||
|         title: 'Project roles', |         title: 'Project roles', | ||||||
|         flag: RE, |         flag: RE, | ||||||
|         menu: { adminSettings: true, mode: ['enterprise'] }, |         menu: { adminSettings: true, mode: ['enterprise'] }, | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| import { styled, SxProps, Theme } from '@mui/material'; | import { styled, SxProps, Theme } from '@mui/material'; | ||||||
| import { ForwardedRef, forwardRef, useMemo, VFC } from 'react'; | 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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||||
| import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess'; | import useProjectAccess from 'hooks/api/getters/useProjectAccess/useProjectAccess'; | ||||||
| import { ProjectRoleDescriptionProjectPermissions } from './ProjectRoleDescriptionProjectPermissions/ProjectRoleDescriptionProjectPermissions'; | import { ProjectRoleDescriptionProjectPermissions } from './ProjectRoleDescriptionProjectPermissions/ProjectRoleDescriptionProjectPermissions'; | ||||||
| @ -64,13 +64,13 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = | |||||||
|             }: IProjectRoleDescriptionProps, |             }: IProjectRoleDescriptionProps, | ||||||
|             ref: ForwardedRef<HTMLDivElement> |             ref: ForwardedRef<HTMLDivElement> | ||||||
|         ) => { |         ) => { | ||||||
|             const { role } = useProjectRole(roleId.toString()); |             const { role } = useRole(roleId.toString()); | ||||||
|             const { access } = useProjectAccess(projectId); |             const { access } = useProjectAccess(projectId); | ||||||
|             const accessRole = access?.roles.find(role => role.id === roleId); |             const accessRole = access?.roles.find(role => role.id === roleId); | ||||||
| 
 | 
 | ||||||
|             const environments = useMemo(() => { |             const environments = useMemo(() => { | ||||||
|                 const environments = new Set<string>(); |                 const environments = new Set<string>(); | ||||||
|                 role.permissions |                 role?.permissions | ||||||
|                     ?.filter((permission: any) => permission.environment) |                     ?.filter((permission: any) => permission.environment) | ||||||
|                     .forEach((permission: any) => { |                     .forEach((permission: any) => { | ||||||
|                         environments.add(permission.environment); |                         environments.add(permission.environment); | ||||||
| @ -79,7 +79,7 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = | |||||||
|             }, [role]); |             }, [role]); | ||||||
| 
 | 
 | ||||||
|             const projectPermissions = useMemo(() => { |             const projectPermissions = useMemo(() => { | ||||||
|                 return role.permissions?.filter( |                 return role?.permissions?.filter( | ||||||
|                     (permission: any) => !permission.environment |                     (permission: any) => !permission.environment | ||||||
|                 ); |                 ); | ||||||
|             }, [role]); |             }, [role]); | ||||||
| @ -92,7 +92,9 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = | |||||||
|                     ref={ref} |                     ref={ref} | ||||||
|                 > |                 > | ||||||
|                     <ConditionallyRender |                     <ConditionallyRender | ||||||
|                         condition={role.permissions?.length > 0} |                         condition={Boolean( | ||||||
|  |                             role?.permissions && role?.permissions?.length > 0 | ||||||
|  |                         )} | ||||||
|                         show={ |                         show={ | ||||||
|                             <> |                             <> | ||||||
|                                 <ConditionallyRender |                                 <ConditionallyRender | ||||||
| @ -107,7 +109,7 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = | |||||||
|                                             <StyledDescriptionBlock> |                                             <StyledDescriptionBlock> | ||||||
|                                                 <ProjectRoleDescriptionProjectPermissions |                                                 <ProjectRoleDescriptionProjectPermissions | ||||||
|                                                     permissions={ |                                                     permissions={ | ||||||
|                                                         role.permissions |                                                         role?.permissions || [] | ||||||
|                                                     } |                                                     } | ||||||
|                                                 /> |                                                 /> | ||||||
|                                             </StyledDescriptionBlock> |                                             </StyledDescriptionBlock> | ||||||
| @ -132,7 +134,8 @@ export const ProjectRoleDescription: VFC<IProjectRoleDescriptionProps> = | |||||||
|                                                                 environment |                                                                 environment | ||||||
|                                                             } |                                                             } | ||||||
|                                                             permissions={ |                                                             permissions={ | ||||||
|                                                                 role.permissions |                                                                 role?.permissions || | ||||||
|  |                                                                 [] | ||||||
|                                                             } |                                                             } | ||||||
|                                                         /> |                                                         /> | ||||||
|                                                     </StyledDescriptionBlock> |                                                     </StyledDescriptionBlock> | ||||||
|  | |||||||
| @ -18,6 +18,7 @@ export const CREATE_ADDON = 'CREATE_ADDON'; | |||||||
| export const UPDATE_ADDON = 'UPDATE_ADDON'; | export const UPDATE_ADDON = 'UPDATE_ADDON'; | ||||||
| export const DELETE_ADDON = 'DELETE_ADDON'; | export const DELETE_ADDON = 'DELETE_ADDON'; | ||||||
| export const CREATE_API_TOKEN = 'CREATE_API_TOKEN'; | 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 DELETE_API_TOKEN = 'DELETE_API_TOKEN'; | ||||||
| export const READ_API_TOKEN = 'READ_API_TOKEN'; | export const READ_API_TOKEN = 'READ_API_TOKEN'; | ||||||
| export const DELETE_ENVIRONMENT = 'DELETE_ENVIRONMENT'; | 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 { useProfile } from 'hooks/api/getters/useProfile/useProfile'; | ||||||
| import { useLocationSettings } from 'hooks/useLocationSettings'; | import { useLocationSettings } from 'hooks/useLocationSettings'; | ||||||
| import { IUser } from 'interfaces/user'; | import { IUser } from 'interfaces/user'; | ||||||
| import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; |  | ||||||
| import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; | import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined'; | ||||||
| import { useNavigate } from 'react-router-dom'; | import { useNavigate } from 'react-router-dom'; | ||||||
| import { PageContent } from 'component/common/PageContent/PageContent'; | import { PageContent } from 'component/common/PageContent/PageContent'; | ||||||
| import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; | ||||||
|  | import { RoleBadge } from 'component/common/RoleBadge/RoleBadge'; | ||||||
| 
 | 
 | ||||||
| const StyledHeader = styled('div')(({ theme }) => ({ | const StyledHeader = styled('div')(({ theme }) => ({ | ||||||
|     display: 'flex', |     display: 'flex', | ||||||
| @ -134,21 +134,17 @@ export const ProfileTab = ({ user }: IProfileTabProps) => { | |||||||
|                 <StyledSectionLabel>Access</StyledSectionLabel> |                 <StyledSectionLabel>Access</StyledSectionLabel> | ||||||
|                 <StyledAccess> |                 <StyledAccess> | ||||||
|                     <Box sx={{ width: '50%' }}> |                     <Box sx={{ width: '50%' }}> | ||||||
|                         <Typography variant="body2">Your root role</Typography> |                         <ConditionallyRender | ||||||
|                         <Tooltip |                             condition={Boolean(profile?.rootRole)} | ||||||
|                             title={profile?.rootRole.description || ''} |                             show={() => ( | ||||||
|                             arrow |                                 <> | ||||||
|                             placement="bottom-end" |                                     <Typography variant="body2"> | ||||||
|                             describeChild |                                         Your root role | ||||||
|                         > |                                     </Typography> | ||||||
|                             <Badge |                                     <RoleBadge roleId={profile?.rootRole.id!} /> | ||||||
|                                 color="success" |                                 </> | ||||||
|                                 icon={<InfoOutlinedIcon />} |                             )} | ||||||
|                                 iconRight |                         /> | ||||||
|                             > |  | ||||||
|                                 {profile?.rootRole.name} |  | ||||||
|                             </Badge> |  | ||||||
|                         </Tooltip> |  | ||||||
|                     </Box> |                     </Box> | ||||||
|                     <Box> |                     <Box> | ||||||
|                         <Typography variant="body2">Projects</Typography> |                         <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 { | import { | ||||||
|     IProjectEnvironmentPermissions, |     IProjectEnvironmentPermissions, | ||||||
|     IProjectRolePermissions, |     IPermissions, | ||||||
|     IPermission, |     IPermission, | ||||||
| } from 'interfaces/project'; | } from 'interfaces/permissions'; | ||||||
| import handleErrorResponses from '../httpErrorResponseHandler'; | import handleErrorResponses from '../httpErrorResponseHandler'; | ||||||
| 
 | 
 | ||||||
| interface IUseProjectRolePermissions { | interface IUsePermissions { | ||||||
|     permissions: |     permissions: | ||||||
|         | IProjectRolePermissions |         | IPermissions | ||||||
|         | { |         | { | ||||||
|  |               root: IPermission[]; | ||||||
|               project: IPermission[]; |               project: IPermission[]; | ||||||
|               environments: IProjectEnvironmentPermissions[]; |               environments: IProjectEnvironmentPermissions[]; | ||||||
|           }; |           }; | ||||||
| @ -21,9 +22,7 @@ interface IUseProjectRolePermissions { | |||||||
|     error: any; |     error: any; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const useProjectRolePermissions = ( | const usePermissions = (options: SWRConfiguration = {}): IUsePermissions => { | ||||||
|     options: SWRConfiguration = {} |  | ||||||
| ): IUseProjectRolePermissions => { |  | ||||||
|     const fetcher = () => { |     const fetcher = () => { | ||||||
|         const path = formatApiPath(`api/admin/permissions`); |         const path = formatApiPath(`api/admin/permissions`); | ||||||
|         return fetch(path, { |         return fetch(path, { | ||||||
| @ -35,7 +34,7 @@ const useProjectRolePermissions = ( | |||||||
| 
 | 
 | ||||||
|     const KEY = `api/admin/permissions`; |     const KEY = `api/admin/permissions`; | ||||||
| 
 | 
 | ||||||
|     const { data, error } = useSWR<{ permissions: IProjectRolePermissions }>( |     const { data, error } = useSWR<{ permissions: IPermissions }>( | ||||||
|         KEY, |         KEY, | ||||||
|         fetcher, |         fetcher, | ||||||
|         options |         options | ||||||
| @ -51,11 +50,15 @@ const useProjectRolePermissions = ( | |||||||
|     }, [data, error]); |     }, [data, error]); | ||||||
| 
 | 
 | ||||||
|     return { |     return { | ||||||
|         permissions: data?.permissions || { project: [], environments: [] }, |         permissions: data?.permissions || { | ||||||
|  |             root: [], | ||||||
|  |             project: [], | ||||||
|  |             environments: [], | ||||||
|  |         }, | ||||||
|         error, |         error, | ||||||
|         loading, |         loading, | ||||||
|         refetch, |         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 { useMemo } from 'react'; | ||||||
| import { formatApiPath } from 'utils/formatPath'; | import { formatApiPath } from 'utils/formatPath'; | ||||||
| import handleErrorResponses from '../httpErrorResponseHandler'; | 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( |     const { data, error, mutate } = useSWR( | ||||||
|         formatApiPath(`api/admin/user-admin`), |         formatApiPath(`api/admin/user-admin`), | ||||||
|         fetcher |         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; |     activeCount: number; | ||||||
|     updatedAt: string; |     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 { | interface IRole { | ||||||
|     id: number; |     id: number; | ||||||
|     name: string; |     name: string; | ||||||
|     project: string | null; |     project: string | null; | ||||||
|     description: string; |     description: string; | ||||||
|     type: string; |     type: string; | ||||||
|  |     permissions?: IPermission[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface IProjectRole { | export interface IProjectRole { | ||||||
|  | |||||||
| @ -54,6 +54,7 @@ export interface IFlags { | |||||||
|     segmentContextFieldUsage?: boolean; |     segmentContextFieldUsage?: boolean; | ||||||
|     disableNotifications?: boolean; |     disableNotifications?: boolean; | ||||||
|     advancedPlayground?: boolean; |     advancedPlayground?: boolean; | ||||||
|  |     customRootRoles?: boolean; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface IVersionInfo { | export interface IVersionInfo { | ||||||
|  | |||||||
| @ -71,6 +71,7 @@ exports[`should create default config 1`] = ` | |||||||
|       "anonymiseEventLog": false, |       "anonymiseEventLog": false, | ||||||
|       "caseInsensitiveInOperators": false, |       "caseInsensitiveInOperators": false, | ||||||
|       "cleanClientApi": false, |       "cleanClientApi": false, | ||||||
|  |       "customRootRoles": false, | ||||||
|       "demo": false, |       "demo": false, | ||||||
|       "disableBulkToggle": false, |       "disableBulkToggle": false, | ||||||
|       "disableNotifications": false, |       "disableNotifications": false, | ||||||
| @ -105,6 +106,7 @@ exports[`should create default config 1`] = ` | |||||||
|       "anonymiseEventLog": false, |       "anonymiseEventLog": false, | ||||||
|       "caseInsensitiveInOperators": false, |       "caseInsensitiveInOperators": false, | ||||||
|       "cleanClientApi": false, |       "cleanClientApi": false, | ||||||
|  |       "customRootRoles": false, | ||||||
|       "demo": false, |       "demo": false, | ||||||
|       "disableBulkToggle": false, |       "disableBulkToggle": false, | ||||||
|       "disableNotifications": false, |       "disableNotifications": false, | ||||||
|  | |||||||
| @ -101,6 +101,7 @@ export class AccessStore implements IAccessStore { | |||||||
|             .select(['id', 'permission', 'type', 'display_name']) |             .select(['id', 'permission', 'type', 'display_name']) | ||||||
|             .where('type', 'project') |             .where('type', 'project') | ||||||
|             .orWhere('type', 'environment') |             .orWhere('type', 'environment') | ||||||
|  |             .orWhere('type', 'root') | ||||||
|             .from(`${T.PERMISSIONS} as p`); |             .from(`${T.PERMISSIONS} as p`); | ||||||
|         return rows.map(this.mapPermission); |         return rows.map(this.mapPermission); | ||||||
|     } |     } | ||||||
| @ -172,7 +173,7 @@ export class AccessStore implements IAccessStore { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     mapUserPermission(row: IPermissionRow): IUserPermission { |     mapUserPermission(row: IPermissionRow): IUserPermission { | ||||||
|         let project: string = undefined; |         let project: string | undefined = undefined; | ||||||
|         // Since the editor should have access to the default project,
 |         // Since the editor should have access to the default project,
 | ||||||
|         // we map the project to the project and environment specific
 |         // we map the project to the project and environment specific
 | ||||||
|         // permissions that are connected to the editor role.
 |         // permissions that are connected to the editor role.
 | ||||||
| @ -425,11 +426,11 @@ export class AccessStore implements IAccessStore { | |||||||
| 
 | 
 | ||||||
|     async removeRolesOfTypeForUser( |     async removeRolesOfTypeForUser( | ||||||
|         userId: number, |         userId: number, | ||||||
|         roleType: string, |         roleTypes: string[], | ||||||
|     ): Promise<void> { |     ): Promise<void> { | ||||||
|         const rolesToRemove = this.db(T.ROLES) |         const rolesToRemove = this.db(T.ROLES) | ||||||
|             .select('id') |             .select('id') | ||||||
|             .where({ type: roleType }); |             .whereIn('type', roleTypes); | ||||||
| 
 | 
 | ||||||
|         return this.db(T.ROLE_USER) |         return this.db(T.ROLE_USER) | ||||||
|             .where({ user_id: userId }) |             .where({ user_id: userId }) | ||||||
|  | |||||||
| @ -160,7 +160,7 @@ export default class RoleStore implements IRoleStore { | |||||||
|         return this.db |         return this.db | ||||||
|             .select(['id', 'name', 'type', 'description']) |             .select(['id', 'name', 'type', 'description']) | ||||||
|             .from<IRole>(T.ROLES) |             .from<IRole>(T.ROLES) | ||||||
|             .where('type', 'root'); |             .whereIn('type', ['root', 'root-custom']); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async removeRolesForProject(projectId: string): Promise<void> { |     async removeRolesForProject(projectId: string): Promise<void> { | ||||||
| @ -177,7 +177,7 @@ export default class RoleStore implements IRoleStore { | |||||||
|             .distinctOn('user_id') |             .distinctOn('user_id') | ||||||
|             .from(`${T.ROLES} AS r`) |             .from(`${T.ROLES} AS r`) | ||||||
|             .leftJoin(`${T.ROLE_USER} AS ru`, 'r.id', 'ru.role_id') |             .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) => ({ |         return rows.map((row) => ({ | ||||||
|             roleId: Number(row.id), |             roleId: Number(row.id), | ||||||
|  | |||||||
| @ -17,7 +17,7 @@ export const createAccessService = ( | |||||||
|     db: Db, |     db: Db, | ||||||
|     config: IUnleashConfig, |     config: IUnleashConfig, | ||||||
| ): AccessService => { | ): AccessService => { | ||||||
|     const { eventBus, getLogger } = config; |     const { eventBus, getLogger, flagResolver } = config; | ||||||
|     const eventStore = new EventStore(db, getLogger); |     const eventStore = new EventStore(db, getLogger); | ||||||
|     const groupStore = new GroupStore(db); |     const groupStore = new GroupStore(db); | ||||||
|     const accountStore = new AccountStore(db, getLogger); |     const accountStore = new AccountStore(db, getLogger); | ||||||
| @ -31,7 +31,7 @@ export const createAccessService = ( | |||||||
| 
 | 
 | ||||||
|     return new AccessService( |     return new AccessService( | ||||||
|         { accessStore, accountStore, roleStore, environmentStore }, |         { accessStore, accountStore, roleStore, environmentStore }, | ||||||
|         { getLogger }, |         { getLogger, flagResolver }, | ||||||
|         groupService, |         groupService, | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| @ -39,7 +39,7 @@ export const createAccessService = ( | |||||||
| export const createFakeAccessService = ( | export const createFakeAccessService = ( | ||||||
|     config: IUnleashConfig, |     config: IUnleashConfig, | ||||||
| ): AccessService => { | ): AccessService => { | ||||||
|     const { getLogger } = config; |     const { getLogger, flagResolver } = config; | ||||||
|     const eventStore = new FakeEventStore(); |     const eventStore = new FakeEventStore(); | ||||||
|     const groupStore = new FakeGroupStore(); |     const groupStore = new FakeGroupStore(); | ||||||
|     const accountStore = new FakeAccountStore(); |     const accountStore = new FakeAccountStore(); | ||||||
| @ -53,7 +53,7 @@ export const createFakeAccessService = ( | |||||||
| 
 | 
 | ||||||
|     return new AccessService( |     return new AccessService( | ||||||
|         { accessStore, accountStore, roleStore, environmentStore }, |         { accessStore, accountStore, roleStore, environmentStore }, | ||||||
|         { getLogger }, |         { getLogger, flagResolver }, | ||||||
|         groupService, |         groupService, | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
|  | |||||||
| @ -91,7 +91,7 @@ export const createFeatureToggleService = ( | |||||||
|     ); |     ); | ||||||
|     const accessService = new AccessService( |     const accessService = new AccessService( | ||||||
|         { accessStore, accountStore, roleStore, environmentStore }, |         { accessStore, accountStore, roleStore, environmentStore }, | ||||||
|         { getLogger }, |         { getLogger, flagResolver }, | ||||||
|         groupService, |         groupService, | ||||||
|     ); |     ); | ||||||
|     const segmentService = new SegmentService( |     const segmentService = new SegmentService( | ||||||
| @ -145,7 +145,7 @@ export const createFakeFeatureToggleService = ( | |||||||
|     ); |     ); | ||||||
|     const accessService = new AccessService( |     const accessService = new AccessService( | ||||||
|         { accessStore, accountStore, roleStore, environmentStore }, |         { accessStore, accountStore, roleStore, environmentStore }, | ||||||
|         { getLogger }, |         { getLogger, flagResolver }, | ||||||
|         groupService, |         groupService, | ||||||
|     ); |     ); | ||||||
|     const segmentService = new SegmentService( |     const segmentService = new SegmentService( | ||||||
|  | |||||||
| @ -523,7 +523,6 @@ export default class UserAdminController extends Controller { | |||||||
|         req: Request, |         req: Request, | ||||||
|         res: Response<AdminCountSchema>, |         res: Response<AdminCountSchema>, | ||||||
|     ): Promise<void> { |     ): Promise<void> { | ||||||
|         console.log('user-admin controller'); |  | ||||||
|         const adminCount = await this.accountService.getAdminCount(); |         const adminCount = await this.accountService.getAdminCount(); | ||||||
| 
 | 
 | ||||||
|         this.openApiService.respondWithValidation( |         this.openApiService.respondWithValidation( | ||||||
|  | |||||||
| @ -3,10 +3,15 @@ import getLogger from '../../test/fixtures/no-logger'; | |||||||
| import createStores from '../../test/fixtures/store'; | import createStores from '../../test/fixtures/store'; | ||||||
| import { AccessService, IRoleValidation } from './access-service'; | import { AccessService, IRoleValidation } from './access-service'; | ||||||
| import { GroupService } from './group-service'; | import { GroupService } from './group-service'; | ||||||
|  | import { createTestConfig } from '../../test/config/test-config'; | ||||||
| 
 | 
 | ||||||
| function getSetup(withNameInUse: boolean) { | function getSetup(withNameInUse: boolean) { | ||||||
|     const stores = createStores(); |     const stores = createStores(); | ||||||
| 
 | 
 | ||||||
|  |     const config = createTestConfig({ | ||||||
|  |         getLogger, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     stores.roleStore = { |     stores.roleStore = { | ||||||
|         ...stores.roleStore, |         ...stores.roleStore, | ||||||
|         async nameInUse(): Promise<boolean> { |         async nameInUse(): Promise<boolean> { | ||||||
| @ -14,13 +19,7 @@ function getSetup(withNameInUse: boolean) { | |||||||
|         }, |         }, | ||||||
|     }; |     }; | ||||||
|     return { |     return { | ||||||
|         accessService: new AccessService( |         accessService: new AccessService(stores, config, {} as GroupService), | ||||||
|             stores, |  | ||||||
|             { |  | ||||||
|                 getLogger, |  | ||||||
|             }, |  | ||||||
|             {} as GroupService, |  | ||||||
|         ), |  | ||||||
|         stores, |         stores, | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  | |||||||
| @ -25,12 +25,18 @@ import NameExistsError from '../error/name-exists-error'; | |||||||
| import { IEnvironmentStore } from 'lib/types/stores/environment-store'; | import { IEnvironmentStore } from 'lib/types/stores/environment-store'; | ||||||
| import RoleInUseError from '../error/role-in-use-error'; | import RoleInUseError from '../error/role-in-use-error'; | ||||||
| import { roleSchema } from '../schema/role-schema'; | 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 { DEFAULT_PROJECT } from '../types/project'; | ||||||
| import InvalidOperationError from '../error/invalid-operation-error'; | import InvalidOperationError from '../error/invalid-operation-error'; | ||||||
| import BadDataError from '../error/bad-data-error'; | import BadDataError from '../error/bad-data-error'; | ||||||
| import { IGroupModelWithProjectRole } from '../types/group'; | import { IGroupModelWithProjectRole } from '../types/group'; | ||||||
| import { GroupService } from './group-service'; | import { GroupService } from './group-service'; | ||||||
|  | import { IFlagResolver, IUnleashConfig } from 'lib/types'; | ||||||
| 
 | 
 | ||||||
| const { ADMIN } = permissions; | const { ADMIN } = permissions; | ||||||
| 
 | 
 | ||||||
| @ -45,6 +51,7 @@ const PROJECT_ADMIN = [ | |||||||
| interface IRoleCreation { | interface IRoleCreation { | ||||||
|     name: string; |     name: string; | ||||||
|     description: string; |     description: string; | ||||||
|  |     type?: 'root-custom' | 'custom'; | ||||||
|     permissions?: IPermission[]; |     permissions?: IPermission[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -58,6 +65,7 @@ interface IRoleUpdate { | |||||||
|     id: number; |     id: number; | ||||||
|     name: string; |     name: string; | ||||||
|     description: string; |     description: string; | ||||||
|  |     type?: 'root-custom' | 'custom'; | ||||||
|     permissions?: IPermission[]; |     permissions?: IPermission[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -76,6 +84,8 @@ export class AccessService { | |||||||
| 
 | 
 | ||||||
|     private logger: Logger; |     private logger: Logger; | ||||||
| 
 | 
 | ||||||
|  |     private flagResolver: IFlagResolver; | ||||||
|  | 
 | ||||||
|     constructor( |     constructor( | ||||||
|         { |         { | ||||||
|             accessStore, |             accessStore, | ||||||
| @ -86,7 +96,10 @@ export class AccessService { | |||||||
|             IUnleashStores, |             IUnleashStores, | ||||||
|             'accessStore' | 'accountStore' | 'roleStore' | 'environmentStore' |             'accessStore' | 'accountStore' | 'roleStore' | 'environmentStore' | ||||||
|         >, |         >, | ||||||
|         { getLogger }: { getLogger: Function }, |         { | ||||||
|  |             getLogger, | ||||||
|  |             flagResolver, | ||||||
|  |         }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>, | ||||||
|         groupService: GroupService, |         groupService: GroupService, | ||||||
|     ) { |     ) { | ||||||
|         this.store = accessStore; |         this.store = accessStore; | ||||||
| @ -95,6 +108,7 @@ export class AccessService { | |||||||
|         this.groupService = groupService; |         this.groupService = groupService; | ||||||
|         this.environmentStore = environmentStore; |         this.environmentStore = environmentStore; | ||||||
|         this.logger = getLogger('/services/access-service.ts'); |         this.logger = getLogger('/services/access-service.ts'); | ||||||
|  |         this.flagResolver = flagResolver; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
| @ -158,6 +172,10 @@ export class AccessService { | |||||||
|         const bindablePermissions = await this.store.getAvailablePermissions(); |         const bindablePermissions = await this.store.getAvailablePermissions(); | ||||||
|         const environments = await this.environmentStore.getAll(); |         const environments = await this.environmentStore.getAll(); | ||||||
| 
 | 
 | ||||||
|  |         const rootPermissions = bindablePermissions.filter( | ||||||
|  |             ({ type }) => type === 'root', | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|         const projectPermissions = bindablePermissions.filter((x) => { |         const projectPermissions = bindablePermissions.filter((x) => { | ||||||
|             return x.type === 'project'; |             return x.type === 'project'; | ||||||
|         }); |         }); | ||||||
| @ -176,6 +194,7 @@ export class AccessService { | |||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|         return { |         return { | ||||||
|  |             root: rootPermissions, | ||||||
|             project: projectPermissions, |             project: projectPermissions, | ||||||
|             environments: allEnvironmentPermissions, |             environments: allEnvironmentPermissions, | ||||||
|         }; |         }; | ||||||
| @ -225,10 +244,10 @@ export class AccessService { | |||||||
|         const newRootRole = await this.resolveRootRole(role); |         const newRootRole = await this.resolveRootRole(role); | ||||||
|         if (newRootRole) { |         if (newRootRole) { | ||||||
|             try { |             try { | ||||||
|                 await this.store.removeRolesOfTypeForUser( |                 await this.store.removeRolesOfTypeForUser(userId, [ | ||||||
|                     userId, |  | ||||||
|                     RoleType.ROOT, |                     RoleType.ROOT, | ||||||
|                 ); |                     RoleType.ROOT_CUSTOM, | ||||||
|  |                 ]); | ||||||
| 
 | 
 | ||||||
|                 await this.store.addUserToRole( |                 await this.store.addUserToRole( | ||||||
|                     userId, |                     userId, | ||||||
| @ -467,38 +486,81 @@ export class AccessService { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async createRole(role: IRoleCreation): Promise<ICustomRole> { |     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 = { |         const baseRole = { | ||||||
|             ...(await this.validateRole(role)), |             ...(await this.validateRole(role)), | ||||||
|             roleType: CUSTOM_ROLE_TYPE, |             roleType, | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         const rolePermissions = role.permissions; |         const rolePermissions = role.permissions; | ||||||
|         const newRole = await this.roleStore.create(baseRole); |         const newRole = await this.roleStore.create(baseRole); | ||||||
|         if (rolePermissions) { |         if (rolePermissions) { | ||||||
|             await this.store.addEnvironmentPermissionsToRole( |             if (roleType === CUSTOM_ROOT_ROLE_TYPE) { | ||||||
|                 newRole.id, |                 await this.store.addPermissionsToRole( | ||||||
|                 rolePermissions, |                     newRole.id, | ||||||
|             ); |                     rolePermissions.map(({ name }) => name), | ||||||
|  |                 ); | ||||||
|  |             } else { | ||||||
|  |                 await this.store.addEnvironmentPermissionsToRole( | ||||||
|  |                     newRole.id, | ||||||
|  |                     rolePermissions, | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|         return newRole; |         return newRole; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async updateRole(role: IRoleUpdate): Promise<ICustomRole> { |     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); |         await this.validateRole(role, role.id); | ||||||
|         const baseRole = { |         const baseRole = { | ||||||
|             id: role.id, |             id: role.id, | ||||||
|             name: role.name, |             name: role.name, | ||||||
|             description: role.description, |             description: role.description, | ||||||
|             roleType: CUSTOM_ROLE_TYPE, |             roleType, | ||||||
|         }; |         }; | ||||||
|         const rolePermissions = role.permissions; |         const rolePermissions = role.permissions; | ||||||
|         const newRole = await this.roleStore.update(baseRole); |         const newRole = await this.roleStore.update(baseRole); | ||||||
|         if (rolePermissions) { |         if (rolePermissions) { | ||||||
|             await this.store.wipePermissionsFromRole(newRole.id); |             await this.store.wipePermissionsFromRole(newRole.id); | ||||||
|             await this.store.addEnvironmentPermissionsToRole( |             if (roleType === CUSTOM_ROOT_ROLE_TYPE) { | ||||||
|                 newRole.id, |                 await this.store.addPermissionsToRole( | ||||||
|                 rolePermissions, |                     newRole.id, | ||||||
|             ); |                     rolePermissions.map(({ name }) => name), | ||||||
|  |                 ); | ||||||
|  |             } else { | ||||||
|  |                 await this.store.addEnvironmentPermissionsToRole( | ||||||
|  |                     newRole.id, | ||||||
|  |                     rolePermissions, | ||||||
|  |                 ); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|         return newRole; |         return newRole; | ||||||
|     } |     } | ||||||
| @ -532,7 +594,10 @@ export class AccessService { | |||||||
| 
 | 
 | ||||||
|     async validateRoleIsNotBuiltIn(roleId: number): Promise<void> { |     async validateRoleIsNotBuiltIn(roleId: number): Promise<void> { | ||||||
|         const role = await this.store.get(roleId); |         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( |             throw new InvalidOperationError( | ||||||
|                 'You cannot change built in roles.', |                 'You cannot change built in roles.', | ||||||
|             ); |             ); | ||||||
|  | |||||||
| @ -25,7 +25,8 @@ export type IFlagKey = | |||||||
|     | 'experimentalExtendedTelemetry' |     | 'experimentalExtendedTelemetry' | ||||||
|     | 'segmentContextFieldUsage' |     | 'segmentContextFieldUsage' | ||||||
|     | 'disableNotifications' |     | 'disableNotifications' | ||||||
|     | 'advancedPlayground'; |     | 'advancedPlayground' | ||||||
|  |     | 'customRootRoles'; | ||||||
| 
 | 
 | ||||||
| export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; | export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; | ||||||
| 
 | 
 | ||||||
| @ -118,6 +119,10 @@ const flags: IFlags = { | |||||||
|         process.env.ADVANCED_PLAYGROUND, |         process.env.ADVANCED_PLAYGROUND, | ||||||
|         false, |         false, | ||||||
|     ), |     ), | ||||||
|  |     customRootRoles: parseEnvVarBoolean( | ||||||
|  |         process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES, | ||||||
|  |         false, | ||||||
|  |     ), | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const defaultExperimentalOptions: IExperimentalOptions = { | export const defaultExperimentalOptions: IExperimentalOptions = { | ||||||
|  | |||||||
| @ -272,6 +272,7 @@ export interface IRoleData { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export interface IAvailablePermissions { | export interface IAvailablePermissions { | ||||||
|  |     root: IPermission[]; | ||||||
|     project: IPermission[]; |     project: IPermission[]; | ||||||
|     environments: IEnvironmentPermission[]; |     environments: IEnvironmentPermission[]; | ||||||
| } | } | ||||||
| @ -305,6 +306,7 @@ export enum RoleName { | |||||||
| 
 | 
 | ||||||
| export enum RoleType { | export enum RoleType { | ||||||
|     ROOT = 'root', |     ROOT = 'root', | ||||||
|  |     ROOT_CUSTOM = 'root-custom', | ||||||
|     PROJECT = 'project', |     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 READ_PROJECT_API_TOKEN = 'READ_PROJECT_API_TOKEN'; | ||||||
| export const CREATE_PROJECT_API_TOKEN = 'CREATE_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 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, |         projectId: string, | ||||||
|     ): Promise<void>; |     ): Promise<void>; | ||||||
| 
 | 
 | ||||||
|     removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void>; |     removeRolesOfTypeForUser( | ||||||
|  |         userId: number, | ||||||
|  |         roleTypes: string[], | ||||||
|  |     ): Promise<void>; | ||||||
| 
 | 
 | ||||||
|     addPermissionsToRole( |     addPermissionsToRole( | ||||||
|         role_id: number, |         role_id: number, | ||||||
|  | |||||||
| @ -7,7 +7,8 @@ export const ROOT_PERMISSION_TYPE = 'root'; | |||||||
| export const ENVIRONMENT_PERMISSION_TYPE = 'environment'; | export const ENVIRONMENT_PERMISSION_TYPE = 'environment'; | ||||||
| export const PROJECT_PERMISSION_TYPE = 'project'; | export const PROJECT_PERMISSION_TYPE = 'project'; | ||||||
| 
 | 
 | ||||||
| export const CUSTOM_ROLE_TYPE = 'custom'; | export const CUSTOM_ROOT_ROLE_TYPE = 'root-custom'; | ||||||
|  | export const CUSTOM_PROJECT_ROLE_TYPE = 'custom'; | ||||||
| 
 | 
 | ||||||
| /* CONTEXT FIELD OPERATORS */ | /* CONTEXT FIELD OPERATORS */ | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -219,7 +219,7 @@ beforeAll(async () => { | |||||||
|         experimental: { environments: { enabled: true } }, |         experimental: { environments: { enabled: true } }, | ||||||
|     }); |     }); | ||||||
|     groupService = new GroupService(stores, { getLogger }); |     groupService = new GroupService(stores, { getLogger }); | ||||||
|     accessService = new AccessService(stores, { getLogger }, groupService); |     accessService = new AccessService(stores, config, groupService); | ||||||
|     const roles = await accessService.getRootRoles(); |     const roles = await accessService.getRootRoles(); | ||||||
|     editorRole = roles.find((r) => r.name === RoleName.EDITOR); |     editorRole = roles.find((r) => r.name === RoleName.EDITOR); | ||||||
|     adminRole = roles.find((r) => r.name === RoleName.ADMIN); |     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, |                 roleStore: undefined, | ||||||
|                 environmentStore: undefined, |                 environmentStore: undefined, | ||||||
|             }, |             }, | ||||||
|             { getLogger: noLoggerProvider }, |             { getLogger: noLoggerProvider, flagResolver: undefined }, | ||||||
|             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([]); |         return Promise.resolve([]); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     removeRolesOfTypeForUser(userId: number, roleType: string): Promise<void> { |     removeRolesOfTypeForUser( | ||||||
|  |         userId: number, | ||||||
|  |         roleTypes: string[], | ||||||
|  |     ): Promise<void> { | ||||||
|         return Promise.resolve(undefined); |         return Promise.resolve(undefined); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user