diff --git a/test/functional/services/dashboard/add_panel.js b/test/functional/services/dashboard/add_panel.js index 64df0bf612e17..c0f571125d6ea 100644 --- a/test/functional/services/dashboard/add_panel.js +++ b/test/functional/services/dashboard/add_panel.js @@ -164,6 +164,8 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { async addVisualization(vizName) { log.debug(`DashboardAddPanel.addVisualization(${vizName})`); await this.ensureAddPanelIsShowing(); + // workaround for timing issue with slideout animation + await PageObjects.common.sleep(500); await this.filterEmbeddableNames(`"${vizName.replace('-', ' ')}"`); await testSubjects.click(`addPanel${vizName.split(' ').join('-')}`); await this.closeAddPanel(); diff --git a/test/functional/services/test_subjects.js b/test/functional/services/test_subjects.js index f40600e71128b..9a614a1acbdc2 100644 --- a/test/functional/services/test_subjects.js +++ b/test/functional/services/test_subjects.js @@ -101,9 +101,7 @@ export function TestSubjectsProvider({ getService }) { async setValue(selector, text) { return await retry.try(async () => { - const element = await this.find(selector); - await element.click(); - + await this.click(selector); // in case the input element is actually a child of the testSubject, we // call clearValue() and type() on the element that is focused after // clicking on the testSubject diff --git a/x-pack/plugins/security/public/components/management/users/confirm_delete.js b/x-pack/plugins/security/public/components/management/users/confirm_delete.js new file mode 100644 index 0000000000000..53d9bd3bed127 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/users/confirm_delete.js @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +export class ConfirmDelete extends Component { + deleteUsers = () => { + const { usersToDelete, apiClient, callback } = this.props; + const errors = []; + usersToDelete.forEach(async username => { + try { + await apiClient.deleteUser(username); + toastNotifications.addSuccess(`Deleted user ${username}`); + } catch (e) { + errors.push(username); + toastNotifications.addDanger(`Error deleting user ${username}`); + } + if (callback) { + callback(usersToDelete, errors); + } + }); + }; + render() { + const { usersToDelete, onCancel } = this.props; + const moreThanOne = usersToDelete.length > 1; + const title = moreThanOne + ? `Delete ${usersToDelete.length} users` + : `Delete user '${usersToDelete[0]}'`; + return ( + + +
+ {moreThanOne ? ( + +

+ You are about to delete these users: +

+
    {usersToDelete.map(username =>
  • {username}
  • )}
+
+ ) : null} +

This operation cannot be undone.

+
+
+
+ ); + } +} diff --git a/x-pack/plugins/security/public/components/management/users/edit_user.js b/x-pack/plugins/security/public/components/management/users/edit_user.js new file mode 100644 index 0000000000000..390172fa6db32 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/users/edit_user.js @@ -0,0 +1,492 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint camelcase: 0 */ +import React, { Component, Fragment } from 'react'; +import { + EuiButton, + EuiCallOut, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiTitle, + EuiForm, + EuiFormRow, + EuiIcon, + EuiText, + EuiFieldText, + EuiPage, + EuiComboBox, + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageContentBody, + EuiHorizontalRule, + EuiSpacer, +} from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { USERS_PATH } from '../../../views/management/management_urls'; +import { ConfirmDelete } from './confirm_delete'; +const validEmailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; //eslint-disable-line max-len +const validUsernameRegex = /[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*/; +export class EditUser extends Component { + constructor(props) { + super(props); + this.state = { + isNewUser: true, + currentUser: {}, + showDeleteConfirmation: false, + user: { + email: null, + username: null, + full_name: null, + roles: [], + }, + roles: [], + selectedRoles: [], + password: null, + confirmPassword: null, + }; + } + async componentDidMount() { + const { apiClient, username } = this.props; + let { user, currentUser } = this.state; + if (username) { + user = await apiClient.getUser(username); + currentUser = await apiClient.getCurrentUser(); + } + const roles = await apiClient.getRoles(); + this.setState({ + isNewUser: !username, + currentUser, + user, + roles, + selectedRoles: user.roles.map(role => ({ label: role })) || [], + }); + } + handleDelete = (usernames, errors) => { + if (errors.length === 0) { + const { changeUrl } = this.props; + changeUrl(USERS_PATH); + } + }; + passwordError = () => { + const { password } = this.state; + if (password !== null && password.length < 6) { + return 'Password must be at least 6 characters'; + } + }; + currentPasswordError = () => { + const { currentPasswordError } = this.state; + if (currentPasswordError) { + return 'The current password you entered is incorrect'; + } + }; + confirmPasswordError = () => { + const { password, confirmPassword } = this.state; + if (password && confirmPassword !== null && password !== confirmPassword) { + return 'Passwords do not match'; + } + }; + usernameError = () => { + const { username } = this.state.user; + if (username !== null && !username) { + return 'Username is required'; + } else if (username && !username.match(validUsernameRegex)) { + return 'Username must begin with a letter or underscore and contain only letters, underscores, and numbers'; + } + }; + fullnameError = () => { + const { full_name } = this.state.user; + if (full_name !== null && !full_name) { + return 'Full name is required'; + } + }; + emailError = () => { + const { email } = this.state.user; + if (email !== null && (!email || !email.match(validEmailRegex))) { + return 'A valid email address is required'; + } + }; + changePassword = async () => { + const { apiClient } = this.props; + const { user, password, currentPassword } = this.state; + try { + await apiClient.changePassword(user.username, password, currentPassword); + toastNotifications.addSuccess('Password changed.'); + } catch (e) { + if (e.status === 401) { + return this.setState({ currentPasswordError: true }); + } else { + toastNotifications.addDanger(`Error setting password: ${e.data.message}`); + } + } + this.clearPasswordForm(); + }; + saveUser = async () => { + const { apiClient, changeUrl } = this.props; + const { user, password, selectedRoles } = this.state; + const userToSave = { ...user }; + userToSave.roles = selectedRoles.map(selectedRole => { + return selectedRole.label; + }); + if (password) { + userToSave.password = password; + } + try { + await apiClient.saveUser(userToSave); + toastNotifications.addSuccess(`Saved user ${user.username}`); + changeUrl(USERS_PATH); + } catch (e) { + toastNotifications.addDanger(`Error saving user: ${e.data.message}`); + } + }; + clearPasswordForm = () => { + this.setState({ + showChangePasswordForm: false, + password: null, + confirmPassword: null, + }); + }; + passwordFields = () => { + const { user, currentUser } = this.state; + const userIsLoggedInUser = user.username && user.username === currentUser.username; + return ( + + {userIsLoggedInUser ? ( + + this.setState({ currentPassword: event.target.value })} + /> + + ) : null} + + this.setState({ password: event.target.value })} + onBlur={event => this.setState({ password: event.target.value || '' })} + /> + + + this.setState({ confirmPassword: event.target.value })} + onBlur={event => this.setState({ confirmPassword: event.target.value || '' })} + name="confirm_password" + type="password" + /> + + + ); + }; + changePasswordForm = () => { + const { + showChangePasswordForm, + password, + confirmPassword, + user: { username }, + } = this.state; + if (!showChangePasswordForm) { + return null; + } + return ( + + + {this.passwordFields()} + {username === 'kibana' ? ( + + +

+ After you change the password for the kibana user, you must update the kibana.yml + file and restart Kibana. +

+
+ +
+ ) : null} + + + { + this.changePassword(password); + }} + > + Save password + + + + { + this.clearPasswordForm(); + }} + > + Cancel + + + +
+ ); + }; + toggleChangePasswordForm = () => { + const { showChangePasswordForm } = this.state; + this.setState({ showChangePasswordForm: !showChangePasswordForm }); + }; + onRolesChange = selectedRoles => { + this.setState({ + selectedRoles, + }); + }; + cannotSaveUser = () => { + const { user, isNewUser } = this.state; + return ( + !user.username || + !user.full_name || + !user.email || + this.emailError() || + (isNewUser && (this.passwordError() || this.confirmPasswordError())) + ); + }; + onCancelDelete = () => { + this.setState({ showDeleteConfirmation: false }); + }; + render() { + const { changeUrl, apiClient } = this.props; + const { + user, + roles, + selectedRoles, + showChangePasswordForm, + isNewUser, + showDeleteConfirmation, + } = this.state; + const reserved = user.metadata && user.metadata._reserved; + if (!user || !roles) { + return null; + } + return ( + + + + + + +

{isNewUser ? 'New user' : `Edit "${user.username}" user`}

+
+
+ {reserved && ( + + + + )} +
+ + {reserved && ( + +

+ Reserved users are built-in and cannot be removed or modified. Only the password + may be changed. +

+
+ )} + + {showDeleteConfirmation ? ( + + ) : null} + +
{ + event.preventDefault(); + }} + > + + + + this.setState({ + user: { + ...this.state.user, + username: event.target.value || '', + }, + }) + } + value={user.username || ''} + name="username" + data-test-subj="userFormUserNameInput" + disabled={!isNewUser} + onChange={event => { + this.setState({ + user: { ...this.state.user, username: event.target.value }, + }); + }} + /> + + {isNewUser ? this.passwordFields() : null} + {reserved ? null : ( + + + + this.setState({ + user: { + ...this.state.user, + full_name: event.target.value || '', + }, + }) + } + data-test-subj="userFormFullNameInput" + name="full_name" + value={user.full_name || ''} + onChange={event => { + this.setState({ + user: { + ...this.state.user, + full_name: event.target.value, + }, + }); + }} + /> + + + + this.setState({ + user: { + ...this.state.user, + email: event.target.value || '', + }, + }) + } + data-test-subj="userFormEmailInput" + name="email" + value={user.email || ''} + onChange={event => { + this.setState({ + user: { + ...this.state.user, + email: event.target.value, + }, + }); + }} + /> + + + )} + + { + return { 'data-test-subj': `roleOption-${role.name}`, label: role.name }; + })} + selectedOptions={selectedRoles} + /> + + + {isNewUser || showChangePasswordForm ? null : ( + + Change password + + )} + {this.changePasswordForm()} + + + + {reserved && ( + changeUrl(USERS_PATH)}>Return to user list + )} + {reserved ? null : ( + + + this.saveUser()} + > + {isNewUser ? 'Create user' : 'Update user'} + + + + changeUrl(USERS_PATH)} + > + Cancel + + + + {isNewUser || reserved ? null : ( + + { + this.setState({ showDeleteConfirmation: true }); + }} + data-test-subj="userFormDeleteButton" + color="danger" + > + Delete user + + + )} + + )} + +
+
+
+
+
+ ); + } +} diff --git a/x-pack/plugins/security/public/components/management/users/index.js b/x-pack/plugins/security/public/components/management/users/index.js new file mode 100644 index 0000000000000..4374ff1b9d097 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/users/index.js @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/*! Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one or more contributor license agreements. + * Licensed under the Elastic License; you may not use this file except in compliance with the Elastic License. */ + +export { Users } from './users'; +export { EditUser } from './edit_user'; diff --git a/x-pack/plugins/security/public/components/management/users/users.js b/x-pack/plugins/security/public/components/management/users/users.js new file mode 100644 index 0000000000000..2a14b99aef975 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/users/users.js @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Component, Fragment } from 'react'; +import { + EuiButton, + EuiIcon, + EuiLink, + EuiInMemoryTable, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiTitle, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiPageContentBody, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { toastNotifications } from 'ui/notify'; +import { ConfirmDelete } from './confirm_delete'; + +export class Users extends Component { + constructor(props) { + super(props); + this.state = { + users: [], + selection: [], + showDeleteConfirmation: false, + }; + } + componentDidMount() { + this.loadUsers(); + } + handleDelete = (usernames, errors) => { + const { users } = this.state; + this.setState({ + selection: [], + showDeleteConfirmation: false, + users: users.filter(({ username }) => { + return !usernames.includes(username) || errors.includes(username); + }), + }); + }; + async loadUsers() { + const { apiClient } = this.props; + try { + const users = await apiClient.getUsers(); + this.setState({ users }); + } catch (e) { + if (e.status === 403) { + this.setState({ permissionDenied: true }); + } else { + toastNotifications.addDanger(`Error fetching users: ${e.data.message}`); + } + } + } + renderToolsLeft() { + const { selection } = this.state; + if (selection.length === 0) { + return; + } + const numSelected = selection.length; + return ( + this.setState({ showDeleteConfirmation: true })} + > + Delete {numSelected} user{numSelected > 1 ? 's' : ''} + + ); + } + onCancelDelete = () => { + this.setState({ showDeleteConfirmation: false }); + } + render() { + const { users, filter, permissionDenied, showDeleteConfirmation, selection } = this.state; + const { apiClient } = this.props; + if (permissionDenied) { + return ( + + + + Permission denied} + body={

You do not have permission to manage users.

} + /> +
+
+
+ ); + } + const path = '#/management/security/'; + const columns = [ + { + field: 'full_name', + name: 'Full Name', + sortable: true, + truncateText: true, + render: fullName => { + return
{fullName}
; + }, + }, + { + field: 'username', + name: 'User Name', + sortable: true, + truncateText: true, + render: username => ( + + {username} + + ), + }, + { + field: 'email', + name: 'Email Address', + sortable: true, + truncateText: true, + }, + { + field: 'roles', + name: 'Roles', + render: rolenames => { + const roleLinks = rolenames.map((rolename, index) => { + return ( + + {rolename} + {index === rolenames.length - 1 ? null : ', '} + + ); + }); + return
{roleLinks}
; + }, + }, + { + field: 'metadata._reserved', + name: 'Reserved', + sortable: false, + width: '100px', + align: 'right', + description: + 'Reserved users are built-in and cannot be removed. Only the password can be changed.', + render: reserved => + reserved ? ( + + ) : null, + }, + ]; + const pagination = { + initialPageSize: 20, + pageSizeOptions: [10, 20, 50, 100], + }; + + const selectionConfig = { + itemId: 'username', + selectable: user => !user.metadata._reserved, + selectableMessage: selectable => (!selectable ? 'User is a system user' : undefined), + onSelectionChange: selection => this.setState({ selection }), + }; + const search = { + toolsLeft: this.renderToolsLeft(), + box: { + incremental: true, + }, + onChange: query => { + this.setState({ + filter: query.queryText, + }); + }, + }; + const sorting = { + sort: { + field: 'full_name', + direction: 'asc', + }, + }; + const rowProps = () => { + return { + 'data-test-subj': 'userRow', + }; + }; + const usersToShow = filter + ? users.filter(({ username, roles }) => { + const normalized = `${username} ${roles.join(' ')}`.toLowerCase(); + const normalizedQuery = filter.toLowerCase(); + return normalized.indexOf(normalizedQuery) !== -1; + }) : users; + return ( + + + + + + +

Users

+
+
+ + + Create new user + + +
+ + + {showDeleteConfirmation ? ( + user.username)} + callback={this.handleDelete} + /> + ) : null} + + + + +
+
+
+ ); + } +} diff --git a/x-pack/plugins/security/public/lib/api.js b/x-pack/plugins/security/public/lib/api.js new file mode 100644 index 0000000000000..908a0ceaf3103 --- /dev/null +++ b/x-pack/plugins/security/public/lib/api.js @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import chrome from 'ui/chrome'; + +const usersUrl = chrome.addBasePath('/api/security/v1/users'); +const rolesUrl = chrome.addBasePath('/api/security/v1/roles'); + +export const createApiClient = (httpClient) => { + return { + async getCurrentUser() { + const url = chrome.addBasePath('/api/security/v1/me'); + const { data } = await httpClient.get(url); + return data; + }, + async getUsers() { + const { data } = await httpClient.get(usersUrl); + return data; + }, + async getUser(username) { + const url = `${usersUrl}/${username}`; + const { data } = await httpClient.get(url); + return data; + }, + async deleteUser(username) { + const url = `${usersUrl}/${username}`; + await httpClient.delete(url); + }, + async saveUser(user) { + const url = `${usersUrl}/${user.username}`; + await httpClient.post(url, user); + }, + async getRoles() { + const { data } = await httpClient.get(rolesUrl); + return data; + }, + async getRole(name) { + const url = `${rolesUrl}/${name}`; + const { data } = await httpClient.get(url); + return data; + }, + async changePassword(username, password, currentPassword) { + const data = { + newPassword: password, + }; + if (currentPassword) { + data.password = currentPassword; + } + await httpClient + .post(`${usersUrl}/${username}/password`, data); + } + }; +}; diff --git a/x-pack/plugins/security/public/views/management/edit_user.html b/x-pack/plugins/security/public/views/management/edit_user.html index be4ef416ce84c..b74b08b0e4ae2 100644 --- a/x-pack/plugins/security/public/views/management/edit_user.html +++ b/x-pack/plugins/security/public/views/management/edit_user.html @@ -1,184 +1,3 @@ -
- -
-
- -

- - Add user - - - “{{ user.username }}” User - -

-
- -
- - -
- - Reserved -
-
-
- - -
- -
- - - - -
- Username must begin with a letter or underscore and contain only letters, underscores, and numbers -
-
- Username is required -
-
- - - - - -
- - - - -
- Full name is required -
-
- - -
- - - - -
- Email is required -
-
- - -
- - - - {{$item}} - - -
-
-
-
-
- - -
- - - - Cancel - -
- - - -
+
diff --git a/x-pack/plugins/security/public/views/management/edit_user.js b/x-pack/plugins/security/public/views/management/edit_user.js index abe421405c2c1..5ba229605e4c7 100644 --- a/x-pack/plugins/security/public/views/management/edit_user.js +++ b/x-pack/plugins/security/public/views/management/edit_user.js @@ -3,101 +3,47 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import _ from 'lodash'; import routes from 'ui/routes'; -import { fatalError, toastNotifications } from 'ui/notify'; import template from 'plugins/security/views/management/edit_user.html'; import 'angular-resource'; import 'angular-ui-select'; import 'plugins/security/services/shield_user'; import 'plugins/security/services/shield_role'; -import { checkLicenseError } from 'plugins/security/lib/check_license_error'; -import { EDIT_USERS_PATH, USERS_PATH } from './management_urls'; +import { EDIT_USERS_PATH } from './management_urls'; +import { EditUser } from '../../components/management/users'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { createApiClient } from '../../lib/api'; + +const renderReact = (elem, httpClient, changeUrl, username) => { + render( + , + elem + ); +}; routes.when(`${EDIT_USERS_PATH}/:username?`, { template, - resolve: { - me(ShieldUser) { - return ShieldUser.getCurrent(); - }, - - user($route, ShieldUser, kbnUrl, Promise) { - const username = $route.current.params.username; - if (username != null) { - return ShieldUser.get({ username }).$promise - .catch((response) => { - if (response.status !== 404) { - return fatalError(response); - } - - toastNotifications.addDanger(`No "${username}" user found.`); - kbnUrl.redirect(USERS_PATH); - return Promise.halt(); - }); - } - return new ShieldUser({ roles: [] }); - }, - - roles(ShieldRole, kbnUrl, Promise, Private) { - // $promise is used here because the result is an ngResource, not a promise itself - return ShieldRole.query().$promise - .then((roles) => _.map(roles, 'name')) - .catch(checkLicenseError(kbnUrl, Promise, Private)); - } - }, controllerAs: 'editUser', - controller($scope, $route, kbnUrl, Notifier, confirmModal) { - $scope.me = $route.current.locals.me; - $scope.user = $route.current.locals.user; - $scope.availableRoles = $route.current.locals.roles; - $scope.usersHref = `#${USERS_PATH}`; - - this.isNewUser = $route.current.params.username == null; - - $scope.deleteUser = (user) => { - const doDelete = () => { - user.$delete() - .then(() => toastNotifications.addSuccess('Deleted user')) - .then($scope.goToUserList) - .catch(error => toastNotifications.addDanger(_.get(error, 'data.message'))); - }; - const confirmModalOptions = { - confirmButtonText: 'Delete user', - onConfirm: doDelete - }; - confirmModal('Are you sure you want to delete this user? This action is irreversible!', confirmModalOptions); - }; - - $scope.saveUser = (user) => { - // newPassword is unexepcted by the API. - delete user.newPassword; - user.$save() - .then(() => toastNotifications.addSuccess('User updated')) - .then($scope.goToUserList) - .catch(error => toastNotifications.addDanger(_.get(error, 'data.message'))); - }; - - $scope.goToUserList = () => { - kbnUrl.redirect(USERS_PATH); - }; - - $scope.saveNewPassword = (newPassword, currentPassword, onSuccess, onIncorrectPassword) => { - $scope.user.newPassword = newPassword; - if (currentPassword) { - // If the currentPassword is null, we shouldn't send it. - $scope.user.password = currentPassword; + controller($scope, $route, kbnUrl, Notifier, confirmModal, $http) { + $scope.$on('$destroy', () => { + const elem = document.getElementById('editUserReactRoot'); + if (elem) { + unmountComponentAtNode(elem); } - - $scope.user.$changePassword() - .then(() => toastNotifications.addSuccess('Password updated')) - .then(onSuccess) - .catch(error => { - if (error.status === 401) { - onIncorrectPassword(); - } - else toastNotifications.addDanger(_.get(error, 'data.message')); - }); - }; - } + }); + $scope.$$postDigest(() => { + const elem = document.getElementById('editUserReactRoot'); + const username = $route.current.params.username; + const changeUrl = (url) => { + kbnUrl.change(url); + $scope.$apply(); + }; + renderReact(elem, $http, changeUrl, username); + }); + }, }); diff --git a/x-pack/plugins/security/public/views/management/management.less b/x-pack/plugins/security/public/views/management/management.less index d9e16650aeecc..046f4b7eb8c53 100644 --- a/x-pack/plugins/security/public/views/management/management.less +++ b/x-pack/plugins/security/public/views/management/management.less @@ -10,3 +10,19 @@ margin-left: 10px; } } + +.mgtUsersEditPage, +.mgtUsersListingPage { + min-height: ~"calc(100vh - 70px)"; +} + +.mgtUsersListingPage__content { + flex-grow: 0; +} + +.mgtUsersEditPage__content { + max-width: 460px; + margin-left: auto; + margin-right: auto; + flex-grow: 0; +} diff --git a/x-pack/plugins/security/public/views/management/users.html b/x-pack/plugins/security/public/views/management/users.html index 3ab259faf9c02..83efad0ae6a69 100644 --- a/x-pack/plugins/security/public/views/management/users.html +++ b/x-pack/plugins/security/public/views/management/users.html @@ -1,251 +1,3 @@ -
-
-
- - - You do not have permission to manage users. - -
- -
-
- Please contact your administrator. -
-
-
- -
- -
- -
-
-
- - -
-
- -
- - - - - - - - Add user - - -
- -
- -
-
- - -
-
- No users match your search criteria -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - - - - - - Reserved - - -
-
- -
-
- - - - -
- - - {{ role }} - - , - -
-
-
-
-
-
- - -
-
-
- {{ selectedUsers.length }} users selected -
-
-
- -
-
-
-
-
+
diff --git a/x-pack/plugins/security/public/views/management/users.js b/x-pack/plugins/security/public/views/management/users.js index 99351f1639c63..fdbb115751907 100644 --- a/x-pack/plugins/security/public/views/management/users.js +++ b/x-pack/plugins/security/public/views/management/users.js @@ -4,89 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; import routes from 'ui/routes'; -import { toastNotifications } from 'ui/notify'; -import { toggle, toggleSort } from 'plugins/security/lib/util'; import template from 'plugins/security/views/management/users.html'; import 'plugins/security/services/shield_user'; -import { checkLicenseError } from 'plugins/security/lib/check_license_error'; -import { SECURITY_PATH, USERS_PATH, EDIT_USERS_PATH, EDIT_ROLES_PATH } from './management_urls'; - +import { SECURITY_PATH, USERS_PATH } from './management_urls'; +import { Users } from '../../components/management/users'; +import { createApiClient } from '../../lib/api'; routes.when(SECURITY_PATH, { - redirectTo: USERS_PATH + redirectTo: USERS_PATH, }); +const renderReact = (elem, httpClient, changeUrl) => { + render(, elem); +}; + routes.when(USERS_PATH, { template, - resolve: { - users(ShieldUser, kbnUrl, Promise, Private) { - // $promise is used here because the result is an ngResource, not a promise itself - return ShieldUser.query().$promise - .catch(checkLicenseError(kbnUrl, Promise, Private)) - .catch(_.identity); // Return the error if there is one - } - }, - - controller($scope, $route, $q, confirmModal) { - $scope.users = $route.current.locals.users; - $scope.forbidden = !_.isArray($scope.users); - $scope.selectedUsers = []; - $scope.sort = { orderBy: 'full_name', reverse: false }; - $scope.editUsersHref = `#${EDIT_USERS_PATH}`; - $scope.getEditUrlHref = (user) => `#${EDIT_USERS_PATH}/${user}`; - $scope.getEditRoleHref = (role) => `#${EDIT_ROLES_PATH}/${role}`; - - $scope.deleteUsers = () => { - const doDelete = () => { - $q.all($scope.selectedUsers.map((user) => user.$delete())) - .then(() => toastNotifications.addSuccess(`Deleted ${$scope.selectedUsers.length > 1 ? 'users' : 'user'}`)) - .then(() => { - $scope.selectedUsers.map((user) => { - const i = $scope.users.indexOf(user); - $scope.users.splice(i, 1); - }); - $scope.selectedUsers.length = 0; - }); - }; - const confirmModalOptions = { - onConfirm: doDelete, - confirmButtonText: 'Delete user(s)' + controller($scope, $route, $q, confirmModal, $http, kbnUrl) { + $scope.$on('$destroy', () => { + const elem = document.getElementById('usersReactRoot'); + if (elem) unmountComponentAtNode(elem); + }); + $scope.$$postDigest(() => { + const elem = document.getElementById('usersReactRoot'); + const changeUrl = (url) => { + kbnUrl.change(url); + $scope.$apply(); }; - confirmModal( - 'Are you sure you want to delete the selected user(s)? This action is irreversible!', - confirmModalOptions - ); - }; - - $scope.getSortArrowClass = field => { - if ($scope.sort.orderBy === field) { - return $scope.sort.reverse ? 'fa-long-arrow-down' : 'fa-long-arrow-up'; - } - - // Sort ascending by default. - return 'fa-long-arrow-up'; - }; - - $scope.toggleAll = () => { - if ($scope.allSelected()) { - $scope.selectedUsers.length = 0; - } else { - $scope.selectedUsers = getActionableUsers().slice(); - } - }; - - $scope.allSelected = () => { - const users = getActionableUsers(); - return users.length && users.length === $scope.selectedUsers.length; - }; - - $scope.toggle = toggle; - $scope.includes = _.includes; - $scope.toggleSort = toggleSort; - - function getActionableUsers() { - return $scope.users.filter((user) => !user.metadata._reserved); - } - } + renderReact(elem, $http, changeUrl); + }); + }, }); diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js index 417ff83a5800f..7fb26877a4a6c 100644 --- a/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js @@ -57,12 +57,11 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.security.clickUsersSection(); await PageObjects.security.clickCreateNewUser(); - await testSubjects.setValue('userFormUserNameInput', 'dashuser'); await testSubjects.setValue('passwordInput', '123456'); await testSubjects.setValue('passwordConfirmationInput', '123456'); await testSubjects.setValue('userFormFullNameInput', 'dashuser'); - await testSubjects.setValue('userFormEmailInput', 'my@email.com'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); await PageObjects.security.assignRoleToUser('kibana_dashboard_only_user'); await PageObjects.security.assignRoleToUser('logstash-data'); @@ -76,7 +75,7 @@ export default function ({ getService, getPageObjects }) { await testSubjects.setValue('passwordInput', '123456'); await testSubjects.setValue('passwordConfirmationInput', '123456'); await testSubjects.setValue('userFormFullNameInput', 'mixeduser'); - await testSubjects.setValue('userFormEmailInput', 'my@email.com'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); await PageObjects.security.assignRoleToUser('kibana_dashboard_only_user'); await PageObjects.security.assignRoleToUser('kibana_user'); await PageObjects.security.assignRoleToUser('logstash-data'); @@ -91,7 +90,7 @@ export default function ({ getService, getPageObjects }) { await testSubjects.setValue('passwordInput', '123456'); await testSubjects.setValue('passwordConfirmationInput', '123456'); await testSubjects.setValue('userFormFullNameInput', 'mixeduser'); - await testSubjects.setValue('userFormEmailInput', 'my@email.com'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); await PageObjects.security.assignRoleToUser('kibana_dashboard_only_user'); await PageObjects.security.assignRoleToUser('superuser'); diff --git a/x-pack/test/functional/apps/security/management.js b/x-pack/test/functional/apps/security/management.js index d01d5e2b99d5d..b2cfb1898b9c6 100644 --- a/x-pack/test/functional/apps/security/management.js +++ b/x-pack/test/functional/apps/security/management.js @@ -53,7 +53,7 @@ export default function ({ getService, getPageObjects }) { await testSubjects.setValue('passwordInput', '123456'); await testSubjects.setValue('passwordConfirmationInput', '123456'); await testSubjects.setValue('userFormFullNameInput', 'Full User Name'); - await testSubjects.setValue('userFormEmailInput', 'my@email.com'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); await PageObjects.security.clickSaveEditUser(); @@ -66,8 +66,9 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.clickLinkText('new-user'); const currentUrl = await remote.getCurrentUrl(); expect(currentUrl).to.contain(EDIT_USERS_PATH); - const userNameInput = await testSubjects.find('userFormUserNameInput'); + // allow time for user to load + await PageObjects.common.sleep(500); const userName = await userNameInput.getProperty('value'); expect(userName).to.equal('new-user'); }); @@ -122,7 +123,7 @@ export default function ({ getService, getPageObjects }) { await testSubjects.setValue('passwordInput', '123456'); await testSubjects.setValue('passwordConfirmationInput', '123456'); await testSubjects.setValue('userFormFullNameInput', 'dashuser'); - await testSubjects.setValue('userFormEmailInput', 'my@email.com'); + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); await PageObjects.security.assignRoleToUser('kibana_dashboard_only_user'); await PageObjects.security.assignRoleToUser('logstash-data'); diff --git a/x-pack/test/functional/apps/security/secure_roles_perm.js b/x-pack/test/functional/apps/security/secure_roles_perm.js index 318604406b1fd..fd64431b3ca7f 100644 --- a/x-pack/test/functional/apps/security/secure_roles_perm.js +++ b/x-pack/test/functional/apps/security/secure_roles_perm.js @@ -13,6 +13,8 @@ export default function ({ getService, getPageObjects }) { const esArchiver = getService('esArchiver'); const remote = getService('remote'); const kibanaServer = getService('kibanaServer'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); @@ -64,12 +66,12 @@ export default function ({ getService, getPageObjects }) { }); - it('Kibana User navigating to Management gets - You do not have permission to manage users', async function () { - const expectedMessage = 'You do not have permission to manage users.'; + it('Kibana User navigating to Management gets permission denied', async function () { await PageObjects.settings.navigateTo(); await PageObjects.security.clickElasticsearchUsers(); - const actualMessage = await PageObjects.security.getPermissionDeniedMessage(); - expect(actualMessage).to.be(expectedMessage); + await retry.tryForTime(2000, async () => { + await testSubjects.find('permissionDeniedMessage'); + }); }); it('Kibana User navigating to Discover and trying to generate CSV gets - Authorization Error ', async function () { diff --git a/x-pack/test/functional/page_objects/security_page.js b/x-pack/test/functional/page_objects/security_page.js index c2e45fcdf9215..61ebe75492ab5 100644 --- a/x-pack/test/functional/page_objects/security_page.js +++ b/x-pack/test/functional/page_objects/security_page.js @@ -117,9 +117,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { } async clickSaveEditUser() { - const saveButton = await retry.try(() => testSubjects.find('userFormSaveButton')); - await remote.moveMouseTo(saveButton); - await saveButton.click(); + await testSubjects.click('userFormSaveButton'); await PageObjects.header.waitUntilLoadingHasFinished(); } @@ -146,11 +144,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { } async assignRoleToUser(role) { - log.debug(`Adding role ${role} to user`); - const privilegeInput = - await retry.try(() => find.byCssSelector('[data-test-subj="userFormRolesDropdown"] > div > input')); - await privilegeInput.type(role); - await privilegeInput.type('\n'); + await this.selectRole(role); } async navigateTo() { @@ -182,13 +176,18 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const fullnameElement = await user.findByCssSelector('[data-test-subj="userRowFullName"]'); const usernameElement = await user.findByCssSelector('[data-test-subj="userRowUserName"]'); const rolesElement = await user.findByCssSelector('[data-test-subj="userRowRoles"]'); - const isReservedElementVisible = await user.findByCssSelector('td:nth-child(5)'); + let reserved = false; + try { + reserved = !!(await user.findByCssSelector('[data-test-subj="reservedUser"]')); + } catch(e) { + //ignoring, just means user is not reserved + } return { username: await usernameElement.getVisibleText(), fullname: await fullnameElement.getVisibleText(), roles: (await rolesElement.getVisibleText()).split(',').map(role => role.trim()), - reserved: (await isReservedElementVisible.getProperty('innerHTML')).includes('userRowReserved') + reserved }; }); } @@ -221,23 +220,12 @@ export function SecurityPageProvider({ getService, getPageObjects }) { await testSubjects.setValue('passwordInput', userObj.password); await testSubjects.setValue('passwordConfirmationInput', userObj.confirmPassword); await testSubjects.setValue('userFormFullNameInput', userObj.fullname); - await testSubjects.setValue('userFormEmailInput', userObj.email); - - function addRoles(role) { - return role.reduce(function (promise, roleName) { - return promise - .then(function () { - log.debug('Add role: ' + roleName); - return self.selectRole(roleName); - }) - .then(function () { - return PageObjects.common.sleep(1000); - }); - - }, Promise.resolve()); - } + await testSubjects.setValue('userFormEmailInput', 'example@example.com'); log.debug('Add roles: ', userObj.roles); - await addRoles(userObj.roles || []); + const rolesToAdd = userObj.roles || []; + for (let i = 0; i < rolesToAdd.length; i++) { + await self.selectRole(rolesToAdd[i]); + } log.debug('After Add role: , userObj.roleName'); if (userObj.save === true) { await testSubjects.click('userFormSaveButton'); @@ -337,8 +325,9 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const dropdown = await testSubjects.find("userFormRolesDropdown"); const input = await dropdown.findByCssSelector("input"); await input.type(role); - await testSubjects.click(`addRoleOption-${role}`); - await testSubjects.find(`userRole-${role}`); + await testSubjects.click(`roleOption-${role}`); + await testSubjects.click('comboBoxToggleListButton'); + await testSubjects.find(`roleOption-${role}`); } deleteUser(username) {