Merge pull request #173 from mabashian/151-org-rbac

Add RBAC to org views (now with tests!)
This commit is contained in:
Michael Abashian
2019-04-26 12:01:33 -04:00
committed by GitHub
22 changed files with 465 additions and 151 deletions

View File

@@ -171,6 +171,7 @@ Here are the guidelines for how to name functions.
|`replace<x>`| Use for methods that make API `PUT` requests | |`replace<x>`| Use for methods that make API `PUT` requests |
|`disassociate<x>`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint | |`disassociate<x>`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint |
|`associate<x>`| Use for methods that pass a resource id as a data param to an endpoint | |`associate<x>`| Use for methods that pass a resource id as a data param to an endpoint |
|`can<x>`| Use for props dealing with RBAC to denote whether a user has access to something |
### Default State Initialization ### Default State Initialization
When declaring empty initial states, prefer the following instead of leaving them undefined: When declaring empty initial states, prefer the following instead of leaving them undefined:

View File

@@ -11,6 +11,7 @@ describe('<Notifications />', () => {
onReadSuccess={() => {}} onReadSuccess={() => {}}
onCreateError={() => {}} onCreateError={() => {}}
onCreateSuccess={() => {}} onCreateSuccess={() => {}}
canToggleNotifications
/> />
); );
}); });
@@ -24,6 +25,7 @@ describe('<Notifications />', () => {
onReadSuccess={() => {}} onReadSuccess={() => {}}
onCreateError={() => {}} onCreateError={() => {}}
onCreateSuccess={() => {}} onCreateSuccess={() => {}}
canToggleNotifications
/> />
); );
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
@@ -38,6 +40,7 @@ describe('<Notifications />', () => {
onReadSuccess={() => {}} onReadSuccess={() => {}}
onCreateError={() => {}} onCreateError={() => {}}
onCreateSuccess={() => {}} onCreateSuccess={() => {}}
canToggleNotifications
/> />
).find('Notifications'); ).find('Notifications');
wrapper.instance().toggleNotification(1, true, 'success'); wrapper.instance().toggleNotification(1, true, 'success');
@@ -56,6 +59,7 @@ describe('<Notifications />', () => {
onReadSuccess={() => {}} onReadSuccess={() => {}}
onCreateError={() => {}} onCreateError={() => {}}
onCreateSuccess={onCreateSuccess} onCreateSuccess={onCreateSuccess}
canToggleNotifications
/> />
).find('Notifications'); ).find('Notifications');
wrapper.setState({ successTemplateIds: [44] }); wrapper.setState({ successTemplateIds: [44] });
@@ -76,6 +80,7 @@ describe('<Notifications />', () => {
onReadSuccess={() => {}} onReadSuccess={() => {}}
onCreateError={() => {}} onCreateError={() => {}}
onCreateSuccess={() => {}} onCreateSuccess={() => {}}
canToggleNotifications
/> />
).find('Notifications'); ).find('Notifications');
wrapper.instance().toggleNotification(1, true, 'error'); wrapper.instance().toggleNotification(1, true, 'error');
@@ -94,6 +99,7 @@ describe('<Notifications />', () => {
onReadSuccess={() => {}} onReadSuccess={() => {}}
onCreateError={onCreateError} onCreateError={onCreateError}
onCreateSuccess={() => {}} onCreateSuccess={() => {}}
canToggleNotifications
/> />
).find('Notifications'); ).find('Notifications');
wrapper.setState({ errorTemplateIds: [44] }); wrapper.setState({ errorTemplateIds: [44] });
@@ -144,6 +150,7 @@ describe('<Notifications />', () => {
onReadSuccess={onReadSuccess} onReadSuccess={onReadSuccess}
onCreateError={() => {}} onCreateError={() => {}}
onCreateSuccess={() => {}} onCreateSuccess={() => {}}
canToggleNotifications
/> />
).find('Notifications'); ).find('Notifications');
wrapper.instance().updateUrl = jest.fn(); wrapper.instance().updateUrl = jest.fn();

View File

@@ -20,6 +20,7 @@ describe('<NotificationListItem />', () => {
toggleNotification={toggleNotification} toggleNotification={toggleNotification}
detailUrl="/foo" detailUrl="/foo"
notificationType="slack" notificationType="slack"
canToggleNotifications
/> />
); );
expect(wrapper.length).toBe(1); expect(wrapper.length).toBe(1);
@@ -33,6 +34,7 @@ describe('<NotificationListItem />', () => {
toggleNotification={toggleNotification} toggleNotification={toggleNotification}
detailUrl="/foo" detailUrl="/foo"
notificationType="slack" notificationType="slack"
canToggleNotifications
/> />
); );
wrapper.find('Switch').first().find('input').simulate('change'); wrapper.find('Switch').first().find('input').simulate('change');
@@ -47,6 +49,7 @@ describe('<NotificationListItem />', () => {
toggleNotification={toggleNotification} toggleNotification={toggleNotification}
detailUrl="/foo" detailUrl="/foo"
notificationType="slack" notificationType="slack"
canToggleNotifications
/> />
); );
wrapper.find('Switch').first().find('input').simulate('change'); wrapper.find('Switch').first().find('input').simulate('change');
@@ -61,6 +64,7 @@ describe('<NotificationListItem />', () => {
toggleNotification={toggleNotification} toggleNotification={toggleNotification}
detailUrl="/foo" detailUrl="/foo"
notificationType="slack" notificationType="slack"
canToggleNotifications
/> />
); );
wrapper.find('Switch').at(1).find('input').simulate('change'); wrapper.find('Switch').at(1).find('input').simulate('change');
@@ -75,6 +79,7 @@ describe('<NotificationListItem />', () => {
toggleNotification={toggleNotification} toggleNotification={toggleNotification}
detailUrl="/foo" detailUrl="/foo"
notificationType="slack" notificationType="slack"
canToggleNotifications
/> />
); );
wrapper.find('Switch').at(1).find('input').simulate('change'); wrapper.find('Switch').at(1).find('input').simulate('change');

View File

@@ -16,13 +16,31 @@ const mockData = [
role: { role: {
name: 'foo', name: 'foo',
id: 2, id: 2,
user_capabilities: {
unattach: true
}
} }
} }
], ]
} }
} }
]; ];
const organization = {
id: 1,
name: 'Default',
summary_fields: {
object_roles: {},
user_capabilities: {
edit: true
}
}
};
const api = {
foo: () => {}
};
describe('<OrganizationAccessList />', () => { describe('<OrganizationAccessList />', () => {
afterEach(() => { afterEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
@@ -33,7 +51,8 @@ describe('<OrganizationAccessList />', () => {
<OrganizationAccessList <OrganizationAccessList
getAccessList={() => {}} getAccessList={() => {}}
removeRole={() => {}} removeRole={() => {}}
/> organization={organization}
/>, { context: { network: { api } } }
); );
}); });
@@ -42,7 +61,8 @@ describe('<OrganizationAccessList />', () => {
<OrganizationAccessList <OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })} getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}} removeRole={() => {}}
/> organization={organization}
/>, { context: { network: { api } } }
).find('OrganizationAccessList'); ).find('OrganizationAccessList');
setImmediate(() => { setImmediate(() => {
@@ -57,7 +77,8 @@ describe('<OrganizationAccessList />', () => {
<OrganizationAccessList <OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })} getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}} removeRole={() => {}}
/> organization={organization}
/>, { context: { network: { api } } }
).find('OrganizationAccessList'); ).find('OrganizationAccessList');
expect(onSort).not.toHaveBeenCalled(); expect(onSort).not.toHaveBeenCalled();
@@ -74,7 +95,8 @@ describe('<OrganizationAccessList />', () => {
<OrganizationAccessList <OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })} getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}} removeRole={() => {}}
/> organization={organization}
/>, { context: { network: { api } } }
).find('OrganizationAccessList'); ).find('OrganizationAccessList');
setImmediate(() => { setImmediate(() => {
@@ -94,7 +116,8 @@ describe('<OrganizationAccessList />', () => {
<OrganizationAccessList <OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })} getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}} removeRole={() => {}}
/> organization={organization}
/>, { context: { network: { api } } }
).find('OrganizationAccessList'); ).find('OrganizationAccessList');
expect(handleWarning).not.toHaveBeenCalled(); expect(handleWarning).not.toHaveBeenCalled();
expect(confirmDelete).not.toHaveBeenCalled(); expect(confirmDelete).not.toHaveBeenCalled();
@@ -117,7 +140,8 @@ describe('<OrganizationAccessList />', () => {
<OrganizationAccessList <OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })} getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}} removeRole={() => {}}
/> organization={organization}
/>, { context: { network: { api } } }
).find('OrganizationAccessList'); ).find('OrganizationAccessList');
setImmediate(() => { setImmediate(() => {
@@ -146,4 +170,36 @@ describe('<OrganizationAccessList />', () => {
done(); done();
}); });
}); });
test('add role button visible for user that can edit org', () => {
const wrapper = mountWithContexts(
<OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}}
organization={organization}
/>, { context: { network: { api } } }
).find('OrganizationAccessList');
setImmediate(() => {
const addRole = wrapper.update().find('DataListToolbar').find('PlusIcon');
expect(addRole.length).toBe(1);
});
});
test('add role button hidden for user that cannot edit org', () => {
const readOnlyOrg = { ...organization };
readOnlyOrg.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(
<OrganizationAccessList
getAccessList={() => ({ data: { count: 1, results: mockData } })}
removeRole={() => {}}
organization={readOnlyOrg}
/>, { context: { network: { api } } }
).find('OrganizationAccessList');
setImmediate(() => {
const addRole = wrapper.update().find('DataListToolbar').find('PlusIcon');
expect(addRole.length).toBe(0);
});
});
}); });

View File

@@ -3,7 +3,21 @@ import { mountWithContexts } from '../../../../enzymeHelpers';
import Organization from '../../../../../src/pages/Organizations/screens/Organization/Organization'; import Organization from '../../../../../src/pages/Organizations/screens/Organization/Organization';
describe('<OrganizationView />', () => { describe('<OrganizationView />', () => {
const me = {
is_super_user: true,
is_system_auditor: false
};
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<Organization />); mountWithContexts(<Organization me={me} />);
});
test('notifications tab shown/hidden based on permissions', () => {
const wrapper = mountWithContexts(<Organization me={me} />);
expect(wrapper.find('.pf-c-tabs__item').length).toBe(3);
expect(wrapper.find('.pf-c-tabs__button[children="Notifications"]').length).toBe(0);
wrapper.find('Organization').setState({
isNotifAdmin: true
});
expect(wrapper.find('.pf-c-tabs__item').length).toBe(4);
expect(wrapper.find('.pf-c-tabs__button[children="Notifications"]').length).toBe(1);
}); });
}); });

View File

@@ -3,8 +3,12 @@ import { mountWithContexts } from '../../../../enzymeHelpers';
import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess'; import OrganizationAccess from '../../../../../src/pages/Organizations/screens/Organization/OrganizationAccess';
describe('<OrganizationAccess />', () => { describe('<OrganizationAccess />', () => {
const organization = {
id: 1,
name: 'Default'
};
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts(<OrganizationAccess />); mountWithContexts(<OrganizationAccess organization={organization} />);
}); });
test('passed methods as props are called appropriately', async () => { test('passed methods as props are called appropriately', async () => {
@@ -14,13 +18,14 @@ describe('<OrganizationAccess />', () => {
const mockResponse = { const mockResponse = {
status: 'success', status: 'success',
}; };
const wrapper = mountWithContexts(<OrganizationAccess />, { context: { network: { const wrapper = mountWithContexts(<OrganizationAccess organization={organization} />,
api: { { context: { network: {
getOrganizationAccessList: () => Promise.resolve(mockAPIAccessList), api: {
disassociate: () => Promise.resolve(mockResponse) getOrganizationAccessList: () => Promise.resolve(mockAPIAccessList),
}, disassociate: () => Promise.resolve(mockResponse)
handleHttpError: () => {} },
} } }).find('OrganizationAccess'); handleHttpError: () => {}
} } }).find('OrganizationAccess');
const accessList = await wrapper.instance().getOrgAccessList(); const accessList = await wrapper.instance().getOrgAccessList();
expect(accessList).toEqual(mockAPIAccessList); expect(accessList).toEqual(mockAPIAccessList);
const resp = await wrapper.instance().removeRole(2, 3, 'users'); const resp = await wrapper.instance().removeRole(2, 3, 'users');

View File

@@ -8,7 +8,12 @@ describe('<OrganizationDetail />', () => {
description: 'Bar', description: 'Bar',
custom_virtualenv: 'Fizz', custom_virtualenv: 'Fizz',
created: 'Bat', created: 'Bat',
modified: 'Boo' modified: 'Boo',
summary_fields: {
user_capabilities: {
edit: true
}
}
}; };
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
@@ -83,4 +88,28 @@ describe('<OrganizationDetail />', () => {
expect(modifiedDetail.find('h6').text()).toBe('Last Modified'); expect(modifiedDetail.find('h6').text()).toBe('Last Modified');
expect(modifiedDetail.find('p').text()).toBe('Boo'); expect(modifiedDetail.find('p').text()).toBe('Boo');
}); });
test('should show edit button for users with edit permission', () => {
const wrapper = mountWithContexts(
<OrganizationDetail
organization={mockDetails}
/>
).find('OrganizationDetail');
const editLink = wrapper.findWhere(node => node.props().to === '/organizations/undefined/edit');
expect(editLink.length).toBe(1);
});
test('should hide edit button for users without edit permission', () => {
const readOnlyOrg = { ...mockDetails };
readOnlyOrg.summary_fields.user_capabilities.edit = false;
const wrapper = mountWithContexts(
<OrganizationDetail
organization={readOnlyOrg}
/>
).find('OrganizationDetail');
const editLink = wrapper.findWhere(node => node.props().to === '/organizations/undefined/edit');
expect(editLink.length).toBe(0);
});
}); });

View File

@@ -18,7 +18,7 @@ describe('<OrganizationNotifications />', () => {
test('initially renders succesfully', () => { test('initially renders succesfully', () => {
mountWithContexts( mountWithContexts(
<OrganizationNotifications />, { context: { network: { <OrganizationNotifications canToggleNotifications />, { context: { network: {
api, api,
handleHttpError: () => {} handleHttpError: () => {}
} } } } } }
@@ -26,7 +26,7 @@ describe('<OrganizationNotifications />', () => {
}); });
test('handles api requests', () => { test('handles api requests', () => {
const wrapper = mountWithContexts( const wrapper = mountWithContexts(
<OrganizationNotifications />, { context: { network: { <OrganizationNotifications canToggleNotifications />, { context: { network: {
api, api,
handleHttpError: () => {} handleHttpError: () => {}
} } } } } }

View File

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

View File

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

View File

@@ -74,7 +74,10 @@
padding: 0px; padding: 0px;
margin: 0px; margin: 0px;
margin-right: 20px; 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 { .awx-toolbar .pf-l-toolbar__item .pf-c-button.pf-m-plain {

View File

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

View File

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

View File

@@ -16,11 +16,14 @@ class Provider extends Component {
version: null, version: null,
custom_logo: null, custom_logo: null,
custom_login_info: null, custom_login_info: null,
me: {},
...props.value ...props.value
} }
}; };
this.fetchConfig = this.fetchConfig.bind(this); this.fetchConfig = this.fetchConfig.bind(this);
this.fetchMe = this.fetchMe.bind(this);
this.updateConfig = this.updateConfig.bind(this);
} }
componentDidMount () { componentDidMount () {
@@ -30,30 +33,64 @@ 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 () { async fetchConfig () {
const { api, handleHttpError } = this.props; const { api, handleHttpError } = this.props;
try { try {
const { const [configRes, rootRes, meRes] = await Promise.all([
data: { api.getConfig(),
ansible_version, api.getRoot(),
custom_virtualenvs, api.getMe()
version ]);
}
} = await api.getConfig();
const {
data: {
custom_logo,
custom_login_info
}
} = await api.getRoot();
this.setState({ this.setState({
value: { value: {
ansible_version, ansible_version: configRes.data.ansible_version,
custom_virtualenvs, custom_virtualenvs: configRes.data.custom_virtualenvs,
version, version: configRes.data.version,
custom_logo, custom_logo: rootRes.data.custom_logo,
custom_login_info custom_login_info: rootRes.data.custom_login_info,
me: meRes.data.results
} }
}); });
} catch (err) { } catch (err) {
@@ -63,7 +100,8 @@ class Provider extends Component {
custom_virtualenvs: null, custom_virtualenvs: null,
version: null, version: null,
custom_logo: null, custom_logo: null,
custom_login_info: null custom_login_info: null,
me: {}
} }
}); });
} }
@@ -75,7 +113,13 @@ class Provider extends Component {
const { children } = this.props; const { children } = this.props;
return ( return (
<ConfigContext.Provider value={value}> <ConfigContext.Provider
value={{
...value,
fetchMe: this.fetchMe,
updateConfig: this.updateConfig
}}
>
{children} {children}
</ConfigContext.Provider> </ConfigContext.Provider>
); );

View File

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

View File

@@ -39,7 +39,7 @@ class AWXLogin extends Component {
async onLoginButtonClick (event) { async onLoginButtonClick (event) {
const { username, password, isLoading } = this.state; const { username, password, isLoading } = this.state;
const { api, handleHttpError, clearRootDialogMessage } = this.props; const { api, handleHttpError, clearRootDialogMessage, fetchMe, updateConfig } = this.props;
event.preventDefault(); event.preventDefault();
@@ -51,7 +51,9 @@ class AWXLogin extends Component {
this.setState({ isLoading: true }); this.setState({ isLoading: true });
try { try {
await api.login(username, password); const { data } = await api.login(username, password);
updateConfig(data);
await fetchMe();
this.setState({ isAuthenticated: true, isLoading: false }); this.setState({ isAuthenticated: true, isLoading: false });
} catch (error) { } catch (error) {
handleHttpError(error) || this.setState({ isInputValid: false, isLoading: false }); 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 { i18nMark } from '@lingui/react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { Config } from '../../contexts/Config';
import { NetworkProvider } from '../../contexts/Network'; import { NetworkProvider } from '../../contexts/Network';
import { withRootDialog } from '../../contexts/RootDialog'; import { withRootDialog } from '../../contexts/RootDialog';
@@ -74,11 +75,16 @@ class Organizations extends Component {
}); });
}} }}
> >
<Organization <Config>
history={history} {({ me }) => (
location={location} <Organization
setBreadcrumb={this.setBreadcrumbConfig} history={history}
/> location={location}
setBreadcrumb={this.setBreadcrumbConfig}
me={me || {}}
/>
)}
</Config>
</NetworkProvider> </NetworkProvider>
)} )}
/> />

View File

@@ -21,6 +21,7 @@ import {
import { withNetwork } from '../../../contexts/Network'; import { withNetwork } from '../../../contexts/Network';
import AlertModal from '../../../components/AlertModal'; import AlertModal from '../../../components/AlertModal';
import BasicChip from '../../../components/BasicChip/BasicChip';
import Pagination from '../../../components/Pagination'; import Pagination from '../../../components/Pagination';
import DataListToolbar from '../../../components/DataListToolbar'; import DataListToolbar from '../../../components/DataListToolbar';
import AddResourceRole from '../../../components/AddRole/AddResourceRole'; import AddResourceRole from '../../../components/AddRole/AddResourceRole';
@@ -357,6 +358,7 @@ class OrganizationAccessList extends React.Component {
columns={this.columns} columns={this.columns}
onSearch={() => { }} onSearch={() => { }}
onSort={this.onSort} onSort={this.onSort}
showAdd={organization.summary_fields.user_capabilities.edit}
add={( add={(
<Fragment> <Fragment>
<Button <Button
@@ -421,13 +423,21 @@ class OrganizationAccessList extends React.Component {
<ul style={userRolesWrapperStyle}> <ul style={userRolesWrapperStyle}>
<Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`User Roles`)}</Text> <Text component={TextVariants.h6} style={detailLabelStyle}>{i18n._(t`User Roles`)}</Text>
{result.userRoles.map(role => ( {result.userRoles.map(role => (
<Chip role.user_capabilities.unattach ? (
key={role.id} <Chip
className="awx-c-chip" key={role.id}
onClick={() => this.handleWarning(role.name, role.id, result.username, result.id, 'users')} className="awx-c-chip"
> onClick={() => this.handleWarning(role.name, role.id, result.username, result.id, 'users')}
{role.name} >
</Chip> {role.name}
</Chip>
) : (
<BasicChip
key={role.id}
>
{role.name}
</BasicChip>
)
))} ))}
</ul> </ul>
)} )}
@@ -466,7 +476,9 @@ class OrganizationAccessList extends React.Component {
} }
OrganizationAccessList.propTypes = { OrganizationAccessList.propTypes = {
api: PropTypes.shape().isRequired,
getAccessList: PropTypes.func.isRequired, getAccessList: PropTypes.func.isRequired,
organization: PropTypes.shape().isRequired,
removeRole: PropTypes.func.isRequired removeRole: PropTypes.func.isRequired
}; };

View File

@@ -33,13 +33,17 @@ class Organization extends Component {
organization: null, organization: null,
error: false, error: false,
loading: true, loading: true,
isNotifAdmin: false,
isAuditorOfThisOrg: false,
isAdminOfThisOrg: false
}; };
this.fetchOrganization = this.fetchOrganization.bind(this); this.fetchOrganization = this.fetchOrganization.bind(this);
this.fetchOrganizationAndRoles = this.fetchOrganizationAndRoles.bind(this);
} }
componentDidMount () { componentDidMount () {
this.fetchOrganization(); this.fetchOrganizationAndRoles();
} }
async componentDidUpdate (prevProps) { 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 () { async fetchOrganization () {
const { const {
match, match,
@@ -70,19 +111,44 @@ class Organization extends Component {
const { const {
location, location,
match, match,
me,
history history
} = this.props; } = this.props;
const { const {
organization, organization,
error, error,
loading loading,
isNotifAdmin,
isAuditorOfThisOrg,
isAdminOfThisOrg
} = this.state; } = this.state;
const tabsPaddingOverride = { const tabsPaddingOverride = {
padding: '0' padding: '0'
}; };
const canSeeNotificationsTab = me.is_system_auditor || isNotifAdmin || isAuditorOfThisOrg;
const canToggleNotifications = isNotifAdmin && (
me.is_system_auditor
|| isAuditorOfThisOrg
|| isAdminOfThisOrg
);
const tabsArray = [
{ name: i18nMark('Details'), link: `${match.url}/details`, id: 0 },
{ name: i18nMark('Access'), link: `${match.url}/access`, id: 1 },
{ name: i18nMark('Teams'), link: `${match.url}/teams`, id: 2 }
];
if (canSeeNotificationsTab) {
tabsArray.push({
name: i18nMark('Notifications'),
link: `${match.url}/notifications`,
id: 3
});
}
let cardHeader = ( let cardHeader = (
loading ? '' loading ? ''
: ( : (
@@ -96,12 +162,7 @@ class Organization extends Component {
match={match} match={match}
history={history} history={history}
labeltext={i18n._(t`Organization detail tabs`)} labeltext={i18n._(t`Organization detail tabs`)}
tabsArray={[ tabsArray={tabsArray}
{ name: i18nMark('Details'), link: `${match.url}/details`, id: 0 },
{ name: i18nMark('Access'), link: `${match.url}/access`, id: 1 },
{ name: i18nMark('Teams'), link: `${match.url}/teams`, id: 2 },
{ name: i18nMark('Notifications'), link: `${match.url}/notifications`, id: 3 },
]}
/> />
<Link <Link
aria-label="Close" aria-label="Close"
@@ -174,16 +235,16 @@ class Organization extends Component {
/> />
)} )}
/> />
<Route {canSeeNotificationsTab && (
path="/organizations/:id/notifications" <Route
render={() => ( path="/organizations/:id/notifications"
<OrganizationNotifications render={() => (
match={match} <OrganizationNotifications
location={location} canToggleNotifications={canToggleNotifications}
history={history} />
/> )}
)} />
/> )}
{organization && <NotifyAndRedirect to={`/organizations/${match.params.id}/details`} />} {organization && <NotifyAndRedirect to={`/organizations/${match.params.id}/details`} />}
</Switch> </Switch>
{error ? 'error!' : ''} {error ? 'error!' : ''}

View File

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

View File

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

View File

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