Add RBAC to org views

This commit is contained in:
mabashian 2019-04-18 13:31:03 -04:00
parent 1509ef3e80
commit 5ae7cbb43a
14 changed files with 315 additions and 98 deletions

View File

@ -3,6 +3,7 @@ const API_LOGIN = `${API_ROOT}login/`;
const API_LOGOUT = `${API_ROOT}logout/`;
const API_V2 = `${API_ROOT}v2/`;
const API_CONFIG = `${API_V2}config/`;
const API_ME = `${API_V2}me/`;
const API_ORGANIZATIONS = `${API_V2}organizations/`;
const API_INSTANCE_GROUPS = `${API_V2}instance_groups/`;
const API_USERS = `${API_V2}users/`;
@ -58,6 +59,10 @@ class APIClient {
return this.http.get(API_CONFIG);
}
getMe () {
return this.http.get(API_ME);
}
destroyOrganization (id) {
const endpoint = `${API_ORGANIZATIONS}${id}/`;
return (this.http.delete(endpoint));
@ -71,6 +76,10 @@ class APIClient {
return this.http.post(API_ORGANIZATIONS, data);
}
callOrganizations () {
return this.http.options(API_ORGANIZATIONS);
}
getOrganizationAccessList (id, params = {}) {
const endpoint = `${API_ORGANIZATIONS}${id}/access_list/`;

View File

@ -1,6 +1,6 @@
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { I18n } from '@lingui/react';
import { I18n, i18nMark } from '@lingui/react';
import { t } from '@lingui/macro';
import {
Button,
@ -28,23 +28,27 @@ import VerticalSeparator from '../VerticalSeparator';
class DataListToolbar extends React.Component {
render () {
const {
add,
addUrl,
columns,
deleteTooltip,
disableTrashCanIcon,
onSelectAll,
sortedColumnKey,
sortOrder,
showDelete,
showSelectAll,
isAllSelected,
isCompact,
noLeftMargin,
onSort,
onSearch,
onSearch
onCompact,
onExpand,
add,
onOpenDeleteModal
onOpenDeleteModal,
onSearch,
onSelectAll,
onSort,
showAdd,
showDelete,
showSelectAll,
sortOrder,
sortedColumnKey
} = this.props;
const showExpandCollapse = (onCompact && onExpand);
@ -112,21 +116,23 @@ class DataListToolbar extends React.Component {
<LevelItem>
{ showDelete && (
<Tooltip
content={i18n._(t`Delete`)}
position="top"
content={deleteTooltip}
position="left"
>
<Button
className="awx-ToolBarBtn"
variant="plain"
aria-label={i18n._(t`Delete`)}
onClick={onOpenDeleteModal}
isDisabled={disableTrashCanIcon}
>
<TrashAltIcon className="awx-ToolBarTrashCanIcon" />
</Button>
<span>
<Button
className="awx-ToolBarBtn"
variant="plain"
aria-label={i18n._(t`Delete`)}
onClick={onOpenDeleteModal}
isDisabled={disableTrashCanIcon}
>
<TrashAltIcon className="awx-ToolBarTrashCanIcon" />
</Button>
</span>
</Tooltip>
)}
{addUrl && (
{showAdd && addUrl && (
<Link to={addUrl}>
<Button
variant="primary"
@ -136,7 +142,7 @@ class DataListToolbar extends React.Component {
</Button>
</Link>
)}
{add && (
{showAdd && add && (
<Fragment>{add}</Fragment>
)}
</LevelItem>
@ -149,13 +155,16 @@ class DataListToolbar extends React.Component {
}
DataListToolbar.propTypes = {
add: PropTypes.node,
addUrl: PropTypes.string,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
deleteTooltip: PropTypes.node,
isAllSelected: PropTypes.bool,
noLeftMargin: PropTypes.bool,
onSearch: PropTypes.func,
onSelectAll: PropTypes.func,
onSort: PropTypes.func,
showAdd: PropTypes.bool,
showDelete: PropTypes.bool,
showSelectAll: PropTypes.bool,
sortOrder: PropTypes.string,
@ -167,10 +176,17 @@ DataListToolbar.propTypes = {
};
DataListToolbar.defaultProps = {
add: null,
addUrl: null,
deleteTooltip: i18nMark('Delete'),
isAllSelected: false,
isCompact: false,
onCompact: null,
onExpand: null,
onSearch: null,
onSelectAll: null,
onSort: null,
showAdd: false,
showDelete: false,
showSelectAll: false,
sortOrder: 'ascending',

View File

@ -74,7 +74,10 @@
padding: 0px;
margin: 0px;
margin-right: 20px;
margin-left: 20px;
}
.awx-toolbar .pf-c-button {
margin-right: 20px;
}
.awx-toolbar .pf-l-toolbar__item .pf-c-button.pf-m-plain {

View File

@ -13,6 +13,7 @@ import {
class NotificationListItem extends React.Component {
render () {
const {
canToggleNotifications,
itemId,
name,
notificationType,
@ -49,12 +50,14 @@ class NotificationListItem extends React.Component {
<Switch
label={i18n._(t`Successful`)}
isChecked={successTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(itemId, successTurnedOn, 'success')}
aria-label={i18n._(t`Notification success toggle`)}
/>
<Switch
label={i18n._(t`Failure`)}
isChecked={errorTurnedOn}
isDisabled={!canToggleNotifications}
onChange={() => toggleNotification(itemId, errorTurnedOn, 'error')}
aria-label={i18n._(t`Notification failure toggle`)}
/>
@ -67,6 +70,7 @@ class NotificationListItem extends React.Component {
}
NotificationListItem.propTypes = {
canToggleNotifications: PropTypes.bool.isRequired,
detailUrl: PropTypes.string.isRequired,
errorTurnedOn: PropTypes.bool,
itemId: PropTypes.number.isRequired,

View File

@ -273,6 +273,7 @@ class Notifications extends Component {
successTemplateIds,
errorTemplateIds
} = this.state;
const { canToggleNotifications } = this.props;
return (
<Fragment>
{noInitialResults && (
@ -315,6 +316,7 @@ class Notifications extends Component {
toggleNotification={this.toggleNotification}
errorTurnedOn={errorTemplateIds.includes(o.id)}
successTurnedOn={successTemplateIds.includes(o.id)}
canToggleNotifications={canToggleNotifications}
/>
))}
</ul>
@ -337,6 +339,7 @@ class Notifications extends Component {
}
Notifications.propTypes = {
canToggleNotifications: PropTypes.bool.isRequired,
onReadError: PropTypes.func.isRequired,
onReadNotifications: PropTypes.func.isRequired,
onReadSuccess: PropTypes.func.isRequired,

View File

@ -16,11 +16,14 @@ class Provider extends Component {
version: null,
custom_logo: null,
custom_login_info: null,
me: {},
...props.value
}
};
this.fetchConfig = this.fetchConfig.bind(this);
this.fetchMe = this.fetchMe.bind(this);
this.updateConfig = this.updateConfig.bind(this);
}
componentDidMount () {
@ -30,6 +33,47 @@ class Provider extends Component {
}
}
updateConfig = (config) => {
const {
ansible_version,
custom_virtualenvs,
version
} = config;
this.setState(prevState => ({
value: {
...prevState.value,
ansible_version,
custom_virtualenvs,
version
},
}));
}
async fetchMe () {
const { api, handleHttpError } = this.props;
try {
const { data: { results: [me] } } = await api.getMe();
this.setState(prevState => ({
value: {
...prevState.value,
me
},
}));
} catch (err) {
handleHttpError(err) || this.setState({
value: {
ansible_version: null,
custom_virtualenvs: null,
version: null,
custom_logo: null,
custom_login_info: null,
me: {}
}
});
}
}
async fetchConfig () {
const { api, handleHttpError } = this.props;
@ -47,13 +91,15 @@ class Provider extends Component {
custom_login_info
}
} = await api.getRoot();
const { data: { results: [me] } } = await api.getMe();
this.setState({
value: {
ansible_version,
custom_virtualenvs,
version,
custom_logo,
custom_login_info
custom_login_info,
me
}
});
} catch (err) {
@ -63,7 +109,8 @@ class Provider extends Component {
custom_virtualenvs: null,
version: null,
custom_logo: null,
custom_login_info: null
custom_login_info: null,
me: {}
}
});
}
@ -75,7 +122,13 @@ class Provider extends Component {
const { children } = this.props;
return (
<ConfigContext.Provider value={value}>
<ConfigContext.Provider
value={{
...(propsValue || stateValue),
fetchMe: this.fetchMe,
updateConfig: this.updateConfig
}}
>
{children}
</ConfigContext.Provider>
);

View File

@ -63,10 +63,12 @@ export function main (render) {
path="/login"
render={() => (
<Config>
{({ custom_logo, custom_login_info }) => (
{({ custom_logo, custom_login_info, fetchMe, updateConfig }) => (
<Login
logo={custom_logo}
loginInfo={custom_login_info}
fetchMe={fetchMe}
updateConfig={updateConfig}
/>
)}
</Config>

View File

@ -39,7 +39,7 @@ class AWXLogin extends Component {
async onLoginButtonClick (event) {
const { username, password, isLoading } = this.state;
const { api, handleHttpError, clearRootDialogMessage } = this.props;
const { api, handleHttpError, clearRootDialogMessage, fetchMe, updateConfig } = this.props;
event.preventDefault();
@ -51,7 +51,9 @@ class AWXLogin extends Component {
this.setState({ isLoading: true });
try {
await api.login(username, password);
const { data } = await api.login(username, password);
updateConfig(data);
fetchMe();
this.setState({ isAuthenticated: true, isLoading: false });
} catch (error) {
handleHttpError(error) || this.setState({ isInputValid: false, isLoading: false });

View File

@ -3,6 +3,7 @@ import { Route, withRouter, Switch } from 'react-router-dom';
import { i18nMark } from '@lingui/react';
import { Trans } from '@lingui/macro';
import { Config } from '../../contexts/Config';
import { NetworkProvider } from '../../contexts/Network';
import { withRootDialog } from '../../contexts/RootDialog';
@ -74,11 +75,16 @@ class Organizations extends Component {
});
}}
>
<Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
/>
<Config>
{({ me }) => (
<Organization
history={history}
location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
</NetworkProvider>
)}
/>

View File

@ -18,9 +18,14 @@ import {
withRouter
} from 'react-router-dom';
import {
PlusIcon,
} from '@patternfly/react-icons';
import { withNetwork } from '../../../contexts/Network';
import AlertModal from '../../../components/AlertModal';
import BasicChip from '../../../components/BasicChip/BasicChip';
import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar';
import AddResourceRole from '../../../components/AddRole/AddResourceRole';
@ -357,6 +362,7 @@ class OrganizationAccessList extends React.Component {
columns={this.columns}
onSearch={() => { }}
onSort={this.onSort}
showAdd={organization.summary_fields.user_capabilities.edit}
add={(
<Fragment>
<Button
@ -421,13 +427,21 @@ class OrganizationAccessList extends React.Component {
<ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`User Roles`)}</Text>
{result.userRoles.map(role => (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => this.handleWarning(role.name, role.id, result.username, result.id, 'users')}
>
{role.name}
</Chip>
role.user_capabilities.unattach ? (
<Chip
key={role.id}
className="awx-c-chip"
onClick={() => this.handleWarning(role.name, role.id, result.username, result.id, 'users')}
>
{role.name}
</Chip>
) : (
<BasicChip
key={role.id}
>
{role.name}
</BasicChip>
)
))}
</ul>
)}

View File

@ -33,13 +33,17 @@ class Organization extends Component {
organization: null,
error: false,
loading: true,
isNotifAdmin: false,
isAuditorOfThisOrg: false,
isAdminOfThisOrg: false
};
this.fetchOrganization = this.fetchOrganization.bind(this);
this.fetchOrganizationAndRoles = this.fetchOrganizationAndRoles.bind(this);
}
componentDidMount () {
this.fetchOrganization();
this.fetchOrganizationAndRoles();
}
async componentDidUpdate (prevProps) {
@ -49,6 +53,43 @@ class Organization extends Component {
}
}
async fetchOrganizationAndRoles () {
const {
match,
setBreadcrumb,
api,
handleHttpError
} = this.props;
try {
const [{ data }, notifAdminRest, auditorRes, adminRes] = await Promise.all([
api.getOrganizationDetails(parseInt(match.params.id, 10)),
api.getOrganizations({
role_level: 'notification_admin_role',
page_size: 1
}),
api.getOrganizations({
role_level: 'auditor_role',
id: parseInt(match.params.id, 10)
}),
api.getOrganizations({
role_level: 'admin_role',
id: parseInt(match.params.id, 10)
})
]);
setBreadcrumb(data);
this.setState({
organization: data,
loading: false,
isNotifAdmin: notifAdminRest.data.results.length > 0,
isAuditorOfThisOrg: auditorRes.data.results.length > 0,
isAdminOfThisOrg: adminRes.data.results.length > 0
});
} catch (error) {
handleHttpError(error) || this.setState({ error: true, loading: false });
}
}
async fetchOrganization () {
const {
match,
@ -70,19 +111,40 @@ class Organization extends Component {
const {
location,
match,
me,
history
} = this.props;
const {
organization,
error,
loading
loading,
isNotifAdmin,
isAuditorOfThisOrg,
isAdminOfThisOrg
} = this.state;
const tabsPaddingOverride = {
padding: '0'
};
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg;
const canToggleNotifications = isNotifAdmin && (
me.is_system_auditor
|| isAuditorOfThisOrg
|| isAdminOfThisOrg
);
const tabElements = [
{ name: i18nMark('Details'), link: `${match.url}/details` },
{ name: i18nMark('Access'), link: `${match.url}/access` },
{ name: i18nMark('Teams'), link: `${match.url}/teams` }
];
if (canSeeNotificationsTab) {
tabElements.push({ name: i18nMark('Notifications'), link: `${match.url}/notifications` });
}
let cardHeader = (
loading ? ''
: (
@ -174,16 +236,19 @@ class Organization extends Component {
/>
)}
/>
<Route
path="/organizations/:id/notifications"
render={() => (
<OrganizationNotifications
match={match}
location={location}
history={history}
/>
)}
/>
{canSeeNotificationsTab && (
<Route
path="/organizations/:id/notifications"
render={() => (
<OrganizationNotifications
match={match}
location={location}
history={history}
canToggleNotifications={canToggleNotifications}
/>
)}
/>
)}
{organization && <NotifyAndRedirect to={`/organizations/${match.params.id}/details`} />}
</Switch>
{error ? 'error!' : ''}

View File

@ -100,7 +100,8 @@ class OrganizationDetail extends Component {
description,
custom_virtualenv,
created,
modified
modified,
summary_fields
},
match
} = this.props;
@ -165,11 +166,13 @@ class OrganizationDetail extends Component {
</TextContent>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'row-reverse', marginTop: '20px' }}>
<Link to={`/organizations/${match.params.id}/edit`}>
<Button><Trans>Edit</Trans></Button>
</Link>
</div>
{summary_fields.user_capabilities.edit && (
<div style={{ display: 'flex', flexDirection: 'row-reverse', marginTop: '20px' }}>
<Link to={`/organizations/${match.params.id}/edit`}>
<Button><Trans>Edit</Trans></Button>
</Link>
</div>
)}
{error ? 'error!' : ''}
</CardBody>
)}

View File

@ -41,13 +41,18 @@ class OrganizationNotifications extends Component {
}
render () {
const {
canToggleNotifications
} = this.props;
return (
<NotificationsList
canToggleNotifications={canToggleNotifications}
onCreateError={this.createOrgNotificationError}
onCreateSuccess={this.createOrgNotificationSuccess}
onReadError={this.readOrgNotificationError}
onReadNotifications={this.readOrgNotifications}
onReadSuccess={this.readOrgNotificationSuccess}
onReadError={this.readOrgNotificationError}
onCreateSuccess={this.createOrgNotificationSuccess}
onCreateError={this.createOrgNotificationError}
/>
);
}

View File

@ -62,8 +62,7 @@ class OrganizationsList extends Component {
loading: true,
results: [],
selected: [],
isModalOpen: false,
orgsToDelete: [],
isModalOpen: false
};
@ -74,14 +73,16 @@ class OrganizationsList extends Component {
this.onSelectAll = this.onSelectAll.bind(this);
this.onSelect = this.onSelect.bind(this);
this.updateUrl = this.updateUrl.bind(this);
this.callOrganizations = this.callOrganizations.bind(this);
this.fetchOrganizations = this.fetchOrganizations.bind(this);
this.handleOrgDelete = this.handleOrgDelete.bind(this);
this.handleOpenOrgDeleteModal = this.handleOpenOrgDeleteModal.bind(this);
this.handleClearOrgsToDelete = this.handleClearOrgsToDelete.bind(this);
this.handleCloseOrgDeleteModal = this.handleCloseOrgDeleteModal.bind(this);
}
componentDidMount () {
const queryParams = this.getQueryParams();
this.callOrganizations();
this.fetchOrganizations(queryParams);
}
@ -117,20 +118,20 @@ class OrganizationsList extends Component {
onSelectAll (isSelected) {
const { results } = this.state;
const selected = isSelected ? results.map(o => o.id) : [];
const selected = isSelected ? results : [];
this.setState({ selected });
}
onSelect (id) {
onSelect (row) {
const { selected } = this.state;
const isSelected = selected.includes(id);
const isSelected = selected.some(s => s.id === row.id);
if (isSelected) {
this.setState({ selected: selected.filter(s => s !== id) });
this.setState({ selected: selected.filter(s => s.id !== row.id) });
} else {
this.setState({ selected: selected.concat(id) });
this.setState({ selected: selected.concat(row) });
}
}
@ -143,43 +144,35 @@ class OrganizationsList extends Component {
return Object.assign({}, this.defaultParams, searchParams, overrides);
}
handleClearOrgsToDelete () {
handleCloseOrgDeleteModal () {
this.setState({
isModalOpen: false,
orgsToDelete: []
isModalOpen: false
});
this.onSelectAll();
}
handleOpenOrgDeleteModal () {
const { results, selected } = this.state;
const { selected } = this.state;
const warningTitle = selected.length > 1 ? i18nMark('Delete Organization') : i18nMark('Delete Organizations');
const warningMsg = i18nMark('Are you sure you want to delete:');
const orgsToDelete = [];
results.forEach((result) => {
selected.forEach((selectedOrg) => {
if (result.id === selectedOrg) {
orgsToDelete.push({ name: result.name, id: selectedOrg });
}
});
});
this.setState({
orgsToDelete,
isModalOpen: true,
warningTitle,
warningMsg,
loading: false });
loading: false
});
}
async handleOrgDelete (event) {
const { orgsToDelete } = this.state;
async handleOrgDelete () {
const { selected } = this.state;
const { api, handleHttpError } = this.props;
let errorHandled;
try {
await Promise.all(orgsToDelete.map((org) => api.destroyOrganization(org.id)));
this.handleClearOrgsToDelete();
await Promise.all(selected.map((org) => api.destroyOrganization(org.id)));
this.setState({
isModalOpen: false,
selected: []
});
} catch (err) {
errorHandled = handleHttpError(err);
} finally {
@ -188,7 +181,6 @@ class OrganizationsList extends Component {
this.fetchOrganizations(queryParams);
}
}
event.preventDefault();
}
updateUrl (queryParams) {
@ -248,16 +240,35 @@ class OrganizationsList extends Component {
}
}
async callOrganizations () {
const { api } = this.props;
try {
const { data } = await api.callOrganizations();
const { actions } = data;
const stateToUpdate = {
canAdd: Object.prototype.hasOwnProperty.call(actions, 'POST')
};
this.setState(stateToUpdate);
} catch (err) {
this.setState({ error: true });
} finally {
this.setState({ loading: false });
}
}
render () {
const {
medium,
} = PageSectionVariants;
const {
canAdd,
count,
error,
loading,
noInitialResults,
orgsToDelete,
page,
pageCount,
page_size,
@ -270,6 +281,12 @@ class OrganizationsList extends Component {
warningMsg,
} = this.state;
const { match } = this.props;
const disableDelete = (
selected.length === 0
|| selected.some(row => !row.summary_fields.user_capabilities.delete)
);
return (
<I18n>
{({ i18n }) => (
@ -280,15 +297,15 @@ class OrganizationsList extends Component {
variant="danger"
title={warningTitle}
isOpen={isModalOpen}
onClose={this.handleClearOrgsToDelete}
onClose={this.handleCloseOrgDeleteModal}
actions={[
<Button variant="danger" key="delete" aria-label="confirm-delete" onClick={this.handleOrgDelete}>{i18n._(t`Delete`)}</Button>,
<Button variant="secondary" key="cancel" aria-label="cancel-delete" onClick={this.handleClearOrgsToDelete}>{i18n._(t`Cancel`)}</Button>
<Button variant="secondary" key="cancel" aria-label="cancel-delete" onClick={this.handleCloseOrgDeleteModal}>{i18n._(t`Cancel`)}</Button>
]}
>
{warningMsg}
<br />
{orgsToDelete.map((org) => (
{selected.map((org) => (
<span key={org.id}>
<strong>
{org.name}
@ -321,9 +338,24 @@ class OrganizationsList extends Component {
onSort={this.onSort}
onSelectAll={this.onSelectAll}
onOpenDeleteModal={this.handleOpenOrgDeleteModal}
disableTrashCanIcon={selected.length === 0}
disableTrashCanIcon={disableDelete}
deleteTooltip={
selected.some(row => !row.summary_fields.user_capabilities.delete) ? (
<div>
<Trans>
You dont have permission to delete the following Organizations:
</Trans>
{selected.map(row => (
<div key={row.id}>
{row.name}
</div>
))}
</div>
) : undefined
}
showDelete
showSelectAll
showAdd={canAdd}
/>
<ul className="pf-c-data-list" aria-label={i18n._(t`Organizations List`)}>
{ results.map(o => (
@ -334,8 +366,8 @@ class OrganizationsList extends Component {
detailUrl={`${match.url}/${o.id}`}
memberCount={o.summary_fields.related_field_counts.users}
teamCount={o.summary_fields.related_field_counts.teams}
isSelected={selected.includes(o.id)}
onSelect={() => this.onSelect(o.id, o.name)}
isSelected={selected.some(row => row.id === o.id)}
onSelect={() => this.onSelect(o)}
onOpenOrgDeleteModal={this.handleOpenOrgDeleteModal}
/>
))}