1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

feat/rbac: edit access for projects. (#251)

This commit is contained in:
Ivar Conradi Østhus 2021-03-11 13:59:20 +01:00 committed by GitHub
parent ae977b0bf1
commit b9e6586c30
14 changed files with 396 additions and 4 deletions

View File

@ -42,6 +42,7 @@
"devDependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.57",
"classnames": "^2.2.6",
"date-fns": "^2.17.0",
"@babel/core": "^7.9.0",

View File

@ -1,2 +1,3 @@
export const P = 'P';
export const C = 'C';
export const RBAC = 'RBAC';

View File

@ -185,6 +185,12 @@ Array [
"path": "/projects/edit/:id",
"title": ":id",
},
Object {
"component": [Function],
"parent": "/projects",
"path": "/projects/:id/access",
"title": ":id",
},
Object {
"component": [Function],
"flag": "P",

View File

@ -1,7 +1,7 @@
import { routes, baseRoutes, getRoute } from '../routes';
test('returns all defined routes', () => {
expect(routes.length).toEqual(33);
expect(routes.length).toEqual(34);
expect(routes).toMatchSnapshot();
});

View File

@ -18,6 +18,7 @@ import LogoutFeatures from '../../page/user/logout';
import ListProjects from '../../page/project';
import CreateProject from '../../page/project/create';
import EditProject from '../../page/project/edit';
import EditProjectAccess from '../../page/project/access';
import ListTagTypes from '../../page/tag-types';
import CreateTagType from '../../page/tag-types/create';
import EditTagType from '../../page/tag-types/edit';
@ -156,6 +157,13 @@ export const routes = [
title: ':id',
component: EditProject,
},
{
path: '/projects/:id/access',
parent: '/projects',
title: ':id',
component: EditProjectAccess,
},
{
path: '/projects',
title: 'Projects',

View File

@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Select,
MenuItem,
TextField,
CircularProgress,
InputLabel,
FormControl,
Grid,
Button,
Icon,
InputAdornment,
} from '@material-ui/core';
import Autocomplete from '@material-ui/lab/Autocomplete';
function AddUserComponent({ roles, addUserToRole }) {
const [user, setUser] = useState();
const [role, setRole] = useState({});
const [options, setOptions] = useState([]);
const [loading, setLoading] = 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 response = await fetch(`api/admin/user-admin/search?q=${q}`);
const users = await response.json();
setOptions([...users]);
} else {
setOptions([]);
}
setLoading(false);
};
const handleQueryUpdate = evt => {
const q = evt.target.value;
search(q);
};
const handleSelectUser = (evt, value) => {
setOptions([]);
setUser(value);
};
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 (
<Grid container justify="center" spacing={3} alignItems="flex-end">
<Grid item>
<Autocomplete
id="add-user-component"
style={{ width: 300 }}
noOptionsText="No users found."
onChange={handleSelectUser}
autoSelect={false}
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"
name="search"
onChange={handleQueryUpdate}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<Icon>search</Icon>
</InputAdornment>
),
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
/>
</Grid>
<Grid item>
<FormControl>
<InputLabel id="add-user-select-role-label">Role</InputLabel>
<Select
labelId="add-user-select-role-label"
id="add-user-select-role"
placeholder="Project role"
value={role.id || ''}
onChange={handleRoleChange}
>
{roles.map(role => (
<MenuItem key={role.id} value={role.id}>
{role.name}
</MenuItem>
))}
</Select>
</FormControl>
</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;

View File

@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import {
Card,
CardHeader,
Avatar,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
ListItemAvatar,
Select,
MenuItem,
Icon,
IconButton,
Dialog,
DialogActions,
DialogTitle,
DialogContentText,
DialogContent,
Button,
} from '@material-ui/core';
import AddUserComponent from './access-add-user';
import projectApi from '../../store/project/api';
function AccessComponent({ projectId, project }) {
const [roles, setRoles] = useState([]);
const [users, setUsers] = useState([]);
const [error, setError] = useState();
const fetchAccess = async () => {
const access = await projectApi.fetchAccess(projectId);
setRoles(access.roles);
setUsers(access.users.map(u => ({ ...u, name: u.name || '(No name)' })));
};
useEffect(() => {
fetchAccess();
}, [projectId]);
if (!project) {
return <p>....</p>;
}
const handleRoleChange = (userId, currRoleId) => async evt => {
const roleId = evt.target.value;
try {
await projectApi.removeUserFromRole(projectId, currRoleId, userId);
await projectApi.addUserToRole(projectId, roleId, userId);
const newUsers = users.map(u => {
if (u.id === userId) {
return { ...u, roleId };
} else return u;
});
setUsers(newUsers);
} catch (err) {
setError(err.message || 'Server problems when adding users.');
}
};
const addUser = async (userId, roleId) => {
try {
await projectApi.addUserToRole(projectId, roleId, userId);
await fetchAccess();
} catch (err) {
setError(err.message || 'Server problems when adding users.');
}
};
const removeAccess = (userId, roleId) => async () => {
try {
await projectApi.removeUserFromRole(projectId, roleId, userId);
const newUsers = users.filter(u => u.id !== userId);
setUsers(newUsers);
} catch (err) {
setError(err.message || 'Server problems when adding users.');
}
};
const handleCloseError = () => {
setError(undefined);
};
return (
<Card style={{ minHeight: '400px' }}>
<CardHeader title={`Managed Access for project "${project.name}"`} />
<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>
<List>
{users.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>
<Select
labelId={`role-${user.id}-select-label`}
id={`role-${user.id}-select`}
placeholder="Choose role"
value={user.roleId}
onChange={handleRoleChange(user.id, user.roleId)}
>
<MenuItem value="" disabled>
Choose role
</MenuItem>
{roles.map(role => (
<MenuItem key={`${user.id}:${role.id}`} value={role.id}>
{role.name}
</MenuItem>
))}
</Select>
<IconButton
edge="end"
aria-label="delete"
title="Remove access"
onClick={removeAccess(user.id, user.roleId)}
>
<Icon>delete</Icon>
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
})}
</List>
</Card>
);
}
AccessComponent.propTypes = {
projectId: PropTypes.string.isRequired,
project: PropTypes.object,
};
export default AccessComponent;

View File

@ -0,0 +1,18 @@
import { connect } from 'react-redux';
import Component from './access-component';
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;

View File

@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import { List, ListItem, ListItemAction, ListItemContent, IconButton, Card } from 'react-mdl';
import { HeaderTitle, styles as commonStyles } from '../common';
import { CREATE_PROJECT, DELETE_PROJECT } from '../../permissions';
import { CREATE_PROJECT, DELETE_PROJECT, UPDATE_PROJECT } from '../../permissions';
class ProjectListComponent extends Component {
static propTypes = {
@ -13,6 +13,7 @@ class ProjectListComponent extends Component {
removeProject: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
hasPermission: PropTypes.func.isRequired,
rbacEnabled: PropTypes.bool.isRequired,
};
componentDidMount() {
@ -25,7 +26,7 @@ class ProjectListComponent extends Component {
};
render() {
const { projects, hasPermission } = this.props;
const { projects, hasPermission, rbacEnabled } = this.props;
return (
<Card shadow={0} className={commonStyles.fullwidth} style={{ overflow: 'visible' }}>
@ -56,6 +57,13 @@ class ProjectListComponent extends Component {
</Link>
</ListItemContent>
<ListItemAction>
{hasPermission(UPDATE_PROJECT) && rbacEnabled ? (
<Link to={`/projects/${project.id}/access`} style={{ color: 'black' }}>
<IconButton name="supervised_user_circle" title="Manage access" />
</Link>
) : (
''
)}
{hasPermission(DELETE_PROJECT) ? (
<IconButton
name="delete"

View File

@ -2,12 +2,15 @@ import { connect } from 'react-redux';
import Component from './list-component.jsx';
import { fetchProjects, removeProject } from './../../store/project/actions';
import { hasPermission } from '../../permissions';
import { RBAC } from '../common/flags';
const mapStateToProps = state => {
const projects = state.projects.toJS();
const rbacEnabled = !!state.uiConfig.toJS().flags[RBAC];
return {
projects,
rbacEnabled,
hasPermission: hasPermission.bind(null, state.user.get('profile')),
};
};

View File

@ -0,0 +1,14 @@
import React from 'react';
import ProjectAccess from '../../component/project/access-container.js';
import PropTypes from 'prop-types';
const render = ({ match: { params }, history }) => (
<ProjectAccess projectId={params.id} title="Edit project Access" history={history} />
);
render.propTypes = {
match: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default render;

View File

@ -55,7 +55,7 @@ export function throwIfNotSuccess(response) {
return new Promise((resolve, reject) => {
reject(new NotFoundError(response.status));
});
} else if (response.status > 399 && response.status < 499) {
} else if (response.status > 399 && response.status < 501) {
return new Promise((resolve, reject) => {
response.json().then(body => {
const errorMsg = body && body.isJoi ? extractJoiMsg(body) : extractLegacyMsg(body);

View File

@ -8,6 +8,28 @@ function fetchAll() {
.then(response => response.json());
}
function fetchAccess(projectId) {
return fetch(`${URI}/${projectId}/users`, { credentials: 'include' })
.then(throwIfNotSuccess)
.then(response => response.json());
}
function addUserToRole(projectId, roleId, userId) {
return fetch(`${URI}/${projectId}/users/${userId}/roles/${roleId}`, {
method: 'POST',
headers,
credentials: 'include',
}).then(throwIfNotSuccess);
}
function removeUserFromRole(projectId, roleId, userId) {
return fetch(`${URI}/${projectId}/users/${userId}/roles/${roleId}`, {
method: 'DELETE',
headers,
credentials: 'include',
}).then(throwIfNotSuccess);
}
function create(project) {
return fetch(URI, {
method: 'POST',
@ -49,4 +71,7 @@ export default {
update,
remove,
validate,
fetchAccess,
addUserToRole,
removeUserFromRole,
};

View File

@ -1475,6 +1475,17 @@
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/lab@4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw==
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.11.2"
clsx "^1.0.4"
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"
"@material-ui/styles@^4.11.3":
version "4.11.3"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.3.tgz#1b8d97775a4a643b53478c895e3f2a464e8916f2"