From 08c4b60cefe9dafd658449702cf8c7af30952fc4 Mon Sep 17 00:00:00 2001 From: Youssef Khedher Date: Wed, 9 Feb 2022 12:25:02 +0100 Subject: [PATCH] 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 --- .../common/PaginateUI/PaginateUI.tsx | 1 + .../PermissionIconButton.tsx | 8 +- .../src/component/project/Project/Project.tsx | 2 +- .../ProjectAccess/ProjectAccess.styles.ts | 7 - .../project/ProjectAccess/ProjectAccess.tsx | 246 +++++------------- .../ProjectAccessAddUser.tsx | 236 +++++++++++++++++ .../ProjectAccessList/ProjectAccessList.tsx | 64 +++++ .../ProjectAccessListItem.styles.ts | 11 + .../ProjectAccessListItem.tsx | 93 +++++++ .../ProjectRoleSelect/ProjectRoleSelect.tsx | 20 +- .../src/component/project/access-add-user.tsx | 162 ------------ .../src/component/project/access-container.js | 20 -- .../src/hooks/api/actions/useApi/useApi.ts | 11 +- .../actions/useProjectApi/useProjectApi.ts | 51 ++++ .../useProjectAccess/useProjectAccess.ts | 61 +++++ frontend/src/hooks/usePagination.ts | 2 +- frontend/src/interfaces/params.ts | 4 + frontend/src/interfaces/role.ts | 1 + 18 files changed, 618 insertions(+), 382 deletions(-) create mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx create mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessList.tsx create mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.styles.ts create mode 100644 frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.tsx delete mode 100644 frontend/src/component/project/access-add-user.tsx delete mode 100644 frontend/src/component/project/access-container.js create mode 100644 frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts diff --git a/frontend/src/component/common/PaginateUI/PaginateUI.tsx b/frontend/src/component/common/PaginateUI/PaginateUI.tsx index c776481f5d..c2636c589e 100644 --- a/frontend/src/component/common/PaginateUI/PaginateUI.tsx +++ b/frontend/src/component/common/PaginateUI/PaginateUI.tsx @@ -15,6 +15,7 @@ interface IPaginateUIProps { prevPage: () => void; setPageIndex: (idx: number) => void; nextPage: () => void; + style?: React.CSSProperties; } const PaginateUI = ({ diff --git a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx index 903d29713b..ff6103efd2 100644 --- a/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx +++ b/frontend/src/component/common/PermissionIconButton/PermissionIconButton.tsx @@ -3,13 +3,19 @@ import { useContext } from 'react'; import AccessContext from '../../../contexts/AccessContext'; interface IPermissionIconButtonProps - extends React.HTMLProps { + extends React.DetailedHTMLProps< + React.HTMLAttributes, + 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 = ({ diff --git a/frontend/src/component/project/Project/Project.tsx b/frontend/src/component/project/Project/Project.tsx index 70c60bd618..f05acc4c40 100644 --- a/frontend/src/component/project/Project/Project.tsx +++ b/frontend/src/component/project/Project/Project.tsx @@ -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'; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts b/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts index 388a9a9da1..d34ce9b1d1 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts +++ b/frontend/src/component/project/ProjectAccess/ProjectAccess.styles.ts @@ -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', diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx index fee3f58a6d..48ac53420d 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectAccess.tsx @@ -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(); +export const ProjectAccess = () => { + const { id: projectId } = useParams(); 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(); if (isOss()) { return ( - + }> 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 ( - - - - {'Error'} - - - {error} - - - - - - -
- - {page.map(user => { - const labelId = `checkbox-list-secondary-label-${user.id}`; - return ( - - - - - - - - - Choose role - - + } + className={styles.pageContent} + > + - { - setUser(user); - setShowDelDialogue(true); - }} - disabled={users.length === 1} - tooltip={ - users.length === 1 - ? 'A project must have at least one owner' - : 'Remove access' - } - > - - - - - ); - })} +
+ { prevPage={prevPage} style={{ bottom: '-21px' }} /> -
+ + { - setUser({}); + setUser(undefined); setShowDelDialogue(false); }} title="Really remove user from this project" @@ -244,5 +134,3 @@ const ProjectAccess = () => {
); }; - -export default ProjectAccess; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx new file mode 100644 index 0000000000..26116a29f9 --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessAddUser/ProjectAccessAddUser.tsx @@ -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(); + const [role, setRole] = useState(); + 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 ( + <> + + The user must have an Unleash root role before added to the + project. + + + + handleBlur()} + value={user || ''} + freeSolo + getOptionSelected={() => true} + filterOptions={o => o} + getOptionLabel={getOptionLabel} + options={options} + loading={loading} + renderInput={params => ( + + + + ), + endAdornment: ( + <> + + } + /> + + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + + + + + + + + + + ); +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessList.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessList.tsx new file mode 100644 index 0000000000..42c15e3b42 --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessList.tsx @@ -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 = ({ + 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 ( + + {sortUsers(page).map(user => { + return ( + + ); + })} + {children} + + ); +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.styles.ts b/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.styles.ts new file mode 100644 index 0000000000..72f94047e0 --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.styles.ts @@ -0,0 +1,11 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useStyles = makeStyles(() => ({ + iconButton: { + marginLeft: '0.5rem', + }, + actionList: { + display: 'flex', + alignItems: 'center', + }, +})); diff --git a/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.tsx b/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.tsx new file mode 100644 index 0000000000..be16175c34 --- /dev/null +++ b/frontend/src/component/project/ProjectAccess/ProjectAccessList/ProjectAccessListItem/ProjectAccessListItem.tsx @@ -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(); + const styles = useStyles(); + + const labelId = `checkbox-list-secondary-label-${user.id}`; + + return ( + + + + + + + + + Choose role + + + { + handleRemoveAccess(user); + }} + disabled={access.users.length === 1} + tooltip={ + access.users.length === 1 + ? 'A project must have at least one owner' + : 'Remove access' + } + > + + + + + ); +}; diff --git a/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx b/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx index deae3eda52..c8f7dcf257 100644 --- a/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx +++ b/frontend/src/component/project/ProjectAccess/ProjectRoleSelect/ProjectRoleSelect.tsx @@ -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 = ({ +export const ProjectRoleSelect: React.FC = ({ roles, onChange, labelId, @@ -39,9 +44,10 @@ const ProjectRoleSelect: React.FC = ({ 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 = ({ ); }; - -export default ProjectRoleSelect; diff --git a/frontend/src/component/project/access-add-user.tsx b/frontend/src/component/project/access-add-user.tsx deleted file mode 100644 index e5029160c9..0000000000 --- a/frontend/src/component/project/access-add-user.tsx +++ /dev/null @@ -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 ( - <> - - The user must have an Unleash root role before added to the - project. - - - - 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 => ( - - - - ), - endAdornment: ( - - {loading ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} - /> - - - - - - - - - - ); -} - -AddUserComponent.propTypes = { - roles: PropTypes.array.isRequired, - addUserToRole: PropTypes.func.isRequired, -}; - -export default AddUserComponent; diff --git a/frontend/src/component/project/access-container.js b/frontend/src/component/project/access-container.js deleted file mode 100644 index 692a07f425..0000000000 --- a/frontend/src/component/project/access-container.js +++ /dev/null @@ -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; diff --git a/frontend/src/hooks/api/actions/useApi/useApi.ts b/frontend/src/hooks/api/actions/useApi/useApi.ts index 8791587534..eb2157a278 100644 --- a/frontend/src/hooks/api/actions/useApi/useApi.ts +++ b/frontend/src/hooks/api/actions/useApi/useApi.ts @@ -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'); } diff --git a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts index 9be6552ba2..364b319276 100644 --- a/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts +++ b/frontend/src/hooks/api/actions/useProjectApi/useProjectApi.ts @@ -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 => { + 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, }; }; diff --git a/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts new file mode 100644 index 0000000000..ae62667811 --- /dev/null +++ b/frontend/src/hooks/api/getters/useProjectAccess/useProjectAccess.ts @@ -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( + 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; diff --git a/frontend/src/hooks/usePagination.ts b/frontend/src/hooks/usePagination.ts index f9ecea4380..366b118ee6 100644 --- a/frontend/src/hooks/usePagination.ts +++ b/frontend/src/hooks/usePagination.ts @@ -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) { diff --git a/frontend/src/interfaces/params.ts b/frontend/src/interfaces/params.ts index a58af34e11..5cac9864d7 100644 --- a/frontend/src/interfaces/params.ts +++ b/frontend/src/interfaces/params.ts @@ -3,3 +3,7 @@ export interface IFeatureViewParams { featureId: string; activeTab: string; } + +export interface IProjectViewParams { + id: string; +} diff --git a/frontend/src/interfaces/role.ts b/frontend/src/interfaces/role.ts index efb2b62a59..479ad71c86 100644 --- a/frontend/src/interfaces/role.ts +++ b/frontend/src/interfaces/role.ts @@ -10,6 +10,7 @@ export interface IProjectRole { id: number; name: string; description: string; + type: string; } export default IRole;