diff --git a/src/api.js b/src/api.js index bda5fd5319..9767632c6a 100644 --- a/src/api.js +++ b/src/api.js @@ -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/`; diff --git a/src/components/DataListToolbar/DataListToolbar.jsx b/src/components/DataListToolbar/DataListToolbar.jsx index 738f396b3f..a4e6428253 100644 --- a/src/components/DataListToolbar/DataListToolbar.jsx +++ b/src/components/DataListToolbar/DataListToolbar.jsx @@ -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 { { showDelete && ( - - - + + + + + )} - {addUrl && ( + {showAdd && addUrl && ( )} - {add && ( + {showAdd && add && ( {add} )} @@ -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', diff --git a/src/components/DataListToolbar/styles.scss b/src/components/DataListToolbar/styles.scss index c91bdb727c..b05068218f 100644 --- a/src/components/DataListToolbar/styles.scss +++ b/src/components/DataListToolbar/styles.scss @@ -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 { diff --git a/src/components/NotificationsList/NotificationListItem.jsx b/src/components/NotificationsList/NotificationListItem.jsx index 85c8c04492..f10f3f94c5 100644 --- a/src/components/NotificationsList/NotificationListItem.jsx +++ b/src/components/NotificationsList/NotificationListItem.jsx @@ -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 { toggleNotification(itemId, successTurnedOn, 'success')} aria-label={i18n._(t`Notification success toggle`)} /> 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, diff --git a/src/components/NotificationsList/Notifications.list.jsx b/src/components/NotificationsList/Notifications.list.jsx index d7595d913e..e012ca63cd 100644 --- a/src/components/NotificationsList/Notifications.list.jsx +++ b/src/components/NotificationsList/Notifications.list.jsx @@ -273,6 +273,7 @@ class Notifications extends Component { successTemplateIds, errorTemplateIds } = this.state; + const { canToggleNotifications } = this.props; return ( {noInitialResults && ( @@ -315,6 +316,7 @@ class Notifications extends Component { toggleNotification={this.toggleNotification} errorTurnedOn={errorTemplateIds.includes(o.id)} successTurnedOn={successTemplateIds.includes(o.id)} + canToggleNotifications={canToggleNotifications} /> ))} @@ -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, diff --git a/src/contexts/Config.jsx b/src/contexts/Config.jsx index 9fe44da64c..10595a7d90 100644 --- a/src/contexts/Config.jsx +++ b/src/contexts/Config.jsx @@ -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 ( - + {children} ); diff --git a/src/index.jsx b/src/index.jsx index 4654c25ed2..e722060c46 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -63,10 +63,12 @@ export function main (render) { path="/login" render={() => ( - {({ custom_logo, custom_login_info }) => ( + {({ custom_logo, custom_login_info, fetchMe, updateConfig }) => ( )} diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx index d3357d77bf..849aaa6cc5 100644 --- a/src/pages/Login.jsx +++ b/src/pages/Login.jsx @@ -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 }); diff --git a/src/pages/Organizations/Organizations.jsx b/src/pages/Organizations/Organizations.jsx index d5dd9dfdb0..e9ba60d059 100644 --- a/src/pages/Organizations/Organizations.jsx +++ b/src/pages/Organizations/Organizations.jsx @@ -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 { }); }} > - + + {({ me }) => ( + + )} + )} /> diff --git a/src/pages/Organizations/components/OrganizationAccessList.jsx b/src/pages/Organizations/components/OrganizationAccessList.jsx index 3897cae617..3d534ad066 100644 --- a/src/pages/Organizations/components/OrganizationAccessList.jsx +++ b/src/pages/Organizations/components/OrganizationAccessList.jsx @@ -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={( {i18n._(t`User Roles`)} {result.userRoles.map(role => ( - this.handleWarning(role.name, role.id, result.username, result.id, 'users')} - > - {role.name} - + role.user_capabilities.unattach ? ( + this.handleWarning(role.name, role.id, result.username, result.id, 'users')} + > + {role.name} + + ) : ( + + {role.name} + + ) ))} )} diff --git a/src/pages/Organizations/screens/Organization/Organization.jsx b/src/pages/Organizations/screens/Organization/Organization.jsx index f4152d54af..da0c526f25 100644 --- a/src/pages/Organizations/screens/Organization/Organization.jsx +++ b/src/pages/Organizations/screens/Organization/Organization.jsx @@ -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 { /> )} /> - ( - - )} - /> + {canSeeNotificationsTab && ( + ( + + )} + /> + )} {organization && } {error ? 'error!' : ''} diff --git a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx index e069a5e01b..c992f66e6d 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationDetail.jsx @@ -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 { )} - - - Edit - - + {summary_fields.user_capabilities.edit && ( + + + Edit + + + )} {error ? 'error!' : ''} )} diff --git a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx index 4f6cf13d6d..491071bfee 100644 --- a/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx +++ b/src/pages/Organizations/screens/Organization/OrganizationNotifications.jsx @@ -41,13 +41,18 @@ class OrganizationNotifications extends Component { } render () { + const { + canToggleNotifications + } = this.props; + return ( ); } diff --git a/src/pages/Organizations/screens/OrganizationsList.jsx b/src/pages/Organizations/screens/OrganizationsList.jsx index a3746ebaab..f0d0054715 100644 --- a/src/pages/Organizations/screens/OrganizationsList.jsx +++ b/src/pages/Organizations/screens/OrganizationsList.jsx @@ -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 }) => ( @@ -280,15 +297,15 @@ class OrganizationsList extends Component { variant="danger" title={warningTitle} isOpen={isModalOpen} - onClose={this.handleClearOrgsToDelete} + onClose={this.handleCloseOrgDeleteModal} actions={[ {i18n._(t`Delete`)}, - {i18n._(t`Cancel`)} + {i18n._(t`Cancel`)} ]} > {warningMsg} - {orgsToDelete.map((org) => ( + {selected.map((org) => ( {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) ? ( + + + You dont have permission to delete the following Organizations: + + {selected.map(row => ( + + {row.name} + + ))} + + ) : undefined + } showDelete showSelectAll + showAdd={canAdd} /> { 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} /> ))}