mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	fix: project access (#621)
* feat: update useProjectApi hook * fix: refactor to hooks * fix: remove some ts errors * fix: set message if error exists directly on response * fix: remove console logs * fix: typo * delete: context2 * feat: filter added user from user add list * fix: cleanup PR based on feedback * fix: handle undefined roles in ProjectRoleSelect * fix: use target value * fix: type event * fix: conflict * fix: add appropriate types * fix conflicts * fix: explicit query * fix: refactor list * refactor: permission icon button * fix: conflict * fix: ts errors * refactor: break list into its own component * fix: use stringifed deps * fix: explicit export * fix: update pr according to comments Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
		
							parent
							
								
									9c2ac3e55b
								
							
						
					
					
						commit
						08c4b60cef
					
				| @ -15,6 +15,7 @@ interface IPaginateUIProps { | ||||
|     prevPage: () => void; | ||||
|     setPageIndex: (idx: number) => void; | ||||
|     nextPage: () => void; | ||||
|     style?: React.CSSProperties; | ||||
| } | ||||
| 
 | ||||
| const PaginateUI = ({ | ||||
|  | ||||
| @ -3,13 +3,19 @@ import { useContext } from 'react'; | ||||
| import AccessContext from '../../../contexts/AccessContext'; | ||||
| 
 | ||||
| interface IPermissionIconButtonProps | ||||
|     extends React.HTMLProps<HTMLButtonElement> { | ||||
|     extends React.DetailedHTMLProps< | ||||
|         React.HTMLAttributes<HTMLButtonElement>, | ||||
|         HTMLButtonElement | ||||
|     > { | ||||
|     permission: string; | ||||
|     Icon?: React.ElementType; | ||||
|     tooltip: string; | ||||
|     onClick?: (e: any) => void; | ||||
|     projectId?: string; | ||||
|     environmentId?: string; | ||||
|     edge?: string; | ||||
|     className?: string; | ||||
|     title?: string; | ||||
| } | ||||
| 
 | ||||
| const PermissionIconButton: React.FC<IPermissionIconButtonProps> = ({ | ||||
|  | ||||
| @ -12,7 +12,7 @@ import useQueryParams from '../../../hooks/useQueryParams'; | ||||
| import { useEffect } from 'react'; | ||||
| import useTabs from '../../../hooks/useTabs'; | ||||
| import TabPanel from '../../common/TabNav/TabPanel'; | ||||
| import ProjectAccess from '../access-container'; | ||||
| import { ProjectAccess } from '../ProjectAccess/ProjectAccess'; | ||||
| import ProjectEnvironment from '../ProjectEnvironment/ProjectEnvironment'; | ||||
| import ProjectOverview from './ProjectOverview'; | ||||
| import ProjectHealth from './ProjectHealth/ProjectHealth'; | ||||
|  | ||||
| @ -11,18 +11,11 @@ export const useStyles = makeStyles(theme => ({ | ||||
|         backgroundColor: '#efefef', | ||||
|         marginTop: '2rem', | ||||
|     }, | ||||
|     actionList: { | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
|     inputLabel: { backgroundColor: '#fff' }, | ||||
|     roleName: { | ||||
|         fontWeight: 'bold', | ||||
|         padding: '5px 0px', | ||||
|     }, | ||||
|     iconButton: { | ||||
|         marginLeft: '0.5rem', | ||||
|     }, | ||||
|     menuItem: { | ||||
|         width: '340px', | ||||
|         whiteSpace: 'normal', | ||||
|  | ||||
| @ -1,71 +1,40 @@ | ||||
| /* eslint-disable react/jsx-no-target-blank */ | ||||
| import { useEffect, useState } from 'react'; | ||||
| import { | ||||
|     Avatar, | ||||
|     Button, | ||||
|     Dialog, | ||||
|     DialogActions, | ||||
|     DialogContent, | ||||
|     DialogContentText, | ||||
|     DialogTitle, | ||||
|     List, | ||||
|     ListItem, | ||||
|     ListItemAvatar, | ||||
|     ListItemSecondaryAction, | ||||
|     ListItemText, | ||||
|     MenuItem, | ||||
| } from '@material-ui/core'; | ||||
| import { Delete } from '@material-ui/icons'; | ||||
| import React, { useState } from 'react'; | ||||
| import { Alert } from '@material-ui/lab'; | ||||
| 
 | ||||
| import AddUserComponent from '../access-add-user'; | ||||
| import { ProjectAccessAddUser } from './ProjectAccessAddUser/ProjectAccessAddUser'; | ||||
| 
 | ||||
| import projectApi from '../../../store/project/api'; | ||||
| import PageContent from '../../common/PageContent'; | ||||
| import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { useStyles } from './ProjectAccess.styles'; | ||||
| import PermissionIconButton from '../../common/PermissionIconButton/PermissionIconButton'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import { IFeatureViewParams } from '../../../interfaces/params'; | ||||
| import ProjectRoleSelect from './ProjectRoleSelect/ProjectRoleSelect'; | ||||
| import { IProjectViewParams } from '../../../interfaces/params'; | ||||
| import usePagination from '../../../hooks/usePagination'; | ||||
| import PaginateUI from '../../common/PaginateUI/PaginateUI'; | ||||
| import useToast from '../../../hooks/useToast'; | ||||
| import ConfirmDialogue from '../../common/Dialogue'; | ||||
| import useProjectAccess, { | ||||
|     IProjectAccessUser, | ||||
| } from '../../../hooks/api/getters/useProjectAccess/useProjectAccess'; | ||||
| import useProjectApi from '../../../hooks/api/actions/useProjectApi/useProjectApi'; | ||||
| import HeaderTitle from '../../common/HeaderTitle'; | ||||
| import { ProjectAccessList } from './ProjectAccessList/ProjectAccessList'; | ||||
| 
 | ||||
| const ProjectAccess = () => { | ||||
|     const { id } = useParams<IFeatureViewParams>(); | ||||
| export const ProjectAccess = () => { | ||||
|     const { id: projectId } = useParams<IProjectViewParams>(); | ||||
|     const styles = useStyles(); | ||||
|     const [roles, setRoles] = useState([]); | ||||
|     const [users, setUsers] = useState([]); | ||||
|     const [error, setError] = useState(); | ||||
|     const { setToastData, setToastApiError } = useToast(); | ||||
|     const { access, refetchProjectAccess } = useProjectAccess(projectId); | ||||
|     const { setToastData } = useToast(); | ||||
|     const { isOss } = useUiConfig(); | ||||
|     const { page, pages, nextPage, prevPage, setPageIndex, pageIndex } = | ||||
|         usePagination(users, 10); | ||||
|         usePagination(access.users, 10); | ||||
|     const { removeUserFromRole, addUserToRole } = useProjectApi(); | ||||
|     const [showDelDialogue, setShowDelDialogue] = useState(false); | ||||
|     const [user, setUser] = useState({}); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         fetchAccess(); | ||||
|         // eslint-disable-next-line react-hooks/exhaustive-deps
 | ||||
|     }, [id]); | ||||
| 
 | ||||
|     const fetchAccess = async () => { | ||||
|         try { | ||||
|             const access = await projectApi.fetchAccess(id); | ||||
|             setRoles(access.roles); | ||||
|             setUsers( | ||||
|                 access.users.map(u => ({ ...u, name: u.name || '(No name)' })) | ||||
|             ); | ||||
|         } catch (e) { | ||||
|             setToastApiError(e.toString()); | ||||
|         } | ||||
|     }; | ||||
|     const [user, setUser] = useState<IProjectAccessUser | undefined>(); | ||||
| 
 | ||||
|     if (isOss()) { | ||||
|         return ( | ||||
|             <PageContent> | ||||
|             <PageContent headerContent={<HeaderTitle title="Project Access" />}> | ||||
|                 <Alert severity="error"> | ||||
|                     Controlling access to projects requires a paid version of | ||||
|                     Unleash. Check out{' '} | ||||
| @ -78,58 +47,49 @@ const ProjectAccess = () => { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     const handleRoleChange = (userId, currRoleId) => async evt => { | ||||
|         const roleId = evt.target.value; | ||||
|         try { | ||||
|             await projectApi.removeUserFromRole(id, currRoleId, userId); | ||||
|             await projectApi.addUserToRole(id, roleId, userId).then(() => { | ||||
|     const handleRoleChange = | ||||
|         (userId: number, currRoleId: number) => | ||||
|         async ( | ||||
|             evt: React.ChangeEvent<{ | ||||
|                 name?: string; | ||||
|                 value: unknown; | ||||
|             }> | ||||
|         ) => { | ||||
|             const roleId = Number(evt.target.value); | ||||
|             try { | ||||
|                 await removeUserFromRole(projectId, currRoleId, userId); | ||||
|                 await addUserToRole(projectId, roleId, userId); | ||||
|                 refetchProjectAccess(); | ||||
| 
 | ||||
|                 setToastData({ | ||||
|                     type: 'success', | ||||
|                     title: 'User role changed successfully', | ||||
|                 }); | ||||
|             }); | ||||
|             const newUsers = users.map(u => { | ||||
|                 if (u.id === userId) { | ||||
|                     return { ...u, roleId }; | ||||
|                 } else return u; | ||||
|             }); | ||||
|             setUsers(newUsers); | ||||
|         } catch (err) { | ||||
|             setToastData({ | ||||
|                 type: 'error', | ||||
|                 title: err.message || 'Server problems when adding users.', | ||||
|             }); | ||||
|         } | ||||
|             } catch (err: any) { | ||||
|                 setToastData({ | ||||
|                     type: 'error', | ||||
|                     title: err.message || 'Server problems when adding users.', | ||||
|                 }); | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|     const handleRemoveAccess = (user: IProjectAccessUser) => { | ||||
|         setUser(user); | ||||
|         setShowDelDialogue(true); | ||||
|     }; | ||||
| 
 | ||||
|     const addUser = async (userId, roleId) => { | ||||
|         try { | ||||
|             await projectApi.addUserToRole(id, roleId, userId); | ||||
|             await fetchAccess().then(() => { | ||||
|                 setToastData({ | ||||
|                     type: 'success', | ||||
|                     title: 'Successfully added user to the project', | ||||
|                 }); | ||||
|             }); | ||||
|         } catch (err) { | ||||
|             setToastData({ | ||||
|                 type: 'error', | ||||
|                 title: err.message || 'Server problems when adding users.', | ||||
|             }); | ||||
|         } | ||||
|     }; | ||||
|     const removeAccess = (user: IProjectAccessUser | undefined) => async () => { | ||||
|         if (!user) return; | ||||
|         const { id, roleId } = user; | ||||
| 
 | ||||
|     const removeAccess = (userId: number, roleId: number) => async () => { | ||||
|         try { | ||||
|             await projectApi.removeUserFromRole(id, roleId, userId).then(() => { | ||||
|                 setToastData({ | ||||
|                     type: 'success', | ||||
|                     title: 'User have been removed from project', | ||||
|                 }); | ||||
|             await removeUserFromRole(projectId, roleId, id); | ||||
|             refetchProjectAccess(); | ||||
|             setToastData({ | ||||
|                 type: 'success', | ||||
|                 title: 'The user has been removed from project', | ||||
|             }); | ||||
|             const newUsers = users.filter(u => u.id !== userId); | ||||
|             setUsers(newUsers); | ||||
|         } catch (err) { | ||||
|         } catch (err: any) { | ||||
|             setToastData({ | ||||
|                 type: 'error', | ||||
|                 title: err.message || 'Server problems when adding users.', | ||||
| @ -138,91 +98,20 @@ const ProjectAccess = () => { | ||||
|         setShowDelDialogue(false); | ||||
|     }; | ||||
| 
 | ||||
|     const handleCloseError = () => { | ||||
|         setError(undefined); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <PageContent className={styles.pageContent}> | ||||
|             <AddUserComponent roles={roles} addUserToRole={addUser} /> | ||||
|             <Dialog | ||||
|                 open={!!error} | ||||
|                 onClose={handleCloseError} | ||||
|                 aria-labelledby="alert-dialog-title" | ||||
|                 aria-describedby="alert-dialog-description" | ||||
|             > | ||||
|                 <DialogTitle id="alert-dialog-title">{'Error'}</DialogTitle> | ||||
|                 <DialogContent> | ||||
|                     <DialogContentText id="alert-dialog-description"> | ||||
|                         {error} | ||||
|                     </DialogContentText> | ||||
|                 </DialogContent> | ||||
|                 <DialogActions> | ||||
|                     <Button | ||||
|                         onClick={handleCloseError} | ||||
|                         color="secondary" | ||||
|                         autoFocus | ||||
|                     > | ||||
|                         Close | ||||
|                     </Button> | ||||
|                 </DialogActions> | ||||
|             </Dialog> | ||||
|             <div className={styles.divider}></div> | ||||
|             <List> | ||||
|                 {page.map(user => { | ||||
|                     const labelId = `checkbox-list-secondary-label-${user.id}`; | ||||
|                     return ( | ||||
|                         <ListItem key={user.id} button> | ||||
|                             <ListItemAvatar> | ||||
|                                 <Avatar alt={user.name} src={user.imageUrl} /> | ||||
|                             </ListItemAvatar> | ||||
|                             <ListItemText | ||||
|                                 id={labelId} | ||||
|                                 primary={user.name} | ||||
|                                 secondary={user.email || user.username} | ||||
|                             /> | ||||
|                             <ListItemSecondaryAction | ||||
|                                 className={styles.actionList} | ||||
|                             > | ||||
|                                 <ProjectRoleSelect | ||||
|                                     labelId={`role-${user.id}-select-label`} | ||||
|                                     id={`role-${user.id}-select`} | ||||
|                                     key={user.id} | ||||
|                                     placeholder="Choose role" | ||||
|                                     onChange={handleRoleChange( | ||||
|                                         user.id, | ||||
|                                         user.roleId | ||||
|                                     )} | ||||
|                                     roles={roles} | ||||
|                                     value={user.roleId || ''} | ||||
|                                 > | ||||
|                                     <MenuItem value="" disabled> | ||||
|                                         Choose role | ||||
|                                     </MenuItem> | ||||
|                                 </ProjectRoleSelect> | ||||
|         <PageContent | ||||
|             headerContent={<HeaderTitle title="Project Roles"></HeaderTitle>} | ||||
|             className={styles.pageContent} | ||||
|         > | ||||
|             <ProjectAccessAddUser roles={access?.roles} /> | ||||
| 
 | ||||
|                                 <PermissionIconButton | ||||
|                                     className={styles.iconButton} | ||||
|                                     edge="end" | ||||
|                                     aria-label="delete" | ||||
|                                     title="Remove access" | ||||
|                                     onClick={() => { | ||||
|                                         setUser(user); | ||||
|                                         setShowDelDialogue(true); | ||||
|                                     }} | ||||
|                                     disabled={users.length === 1} | ||||
|                                     tooltip={ | ||||
|                                         users.length === 1 | ||||
|                                             ? 'A project must have at least one owner' | ||||
|                                             : 'Remove access' | ||||
|                                     } | ||||
|                                 > | ||||
|                                     <Delete /> | ||||
|                                 </PermissionIconButton> | ||||
|                             </ListItemSecondaryAction> | ||||
|                         </ListItem> | ||||
|                     ); | ||||
|                 })} | ||||
|             <div className={styles.divider}></div> | ||||
|             <ProjectAccessList | ||||
|                 handleRoleChange={handleRoleChange} | ||||
|                 handleRemoveAccess={handleRemoveAccess} | ||||
|                 page={page} | ||||
|                 access={access} | ||||
|             > | ||||
|                 <PaginateUI | ||||
|                     pages={pages} | ||||
|                     pageIndex={pageIndex} | ||||
| @ -231,12 +120,13 @@ const ProjectAccess = () => { | ||||
|                     prevPage={prevPage} | ||||
|                     style={{ bottom: '-21px' }} | ||||
|                 /> | ||||
|             </List> | ||||
|             </ProjectAccessList> | ||||
| 
 | ||||
|             <ConfirmDialogue | ||||
|                 open={showDelDialogue} | ||||
|                 onClick={removeAccess(user.id, user.roleId)} | ||||
|                 onClick={removeAccess(user)} | ||||
|                 onClose={() => { | ||||
|                     setUser({}); | ||||
|                     setUser(undefined); | ||||
|                     setShowDelDialogue(false); | ||||
|                 }} | ||||
|                 title="Really remove user from this project" | ||||
| @ -244,5 +134,3 @@ const ProjectAccess = () => { | ||||
|         </PageContent> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default ProjectAccess; | ||||
|  | ||||
| @ -0,0 +1,236 @@ | ||||
| import React, { ChangeEvent, useEffect, useState } from 'react'; | ||||
| import { | ||||
|     TextField, | ||||
|     CircularProgress, | ||||
|     Grid, | ||||
|     Button, | ||||
|     InputAdornment, | ||||
| } from '@material-ui/core'; | ||||
| import { Search } from '@material-ui/icons'; | ||||
| import Autocomplete from '@material-ui/lab/Autocomplete'; | ||||
| import { Alert } from '@material-ui/lab'; | ||||
| import { ProjectRoleSelect } from '../ProjectRoleSelect/ProjectRoleSelect'; | ||||
| import useProjectApi from '../../../../hooks/api/actions/useProjectApi/useProjectApi'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import useToast from '../../../../hooks/useToast'; | ||||
| import useProjectAccess, { | ||||
|     IProjectAccessUser, | ||||
| } from '../../../../hooks/api/getters/useProjectAccess/useProjectAccess'; | ||||
| import { IProjectRole } from '../../../../interfaces/role'; | ||||
| import ConditionallyRender from '../../../common/ConditionallyRender'; | ||||
| 
 | ||||
| interface IProjectAccessAddUserProps { | ||||
|     roles: IProjectRole[]; | ||||
| } | ||||
| 
 | ||||
| export const ProjectAccessAddUser = ({ roles }: IProjectAccessAddUserProps) => { | ||||
|     const { id } = useParams<{ id: string }>(); | ||||
|     const [user, setUser] = useState<IProjectAccessUser | undefined>(); | ||||
|     const [role, setRole] = useState<IProjectRole | undefined>(); | ||||
|     const [options, setOptions] = useState([]); | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const { setToastData } = useToast(); | ||||
|     const { refetchProjectAccess, access } = useProjectAccess(id); | ||||
| 
 | ||||
|     const { searchProjectUser, addUserToRole } = useProjectApi(); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (roles.length > 0) { | ||||
|             const regularRole = roles.find( | ||||
|                 r => r.name.toLowerCase() === 'regular' | ||||
|             ); | ||||
|             setRole(regularRole || roles[0]); | ||||
|         } | ||||
|     }, [roles]); | ||||
| 
 | ||||
|     const search = async (query: string) => { | ||||
|         if (query.length > 1) { | ||||
|             setLoading(true); | ||||
| 
 | ||||
|             const result = await searchProjectUser(query); | ||||
|             const userSearchResults = await result.json(); | ||||
| 
 | ||||
|             const filteredUsers = userSearchResults.filter( | ||||
|                 (selectedUser: IProjectAccessUser) => { | ||||
|                     const selected = access.users.find( | ||||
|                         (user: IProjectAccessUser) => | ||||
|                             user.id === selectedUser.id | ||||
|                     ); | ||||
|                     return !selected; | ||||
|                 } | ||||
|             ); | ||||
|             setOptions(filteredUsers); | ||||
|         } else { | ||||
|             setOptions([]); | ||||
|         } | ||||
|         setLoading(false); | ||||
|     }; | ||||
| 
 | ||||
|     const handleQueryUpdate = (evt: { target: { value: string } }) => { | ||||
|         const q = evt.target.value; | ||||
|         search(q); | ||||
|     }; | ||||
| 
 | ||||
|     const handleBlur = () => { | ||||
|         if (options.length > 0) { | ||||
|             const user = options[0]; | ||||
|             setUser(user); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const handleSelectUser = ( | ||||
|         evt: ChangeEvent<{}>, | ||||
|         selectedUser: string | IProjectAccessUser | null | ||||
|     ) => { | ||||
|         setOptions([]); | ||||
| 
 | ||||
|         if (typeof selectedUser === 'string' || selectedUser === null) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (selectedUser?.id) { | ||||
|             setUser(selectedUser); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const handleRoleChange = ( | ||||
|         evt: React.ChangeEvent<{ | ||||
|             name?: string | undefined; | ||||
|             value: unknown; | ||||
|         }> | ||||
|     ) => { | ||||
|         const roleId = Number(evt.target.value); | ||||
|         const role = roles.find(role => role.id === roleId); | ||||
|         if (role) { | ||||
|             setRole(role); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const handleSubmit = async (evt: React.SyntheticEvent) => { | ||||
|         evt.preventDefault(); | ||||
|         if (!role || !user) { | ||||
|             setToastData({ | ||||
|                 type: 'error', | ||||
|                 title: 'Invalid selection', | ||||
|                 text: `The selected user or role does not exist`, | ||||
|             }); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             await addUserToRole(id, role.id, user.id); | ||||
|             refetchProjectAccess(); | ||||
|             setUser(undefined); | ||||
|             setOptions([]); | ||||
|             setToastData({ | ||||
|                 type: 'success', | ||||
|                 title: 'Added user to project', | ||||
|                 text: `User added to the project with the role of ${role.name}`, | ||||
|             }); | ||||
|         } catch (e: any) { | ||||
|             let error; | ||||
| 
 | ||||
|             if ( | ||||
|                 e | ||||
|                     .toString() | ||||
|                     .includes(`User already has access to project=${id}`) | ||||
|             ) { | ||||
|                 error = `User already has access to project ${id}`; | ||||
|             } else { | ||||
|                 error = e.toString() || 'Server problems when adding users.'; | ||||
|             } | ||||
|             setToastData({ | ||||
|                 type: 'error', | ||||
|                 title: error, | ||||
|             }); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const getOptionLabel = (option: IProjectAccessUser) => { | ||||
|         if (option) { | ||||
|             return `${option.name || '(Empty name)'} <${ | ||||
|                 option.email || option.username | ||||
|             }>`;
 | ||||
|         } else return ''; | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <Alert severity="info" style={{ marginBottom: '20px' }}> | ||||
|                 The user must have an Unleash root role before added to the | ||||
|                 project. | ||||
|             </Alert> | ||||
|             <Grid container spacing={3} alignItems="flex-end"> | ||||
|                 <Grid item> | ||||
|                     <Autocomplete | ||||
|                         id="add-user-component" | ||||
|                         style={{ width: 300 }} | ||||
|                         noOptionsText="No users found." | ||||
|                         onChange={handleSelectUser} | ||||
|                         onBlur={() => handleBlur()} | ||||
|                         value={user || ''} | ||||
|                         freeSolo | ||||
|                         getOptionSelected={() => true} | ||||
|                         filterOptions={o => o} | ||||
|                         getOptionLabel={getOptionLabel} | ||||
|                         options={options} | ||||
|                         loading={loading} | ||||
|                         renderInput={params => ( | ||||
|                             <TextField | ||||
|                                 {...params} | ||||
|                                 label="User" | ||||
|                                 variant="outlined" | ||||
|                                 size="small" | ||||
|                                 name="search" | ||||
|                                 onChange={handleQueryUpdate} | ||||
|                                 InputProps={{ | ||||
|                                     ...params.InputProps, | ||||
|                                     startAdornment: ( | ||||
|                                         <InputAdornment position="start"> | ||||
|                                             <Search /> | ||||
|                                         </InputAdornment> | ||||
|                                     ), | ||||
|                                     endAdornment: ( | ||||
|                                         <> | ||||
|                                             <ConditionallyRender | ||||
|                                                 condition={loading} | ||||
|                                                 show={ | ||||
|                                                     <CircularProgress | ||||
|                                                         color="inherit" | ||||
|                                                         size={20} | ||||
|                                                     /> | ||||
|                                                 } | ||||
|                                             /> | ||||
| 
 | ||||
|                                             {params.InputProps.endAdornment} | ||||
|                                         </> | ||||
|                                     ), | ||||
|                                 }} | ||||
|                             /> | ||||
|                         )} | ||||
|                     /> | ||||
|                 </Grid> | ||||
|                 <Grid item> | ||||
|                     <ProjectRoleSelect | ||||
|                         labelId="add-user-select-role-label" | ||||
|                         id="add-user-select-role" | ||||
|                         placeholder="Project role" | ||||
|                         value={role?.id || -1} | ||||
|                         onChange={handleRoleChange} | ||||
|                         roles={roles} | ||||
|                     /> | ||||
|                 </Grid> | ||||
|                 <Grid item> | ||||
|                     <Button | ||||
|                         variant="contained" | ||||
|                         color="primary" | ||||
|                         disabled={!user} | ||||
|                         onClick={handleSubmit} | ||||
|                     > | ||||
|                         Add user | ||||
|                     </Button> | ||||
|                 </Grid> | ||||
|             </Grid> | ||||
|         </> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,64 @@ | ||||
| import { List } from '@material-ui/core'; | ||||
| import { | ||||
|     IProjectAccessOutput, | ||||
|     IProjectAccessUser, | ||||
| } from '../../../../hooks/api/getters/useProjectAccess/useProjectAccess'; | ||||
| import { ProjectAccessListItem } from './ProjectAccessListItem/ProjectAccessListItem'; | ||||
| 
 | ||||
| interface IProjectAccesListProps { | ||||
|     page: IProjectAccessUser[]; | ||||
|     handleRoleChange: ( | ||||
|         userId: number, | ||||
|         currRoleId: number | ||||
|     ) => ( | ||||
|         evt: React.ChangeEvent<{ | ||||
|             name?: string; | ||||
|             value: unknown; | ||||
|         }> | ||||
|     ) => void; | ||||
|     handleRemoveAccess: (user: IProjectAccessUser) => void; | ||||
|     access: IProjectAccessOutput; | ||||
| } | ||||
| 
 | ||||
| export const ProjectAccessList: React.FC<IProjectAccesListProps> = ({ | ||||
|     page, | ||||
|     access, | ||||
|     handleRoleChange, | ||||
|     handleRemoveAccess, | ||||
|     children, | ||||
| }) => { | ||||
|     const sortUsers = (users: IProjectAccessUser[]): IProjectAccessUser[] => { | ||||
|         /* This should be done on the API side in the future,  | ||||
|                 we should expect the list of users to come in the  | ||||
|                 same order each time and not jump around on the screen*/ | ||||
| 
 | ||||
|         return users.sort( | ||||
|             (userA: IProjectAccessUser, userB: IProjectAccessUser) => { | ||||
|                 if (!userA.name) { | ||||
|                     return -1; | ||||
|                 } else if (!userB.name) { | ||||
|                     return 1; | ||||
|                 } | ||||
| 
 | ||||
|                 return userA.name.localeCompare(userB.name); | ||||
|             } | ||||
|         ); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <List> | ||||
|             {sortUsers(page).map(user => { | ||||
|                 return ( | ||||
|                     <ProjectAccessListItem | ||||
|                         key={user.id} | ||||
|                         user={user} | ||||
|                         access={access} | ||||
|                         handleRoleChange={handleRoleChange} | ||||
|                         handleRemoveAccess={handleRemoveAccess} | ||||
|                     /> | ||||
|                 ); | ||||
|             })} | ||||
|             {children} | ||||
|         </List> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,11 @@ | ||||
| import { makeStyles } from '@material-ui/core/styles'; | ||||
| 
 | ||||
| export const useStyles = makeStyles(() => ({ | ||||
|     iconButton: { | ||||
|         marginLeft: '0.5rem', | ||||
|     }, | ||||
|     actionList: { | ||||
|         display: 'flex', | ||||
|         alignItems: 'center', | ||||
|     }, | ||||
| })); | ||||
| @ -0,0 +1,93 @@ | ||||
| import { | ||||
|     ListItem, | ||||
|     ListItemAvatar, | ||||
|     Avatar, | ||||
|     ListItemText, | ||||
|     ListItemSecondaryAction, | ||||
|     MenuItem, | ||||
| } from '@material-ui/core'; | ||||
| import { Delete } from '@material-ui/icons'; | ||||
| import { useParams } from 'react-router-dom'; | ||||
| import { | ||||
|     IProjectAccessUser, | ||||
|     IProjectAccessOutput, | ||||
| } from '../../../../../hooks/api/getters/useProjectAccess/useProjectAccess'; | ||||
| import { IProjectViewParams } from '../../../../../interfaces/params'; | ||||
| import PermissionIconButton from '../../../../common/PermissionIconButton/PermissionIconButton'; | ||||
| import { UPDATE_PROJECT } from '../../../../providers/AccessProvider/permissions'; | ||||
| import { ProjectRoleSelect } from '../../ProjectRoleSelect/ProjectRoleSelect'; | ||||
| import { useStyles } from '../ProjectAccessListItem/ProjectAccessListItem.styles'; | ||||
| 
 | ||||
| interface IProjectAccessListItemProps { | ||||
|     user: IProjectAccessUser; | ||||
|     handleRoleChange: ( | ||||
|         userId: number, | ||||
|         currRoleId: number | ||||
|     ) => ( | ||||
|         evt: React.ChangeEvent<{ | ||||
|             name?: string; | ||||
|             value: unknown; | ||||
|         }> | ||||
|     ) => void; | ||||
|     handleRemoveAccess: (user: IProjectAccessUser) => void; | ||||
|     access: IProjectAccessOutput; | ||||
| } | ||||
| 
 | ||||
| export const ProjectAccessListItem = ({ | ||||
|     user, | ||||
|     access, | ||||
|     handleRoleChange, | ||||
|     handleRemoveAccess, | ||||
| }: IProjectAccessListItemProps) => { | ||||
|     const { id: projectId } = useParams<IProjectViewParams>(); | ||||
|     const styles = useStyles(); | ||||
| 
 | ||||
|     const labelId = `checkbox-list-secondary-label-${user.id}`; | ||||
| 
 | ||||
|     return ( | ||||
|         <ListItem key={user.id} button> | ||||
|             <ListItemAvatar> | ||||
|                 <Avatar alt={user.name} src={user.imageUrl} /> | ||||
|             </ListItemAvatar> | ||||
|             <ListItemText | ||||
|                 id={labelId} | ||||
|                 primary={user.name} | ||||
|                 secondary={user.email || user.username} | ||||
|             /> | ||||
|             <ListItemSecondaryAction className={styles.actionList}> | ||||
|                 <ProjectRoleSelect | ||||
|                     labelId={`role-${user.id}-select-label`} | ||||
|                     id={`role-${user.id}-select`} | ||||
|                     key={user.id} | ||||
|                     placeholder="Choose role" | ||||
|                     onChange={handleRoleChange(user.id, user.roleId)} | ||||
|                     roles={access.roles} | ||||
|                     value={user.roleId || -1} | ||||
|                 > | ||||
|                     <MenuItem value="" disabled> | ||||
|                         Choose role | ||||
|                     </MenuItem> | ||||
|                 </ProjectRoleSelect> | ||||
|                 <PermissionIconButton | ||||
|                     permission={UPDATE_PROJECT} | ||||
|                     projectId={projectId} | ||||
|                     className={styles.iconButton} | ||||
|                     edge="end" | ||||
|                     aria-label="delete" | ||||
|                     title="Remove access" | ||||
|                     onClick={() => { | ||||
|                         handleRemoveAccess(user); | ||||
|                     }} | ||||
|                     disabled={access.users.length === 1} | ||||
|                     tooltip={ | ||||
|                         access.users.length === 1 | ||||
|                             ? 'A project must have at least one owner' | ||||
|                             : 'Remove access' | ||||
|                     } | ||||
|                 > | ||||
|                     <Delete /> | ||||
|                 </PermissionIconButton> | ||||
|             </ListItemSecondaryAction> | ||||
|         </ListItem> | ||||
|     ); | ||||
| }; | ||||
| @ -1,19 +1,24 @@ | ||||
| import { FormControl, InputLabel, Select, MenuItem } from '@material-ui/core'; | ||||
| import React from 'react'; | ||||
| import IRole from '../../../../interfaces/role'; | ||||
| import { IProjectRole } from '../../../../interfaces/role'; | ||||
| 
 | ||||
| import { useStyles } from '../ProjectAccess.styles'; | ||||
| 
 | ||||
| interface IProjectRoleSelect { | ||||
|     roles: IRole[]; | ||||
|     roles: IProjectRole[]; | ||||
|     labelId: string; | ||||
|     id: string; | ||||
|     placeholder?: string; | ||||
|     onChange: () => void; | ||||
|     onChange: ( | ||||
|         evt: React.ChangeEvent<{ | ||||
|             name?: string | undefined; | ||||
|             value: unknown; | ||||
|         }> | ||||
|     ) => void; | ||||
|     value: any; | ||||
| } | ||||
| 
 | ||||
| const ProjectRoleSelect: React.FC<IProjectRoleSelect> = ({ | ||||
| export const ProjectRoleSelect: React.FC<IProjectRoleSelect> = ({ | ||||
|     roles, | ||||
|     onChange, | ||||
|     labelId, | ||||
| @ -39,9 +44,10 @@ const ProjectRoleSelect: React.FC<IProjectRoleSelect> = ({ | ||||
|                 value={value || ''} | ||||
|                 onChange={onChange} | ||||
|                 renderValue={roleId => { | ||||
|                     return roles?.find(role => { | ||||
|                     const role = roles?.find(role => { | ||||
|                         return role.id === roleId; | ||||
|                     }).name; | ||||
|                     }); | ||||
|                     return role?.name || ''; | ||||
|                 }} | ||||
|             > | ||||
|                 {children} | ||||
| @ -66,5 +72,3 @@ const ProjectRoleSelect: React.FC<IProjectRoleSelect> = ({ | ||||
|         </FormControl> | ||||
|     ); | ||||
| }; | ||||
| 
 | ||||
| export default ProjectRoleSelect; | ||||
|  | ||||
| @ -1,162 +0,0 @@ | ||||
| import React, { useEffect, useState } from 'react'; | ||||
| import projectApi from '../../store/project/api'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { | ||||
|     TextField, | ||||
|     CircularProgress, | ||||
|     Grid, | ||||
|     Button, | ||||
|     InputAdornment, | ||||
| } from '@material-ui/core'; | ||||
| import { Search } from '@material-ui/icons'; | ||||
| import Autocomplete from '@material-ui/lab/Autocomplete'; | ||||
| import { Alert } from '@material-ui/lab'; | ||||
| import ProjectRoleSelect from './ProjectAccess/ProjectRoleSelect/ProjectRoleSelect'; | ||||
| 
 | ||||
| function AddUserComponent({ roles, addUserToRole }) { | ||||
|     const [user, setUser] = useState(); | ||||
|     const [role, setRole] = useState({}); | ||||
|     const [options, setOptions] = useState([]); | ||||
|     const [loading, setLoading] = useState(false); | ||||
|     const [select, setSelect] = useState(false); | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         if (roles.length > 0) { | ||||
|             const regularRole = roles.find( | ||||
|                 r => r.name.toLowerCase() === 'regular' | ||||
|             ); | ||||
|             setRole(regularRole || roles[0]); | ||||
|         } | ||||
|     }, [roles]); | ||||
| 
 | ||||
|     const search = async q => { | ||||
|         if (q.length > 1) { | ||||
|             setLoading(true); | ||||
|             // TODO: Do not hard-code fetch here.
 | ||||
|             const users = await projectApi.searchProjectUser(q); | ||||
|             setOptions([...users]); | ||||
|         } else { | ||||
|             setOptions([]); | ||||
|         } | ||||
|         setLoading(false); | ||||
|     }; | ||||
| 
 | ||||
|     const handleQueryUpdate = evt => { | ||||
|         const q = evt.target.value; | ||||
|         search(q); | ||||
|         if (options.length === 1) { | ||||
|             setSelect(true); | ||||
|             return; | ||||
|         } | ||||
|         setSelect(false); | ||||
|     }; | ||||
| 
 | ||||
|     const handleSelectUser = (evt, selectedUser) => { | ||||
|         setOptions([]); | ||||
|         if (selectedUser?.id) { | ||||
|             setUser(selectedUser); | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const handleRoleChange = evt => { | ||||
|         const roleId = +evt.target.value; | ||||
|         const role = roles.find(r => r.id === roleId); | ||||
|         setRole(role); | ||||
|     }; | ||||
| 
 | ||||
|     const handleSubmit = async evt => { | ||||
|         evt.preventDefault(); | ||||
|         await addUserToRole(user.id, role.id); | ||||
|         setUser(undefined); | ||||
|         setOptions([]); | ||||
|     }; | ||||
| 
 | ||||
|     return ( | ||||
|         <> | ||||
|             <Alert severity="info" style={{ marginBottom: '20px' }}> | ||||
|                 The user must have an Unleash root role before added to the | ||||
|                 project. | ||||
|             </Alert> | ||||
|             <Grid container spacing={3} alignItems="flex-end"> | ||||
|                 <Grid item> | ||||
|                     <Autocomplete | ||||
|                         id="add-user-component" | ||||
|                         style={{ width: 300 }} | ||||
|                         noOptionsText="No users found." | ||||
|                         onChange={handleSelectUser} | ||||
|                         autoSelect={select} | ||||
|                         value={user || ''} | ||||
|                         freeSolo | ||||
|                         getOptionSelected={() => true} | ||||
|                         filterOptions={o => o} | ||||
|                         getOptionLabel={option => { | ||||
|                             if (option) { | ||||
|                                 return `${option.name || '(Empty name)'} <${ | ||||
|                                     option.email || option.username | ||||
|                                 }>`;
 | ||||
|                             } else return ''; | ||||
|                         }} | ||||
|                         options={options} | ||||
|                         loading={loading} | ||||
|                         renderInput={params => ( | ||||
|                             <TextField | ||||
|                                 {...params} | ||||
|                                 label="User" | ||||
|                                 variant="outlined" | ||||
|                                 size="small" | ||||
|                                 name="search" | ||||
|                                 onChange={handleQueryUpdate} | ||||
|                                 InputProps={{ | ||||
|                                     ...params.InputProps, | ||||
|                                     startAdornment: ( | ||||
|                                         <InputAdornment position="start"> | ||||
|                                             <Search /> | ||||
|                                         </InputAdornment> | ||||
|                                     ), | ||||
|                                     endAdornment: ( | ||||
|                                         <React.Fragment> | ||||
|                                             {loading ? ( | ||||
|                                                 <CircularProgress | ||||
|                                                     color="inherit" | ||||
|                                                     size={20} | ||||
|                                                 /> | ||||
|                                             ) : null} | ||||
|                                             {params.InputProps.endAdornment} | ||||
|                                         </React.Fragment> | ||||
|                                     ), | ||||
|                                 }} | ||||
|                             /> | ||||
|                         )} | ||||
|                     /> | ||||
|                 </Grid> | ||||
|                 <Grid item> | ||||
|                     <ProjectRoleSelect | ||||
|                         labelId="add-user-select-role-label" | ||||
|                         id="add-user-select-role" | ||||
|                         placeholder="Project role" | ||||
|                         value={role.id || ''} | ||||
|                         onChange={handleRoleChange} | ||||
|                         roles={roles} | ||||
|                     /> | ||||
|                 </Grid> | ||||
|                 <Grid item> | ||||
|                     <Button | ||||
|                         variant="contained" | ||||
|                         color="primary" | ||||
|                         disabled={!user} | ||||
|                         onClick={handleSubmit} | ||||
|                     > | ||||
|                         Add user | ||||
|                     </Button> | ||||
|                 </Grid> | ||||
|             </Grid> | ||||
|         </> | ||||
|     ); | ||||
| } | ||||
| 
 | ||||
| AddUserComponent.propTypes = { | ||||
|     roles: PropTypes.array.isRequired, | ||||
|     addUserToRole: PropTypes.func.isRequired, | ||||
| }; | ||||
| 
 | ||||
| export default AddUserComponent; | ||||
| @ -1,20 +0,0 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import Component from './ProjectAccess/ProjectAccess'; | ||||
| 
 | ||||
| const mapStateToProps = (state, props) => { | ||||
|     const projectBase = { id: '', name: '', description: '' }; | ||||
|     const realProject = state.projects | ||||
|         .toJS() | ||||
|         .find(n => n.id === props.projectId); | ||||
|     const project = Object.assign(projectBase, realProject); | ||||
| 
 | ||||
|     return { | ||||
|         project, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| const mapDispatchToProps = () => ({}); | ||||
| 
 | ||||
| const AccessContainer = connect(mapStateToProps, mapDispatchToProps)(Component); | ||||
| 
 | ||||
| export default AccessContainer; | ||||
| @ -163,14 +163,19 @@ const useAPI = ({ | ||||
| 
 | ||||
|         if (res.status > 399) { | ||||
|             const response = await res.json(); | ||||
| 
 | ||||
|             if (response?.details?.length > 0) { | ||||
|             if (response?.details?.length > 0 && propagateErrors) { | ||||
|                 const error = response.details[0]; | ||||
|                 if (propagateErrors) { | ||||
|                     throw new Error(error.message); | ||||
|                     throw new Error(error.message || error.msg); | ||||
|                 } | ||||
|                 return error; | ||||
|             } | ||||
| 
 | ||||
|             if (response?.length > 0 && propagateErrors) { | ||||
|                 const error = response[0]; | ||||
|                 throw new Error(error.message || error.msg); | ||||
|             } | ||||
| 
 | ||||
|             if (propagateErrors) { | ||||
|                 throw new Error('Action could not be performed'); | ||||
|             } | ||||
|  | ||||
| @ -107,6 +107,54 @@ const useProjectApi = () => { | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const addUserToRole = async ( | ||||
|         projectId: string, | ||||
|         roleId: number, | ||||
|         userId: number | ||||
|     ) => { | ||||
|         const path = `api/admin/projects/${projectId}/users/${userId}/roles/${roleId}`; | ||||
|         const req = createRequest(path, { method: 'POST' }); | ||||
| 
 | ||||
|         try { | ||||
|             const res = await makeRequest(req.caller, req.id); | ||||
| 
 | ||||
|             return res; | ||||
|         } catch (e) { | ||||
|             throw e; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const removeUserFromRole = async ( | ||||
|         projectId: string, | ||||
|         roleId: number, | ||||
|         userId: number | ||||
|     ) => { | ||||
|         const path = `api/admin/projects/${projectId}/users/${userId}/roles/${roleId}`; | ||||
|         const req = createRequest(path, { method: 'DELETE' }); | ||||
| 
 | ||||
|         try { | ||||
|             const res = await makeRequest(req.caller, req.id); | ||||
| 
 | ||||
|             return res; | ||||
|         } catch (e) { | ||||
|             throw e; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     const searchProjectUser = async (query: string): Promise<Response> => { | ||||
|         const path = `api/admin/user-admin/search?q=${query}`; | ||||
| 
 | ||||
|         const req = createRequest(path, { method: 'GET' }); | ||||
| 
 | ||||
|         try { | ||||
|             const res = await makeRequest(req.caller, req.id); | ||||
| 
 | ||||
|             return res; | ||||
|         } catch (e) { | ||||
|             throw e; | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     return { | ||||
|         createProject, | ||||
|         validateId, | ||||
| @ -114,8 +162,11 @@ const useProjectApi = () => { | ||||
|         deleteProject, | ||||
|         addEnvironmentToProject, | ||||
|         removeEnvironmentFromProject, | ||||
|         addUserToRole, | ||||
|         removeUserFromRole, | ||||
|         errors, | ||||
|         loading, | ||||
|         searchProjectUser, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -0,0 +1,61 @@ | ||||
| import useSWR, { mutate, SWRConfiguration } from 'swr'; | ||||
| import { useState, useEffect } from 'react'; | ||||
| import { formatApiPath } from '../../../../utils/format-path'; | ||||
| import handleErrorResponses from '../httpErrorResponseHandler'; | ||||
| import { IProjectRole } from '../../../../interfaces/role'; | ||||
| 
 | ||||
| export interface IProjectAccessUser { | ||||
|     id: number; | ||||
|     imageUrl: string; | ||||
|     isAPI: boolean; | ||||
|     roleId: number; | ||||
|     username?: string; | ||||
|     name?: string; | ||||
|     email?: string; | ||||
| } | ||||
| 
 | ||||
| export interface IProjectAccessOutput { | ||||
|     users: IProjectAccessUser[]; | ||||
|     roles: IProjectRole[]; | ||||
| } | ||||
| 
 | ||||
| const useProjectAccess = ( | ||||
|     projectId: string, | ||||
|     options: SWRConfiguration = {} | ||||
| ) => { | ||||
|     const path = formatApiPath(`api/admin/projects/${projectId}/users`); | ||||
|     const fetcher = () => { | ||||
|         return fetch(path, { | ||||
|             method: 'GET', | ||||
|         }) | ||||
|             .then(handleErrorResponses('project access')) | ||||
|             .then(res => res.json()); | ||||
|     }; | ||||
| 
 | ||||
|     const CACHE_KEY = `api/admin/projects/${projectId}/users`; | ||||
| 
 | ||||
|     const { data, error } = useSWR<IProjectAccessOutput>( | ||||
|         CACHE_KEY, | ||||
|         fetcher, | ||||
|         options | ||||
|     ); | ||||
| 
 | ||||
|     const [loading, setLoading] = useState(!error && !data); | ||||
| 
 | ||||
|     const refetchProjectAccess = () => { | ||||
|         mutate(CACHE_KEY); | ||||
|     }; | ||||
| 
 | ||||
|     useEffect(() => { | ||||
|         setLoading(!error && !data); | ||||
|     }, [data, error]); | ||||
| 
 | ||||
|     return { | ||||
|         access: data ? data : { roles: [], users: [] }, | ||||
|         error, | ||||
|         loading, | ||||
|         refetchProjectAccess, | ||||
|     }; | ||||
| }; | ||||
| 
 | ||||
| export default useProjectAccess; | ||||
| @ -19,7 +19,7 @@ const usePagination = ( | ||||
|         const result = paginate(dataToPaginate, limit); | ||||
|         setPaginatedData(result); | ||||
|         /* eslint-disable-next-line */ | ||||
|     }, [data, limit]); | ||||
|     }, [JSON.stringify(data), limit]); | ||||
| 
 | ||||
|     const nextPage = () => { | ||||
|         if (pageIndex < paginatedData.length - 1) { | ||||
|  | ||||
| @ -3,3 +3,7 @@ export interface IFeatureViewParams { | ||||
|     featureId: string; | ||||
|     activeTab: string; | ||||
| } | ||||
| 
 | ||||
| export interface IProjectViewParams { | ||||
|     id: string; | ||||
| } | ||||
|  | ||||
| @ -10,6 +10,7 @@ export interface IProjectRole { | ||||
|     id: number; | ||||
|     name: string; | ||||
|     description: string; | ||||
|     type: string; | ||||
| } | ||||
| 
 | ||||
| export default IRole; | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user