mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
* partial progress on reactifying users * progress on EUIfication of users screen * removing Angular stuff * adding data-test-subj="passwordConfirmationInput" * removing data-test-subj="userFormEmailInput" refs from tests * fixing selector for role assignment * some functional test fixes * fixing some functional tests * fixing last functional test * removing stray console log * fixing warnings * attempting to fix flaky test * trying again to fix flaky test * PR feedback * PR feedback * fixing issue where form tried to submit * adding sleep to allow user to load * Design edits Mainly adding wrapper EUI page elements, but also shifted around form elements. * Fixed console error and added responsive prop to table * addressing PR feedback * A few more PR feedback - Fixed alignment of table - Removed the tooltip from the lock icon and placed the description inline. - Changed delete button to an empty button * addressing more PR feedback * adding email field back in * adding back username validation * restoring original error message * fixing dumb null error
This commit is contained in:
parent
89799ca6b6
commit
6c5d885c38
16 changed files with 965 additions and 647 deletions
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 (
|
||||
<EuiOverlayMask>
|
||||
<EuiConfirmModal
|
||||
title={title}
|
||||
onCancel={onCancel}
|
||||
onConfirm={this.deleteUsers}
|
||||
cancelButtonText="Cancel"
|
||||
confirmButtonText="Delete"
|
||||
buttonColor="danger"
|
||||
>
|
||||
<div>
|
||||
{moreThanOne ? (
|
||||
<Fragment>
|
||||
<p>
|
||||
You are about to delete these users:
|
||||
</p>
|
||||
<ul>{usersToDelete.map(username => <li key={username}>{username}</li>)}</ul>
|
||||
</Fragment>
|
||||
) : null}
|
||||
<p>This operation cannot be undone.</p>
|
||||
</div>
|
||||
</EuiConfirmModal>
|
||||
</EuiOverlayMask>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
<Fragment>
|
||||
{userIsLoggedInUser ? (
|
||||
<EuiFormRow
|
||||
label="Current password"
|
||||
isInvalid={!!this.currentPasswordError()}
|
||||
error={this.currentPasswordError()}
|
||||
>
|
||||
<EuiFieldText
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
onChange={event => this.setState({ currentPassword: event.target.value })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
) : null}
|
||||
<EuiFormRow
|
||||
label={userIsLoggedInUser ? 'New password' : 'Password'}
|
||||
isInvalid={!!this.passwordError()}
|
||||
error={this.passwordError()}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="passwordInput"
|
||||
name="password"
|
||||
type="password"
|
||||
onChange={event => this.setState({ password: event.target.value })}
|
||||
onBlur={event => this.setState({ password: event.target.value || '' })}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
label="Confirm password"
|
||||
isInvalid={!!this.confirmPasswordError()}
|
||||
error={this.confirmPasswordError()}
|
||||
>
|
||||
<EuiFieldText
|
||||
data-test-subj="passwordConfirmationInput"
|
||||
onChange={event => this.setState({ confirmPassword: event.target.value })}
|
||||
onBlur={event => this.setState({ confirmPassword: event.target.value || '' })}
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
changePasswordForm = () => {
|
||||
const {
|
||||
showChangePasswordForm,
|
||||
password,
|
||||
confirmPassword,
|
||||
user: { username },
|
||||
} = this.state;
|
||||
if (!showChangePasswordForm) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiHorizontalRule />
|
||||
{this.passwordFields()}
|
||||
{username === 'kibana' ? (
|
||||
<Fragment>
|
||||
<EuiCallOut title="Extra step needed" color="warning" iconType="help">
|
||||
<p>
|
||||
After you change the password for the kibana user, you must update the kibana.yml
|
||||
file and restart Kibana.
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
) : null}
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
fill
|
||||
disabled={
|
||||
!password || !confirmPassword || this.passwordError() || this.confirmPasswordError()
|
||||
}
|
||||
onClick={() => {
|
||||
this.changePassword(password);
|
||||
}}
|
||||
>
|
||||
Save password
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
onClick={() => {
|
||||
this.clearPasswordForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
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 (
|
||||
<EuiPage className="mgtUsersEditPage">
|
||||
<EuiPageBody>
|
||||
<EuiPageContent className="mgtUsersEditPage__content">
|
||||
<EuiPageContentHeader>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiTitle>
|
||||
<h2>{isNewUser ? 'New user' : `Edit "${user.username}" user`}</h2>
|
||||
</EuiTitle>
|
||||
</EuiPageContentHeaderSection>
|
||||
{reserved && (
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiIcon type="lock" size="l" color="subdued" />
|
||||
</EuiPageContentHeaderSection>
|
||||
)}
|
||||
</EuiPageContentHeader>
|
||||
<EuiPageContentBody>
|
||||
{reserved && (
|
||||
<EuiText size="s" color="subdued">
|
||||
<p>
|
||||
Reserved users are built-in and cannot be removed or modified. Only the password
|
||||
may be changed.
|
||||
</p>
|
||||
</EuiText>
|
||||
)}
|
||||
|
||||
{showDeleteConfirmation ? (
|
||||
<ConfirmDelete
|
||||
onCancel={this.onCancelDelete}
|
||||
apiClient={apiClient}
|
||||
usersToDelete={[user.username]}
|
||||
callback={this.handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<EuiForm>
|
||||
<EuiFormRow
|
||||
isInvalid={!!this.usernameError()}
|
||||
error={this.usernameError()}
|
||||
helpText={
|
||||
!isNewUser && !reserved
|
||||
? "Username's cannot be changed after creation."
|
||||
: null
|
||||
}
|
||||
label="Username"
|
||||
>
|
||||
<EuiFieldText
|
||||
onBlur={event =>
|
||||
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 },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{isNewUser ? this.passwordFields() : null}
|
||||
{reserved ? null : (
|
||||
<Fragment>
|
||||
<EuiFormRow
|
||||
isInvalid={!!this.fullnameError()}
|
||||
error={this.fullnameError()}
|
||||
label="Full name"
|
||||
>
|
||||
<EuiFieldText
|
||||
onBlur={event =>
|
||||
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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow
|
||||
isInvalid={!!this.emailError()}
|
||||
error={this.emailError()}
|
||||
label="Email address"
|
||||
>
|
||||
<EuiFieldText
|
||||
onBlur={event =>
|
||||
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,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</Fragment>
|
||||
)}
|
||||
<EuiFormRow label="Roles">
|
||||
<EuiComboBox
|
||||
data-test-subj="userFormRolesDropdown"
|
||||
placeholder="Add roles"
|
||||
onChange={this.onRolesChange}
|
||||
isDisabled={reserved}
|
||||
name="roles"
|
||||
options={roles.map(role => {
|
||||
return { 'data-test-subj': `roleOption-${role.name}`, label: role.name };
|
||||
})}
|
||||
selectedOptions={selectedRoles}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
|
||||
{isNewUser || showChangePasswordForm ? null : (
|
||||
<EuiFormRow label="Password">
|
||||
<EuiLink onClick={this.toggleChangePasswordForm}>Change password</EuiLink>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{this.changePasswordForm()}
|
||||
|
||||
<EuiHorizontalRule />
|
||||
|
||||
{reserved && (
|
||||
<EuiButton onClick={() => changeUrl(USERS_PATH)}>Return to user list</EuiButton>
|
||||
)}
|
||||
{reserved ? null : (
|
||||
<EuiFlexGroup responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
disabled={this.cannotSaveUser()}
|
||||
fill
|
||||
data-test-subj="userFormSaveButton"
|
||||
onClick={() => this.saveUser()}
|
||||
>
|
||||
{isNewUser ? 'Create user' : 'Update user'}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="userFormCancelButton"
|
||||
onClick={() => changeUrl(USERS_PATH)}
|
||||
>
|
||||
Cancel
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={true} />
|
||||
{isNewUser || reserved ? null : (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonEmpty
|
||||
onClick={() => {
|
||||
this.setState({ showDeleteConfirmation: true });
|
||||
}}
|
||||
data-test-subj="userFormDeleteButton"
|
||||
color="danger"
|
||||
>
|
||||
Delete user
|
||||
</EuiButtonEmpty>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</EuiForm>
|
||||
</form>
|
||||
</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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 (
|
||||
<EuiButton
|
||||
data-test-subj="deleteUserButton"
|
||||
color="danger"
|
||||
onClick={() => this.setState({ showDeleteConfirmation: true })}
|
||||
>
|
||||
Delete {numSelected} user{numSelected > 1 ? 's' : ''}
|
||||
</EuiButton>
|
||||
);
|
||||
}
|
||||
onCancelDelete = () => {
|
||||
this.setState({ showDeleteConfirmation: false });
|
||||
}
|
||||
render() {
|
||||
const { users, filter, permissionDenied, showDeleteConfirmation, selection } = this.state;
|
||||
const { apiClient } = this.props;
|
||||
if (permissionDenied) {
|
||||
return (
|
||||
<EuiPage className="mgtUsersListingPage">
|
||||
<EuiPageBody>
|
||||
<EuiPageContent horizontalPosition="center">
|
||||
<EuiEmptyPrompt
|
||||
iconType="securityApp"
|
||||
iconColor={null}
|
||||
title={<h2>Permission denied</h2>}
|
||||
body={<p data-test-subj="permissionDeniedMessage">You do not have permission to manage users.</p>}
|
||||
/>
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
}
|
||||
const path = '#/management/security/';
|
||||
const columns = [
|
||||
{
|
||||
field: 'full_name',
|
||||
name: 'Full Name',
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
render: fullName => {
|
||||
return <div data-test-subj="userRowFullName">{fullName}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'username',
|
||||
name: 'User Name',
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
render: username => (
|
||||
<EuiLink data-test-subj="userRowUserName" href={`${path}users/edit/${username}`}>
|
||||
{username}
|
||||
</EuiLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'email',
|
||||
name: 'Email Address',
|
||||
sortable: true,
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
field: 'roles',
|
||||
name: 'Roles',
|
||||
render: rolenames => {
|
||||
const roleLinks = rolenames.map((rolename, index) => {
|
||||
return (
|
||||
<Fragment key={rolename}>
|
||||
<EuiLink href={`${path}roles/edit/${rolename}`}>{rolename}</EuiLink>
|
||||
{index === rolenames.length - 1 ? null : ', '}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
return <div data-test-subj="userRowRoles">{roleLinks}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 ? (
|
||||
<EuiIcon aria-label="Reserved user" data-test-subj="reservedUser" type="check" />
|
||||
) : 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 (
|
||||
<EuiPage className="mgtUsersListingPage">
|
||||
<EuiPageBody>
|
||||
<EuiPageContent className="mgtUsersListingPage__content">
|
||||
<EuiPageContentHeader>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiTitle>
|
||||
<h2>Users</h2>
|
||||
</EuiTitle>
|
||||
</EuiPageContentHeaderSection>
|
||||
<EuiPageContentHeaderSection>
|
||||
<EuiButton
|
||||
data-test-subj="createUserButton"
|
||||
href="#/management/security/users/edit"
|
||||
>
|
||||
Create new user
|
||||
</EuiButton>
|
||||
</EuiPageContentHeaderSection>
|
||||
</EuiPageContentHeader>
|
||||
<EuiPageContentBody>
|
||||
|
||||
{showDeleteConfirmation ? (
|
||||
<ConfirmDelete
|
||||
onCancel={this.onCancelDelete}
|
||||
apiClient={apiClient}
|
||||
usersToDelete={selection.map((user) => user.username)}
|
||||
callback={this.handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<EuiInMemoryTable
|
||||
itemId="username"
|
||||
columns={columns}
|
||||
selection={selectionConfig}
|
||||
pagination={pagination}
|
||||
items={usersToShow}
|
||||
loading={users.length === 0}
|
||||
search={search}
|
||||
sorting={sorting}
|
||||
rowProps={rowProps}
|
||||
isSelectable
|
||||
/>
|
||||
|
||||
</EuiPageContentBody>
|
||||
</EuiPageContent>
|
||||
</EuiPageBody>
|
||||
</EuiPage>
|
||||
);
|
||||
}
|
||||
}
|
55
x-pack/plugins/security/public/lib/api.js
Normal file
55
x-pack/plugins/security/public/lib/api.js
Normal file
|
@ -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);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -1,184 +1,3 @@
|
|||
<kbn-management-app section="security" omit-breadcrumb-pages="['edit']">
|
||||
<div class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem">
|
||||
<!-- Subheader -->
|
||||
<div class="kuiBar kuiVerticalRhythm">
|
||||
<div class="kuiBarSection">
|
||||
<!-- Title -->
|
||||
<h1 class="kuiTitle">
|
||||
<span ng-if="editUser.isNewUser">
|
||||
Add user
|
||||
</span>
|
||||
<span ng-if="!editUser.isNewUser">
|
||||
“{{ user.username }}” User
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="kuiBarSection">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
ng-if="!editUser.isNewUser && !user.metadata._reserved"
|
||||
class="kuiButton kuiButton--danger kuiButton--iconText"
|
||||
ng-click="deleteUser(user)"
|
||||
tooltip="Delete User"
|
||||
data-test-subj="userFormDeleteButton"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span class="kuiButton__icon kuiIcon fa-trash"></span>
|
||||
<span>Delete user</span>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
ng-if="user.metadata._reserved"
|
||||
class="kuiBadge kuiBadge--default"
|
||||
tooltip="Reserved users are built-in and cannot be removed. Only the password can be changed."
|
||||
>
|
||||
<span class="kuiIcon fa-lock"></span>
|
||||
Reserved
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form name="form" novalidate class="kuiVerticalRhythm">
|
||||
<!-- Username -->
|
||||
<div class="kuiFormSection">
|
||||
<label for="username" class="kuiFormLabel">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
ng-class="::editUser.isNewUser ? 'kuiTextInput fullWidth' : 'kuiStaticInput'"
|
||||
ng-disabled="!editUser.isNewUser"
|
||||
id="username"
|
||||
name="username"
|
||||
ng-model="user.username"
|
||||
required pattern="[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*"
|
||||
maxlength="30"
|
||||
ng-disabled="!editUser.isNewUser"
|
||||
data-test-subj="userFormUserNameInput"
|
||||
/>
|
||||
|
||||
<!-- Errors -->
|
||||
<div
|
||||
class="kuiInputNote kuiInputNote--danger"
|
||||
ng-show="form.username.$error.pattern"
|
||||
>
|
||||
Username must begin with a letter or underscore and contain only letters, underscores, and numbers
|
||||
</div>
|
||||
<div
|
||||
class="kuiInputNote kuiInputNote--danger"
|
||||
ng-show="form.username.$touched && form.username.$error.required"
|
||||
>
|
||||
Username is required
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New user password -->
|
||||
<kbn-password-form
|
||||
ng-if="editUser.isNewUser"
|
||||
password="user.password"
|
||||
></kbn-password-form>
|
||||
|
||||
<!-- Full name -->
|
||||
<div class="kuiFormSection" ng-if="!user.metadata._reserved">
|
||||
<label for="fullname" class="kuiFormLabel">
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="kuiTextInput fullWidth"
|
||||
id="fullname"
|
||||
name="fullname"
|
||||
ng-model="user.full_name"
|
||||
required
|
||||
ng-disabled="editUser.changePasswordMode"
|
||||
data-test-subj="userFormFullNameInput"
|
||||
/>
|
||||
|
||||
<!-- Errors -->
|
||||
<div
|
||||
class="kuiInputNote kuiInputNote--danger"
|
||||
ng-show="form.fullname.$touched && form.fullname.$error.required"
|
||||
>
|
||||
Full name is required
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email-->
|
||||
<div class="kuiFormSection" ng-if="!user.metadata._reserved">
|
||||
<label for="email" class="kuiFormLabel">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
class="kuiTextInput fullWidth"
|
||||
id="email"
|
||||
name="email"
|
||||
ng-model="user.email"
|
||||
required
|
||||
ng-disabled="editUser.changePasswordMode"
|
||||
data-test-subj="userFormEmailInput"
|
||||
/>
|
||||
|
||||
<!-- Errors -->
|
||||
<div
|
||||
class="kuiInputNote kuiInputNote--danger"
|
||||
ng-show="form.email.$touched && form.email.$error.required"
|
||||
>
|
||||
Email is required
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles-->
|
||||
<div class="kuiFormSection">
|
||||
<label class="kuiFormLabel">
|
||||
Roles
|
||||
</label>
|
||||
<ui-select
|
||||
multiple
|
||||
ng-model="user.roles"
|
||||
ng-disabled="user.metadata._reserved"
|
||||
data-test-subj="userFormRolesDropdown"
|
||||
>
|
||||
<ui-select-match placeholder="Start typing for suggestions">
|
||||
<span data-test-subj="userRole userRole-{{$item}}">{{$item}}</span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="role as role in availableRoles | filter:$select.search">
|
||||
<div ng-bind-html="role" data-test-subj="addRoleOption addRoleOption-{{role}}" ></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Form actions -->
|
||||
<div class="kuiFormSection kuiFormFooter">
|
||||
<button
|
||||
class="kuiButton kuiButton--primary"
|
||||
ng-if="!user.metadata._reserved"
|
||||
ng-disabled="form.$invalid"
|
||||
ng-click="saveUser(user)"
|
||||
data-test-subj="userFormSaveButton"
|
||||
>
|
||||
Add user
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="kuiButton kuiButton--basic"
|
||||
ng-if="!user.metadata._reserved"
|
||||
ng-href="{{usersHref}}"
|
||||
data-test-subj="userFormCancelButton"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Existing user password -->
|
||||
<kbn-change-password-form
|
||||
ng-if="!editUser.isNewUser"
|
||||
require-current-password="me.username === user.username"
|
||||
show-kibana-warning="user.metadata._reserved && user.username === 'kibana'"
|
||||
on-change-password="saveNewPassword(newPassword, currentPassword, onSuccess, onIncorrectPassword)"
|
||||
></kbn-change-password-form>
|
||||
</div>
|
||||
<div id="editUserReactRoot" />
|
||||
</kbn-management-app>
|
||||
|
|
|
@ -3,107 +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 { GateKeeperProvider } from 'plugins/xpack_main/services/gate_keeper';
|
||||
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(
|
||||
<EditUser
|
||||
changeUrl={changeUrl}
|
||||
apiClient={createApiClient(httpClient)}
|
||||
username={username}
|
||||
/>,
|
||||
elem
|
||||
);
|
||||
};
|
||||
|
||||
routes.when(`${EDIT_USERS_PATH}/:username?`, {
|
||||
template,
|
||||
resolve: {
|
||||
tribeRedirect(Private) {
|
||||
const gateKeeper = Private(GateKeeperProvider);
|
||||
gateKeeper.redirectAndNotifyIfTribe();
|
||||
},
|
||||
|
||||
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);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,251 +1,3 @@
|
|||
<kbn-management-app section="security">
|
||||
<div class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem">
|
||||
<div class="kuiInfoPanel kuiInfoPanel--error" ng-if="forbidden">
|
||||
<div class="kuiInfoPanelHeader">
|
||||
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--error fa-warning"></span>
|
||||
<span class="kuiInfoPanelHeader__title">
|
||||
You do not have permission to manage users.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="kuiInfoPanelBody">
|
||||
<div class="kuiInfoPanelBody__message">
|
||||
Please contact your administrator.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="!forbidden">
|
||||
<!-- ControlledTable -->
|
||||
<div class="kuiControlledTable">
|
||||
<!-- ToolBar -->
|
||||
<div class="kuiToolBar">
|
||||
<div class="kuiToolBarSearch">
|
||||
<div class="kuiToolBarSearchBox">
|
||||
<div class="kuiToolBarSearchBox__icon kuiIcon fa-search"></div>
|
||||
<input
|
||||
class="kuiToolBarSearchBox__input"
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
aria-label="Filter"
|
||||
ng-model="query"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuiToolBarSection">
|
||||
<!-- Delete users button -->
|
||||
<button
|
||||
ng-click="deleteUsers()"
|
||||
class="kuiButton kuiButton--danger kuiButton--iconText"
|
||||
ng-if="selectedUsers.length"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span class="kuiButton__icon kuiIcon fa-trash"></span>
|
||||
<span>Delete</span>
|
||||
<span class="kuiButton__inner">
|
||||
</button>
|
||||
|
||||
<!-- Create user button -->
|
||||
<a
|
||||
ng-href="{{editUsersHref}}"
|
||||
ng-click="newUser()"
|
||||
class="kuiButton kuiButton--primary kuiButton--iconText"
|
||||
ng-if="!selectedUsers.length"
|
||||
data-test-subj="createUserButton"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span class="kuiButton__icon kuiIcon fa-plus"></span>
|
||||
<span>Add user</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="kuiToolBarSection">
|
||||
<!-- We need an empty section for the buttons to be positioned consistently. -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NoResults -->
|
||||
<div class="kuiPanel kuiPanel--centered" ng-show="!(users | filter:query).length">
|
||||
<div class="kuiNoItems">
|
||||
No users <span ng-show="query">match</span> your search criteria
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<table class="kuiTable" ng-show="(users | filter:query).length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="kuiTableHeaderCell kuiTableHeaderCell--checkBox">
|
||||
<div class="kuiTableHeaderCell__liner">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="kuiCheckBox"
|
||||
ng-checked="allSelected()"
|
||||
ng-click="toggleAll()"
|
||||
aria-label="{{allSelected() ? 'Deselect all rows' : 'Select all rows'}}"
|
||||
>
|
||||
</div>
|
||||
</th>
|
||||
|
||||
<th
|
||||
scope="col"
|
||||
class="kuiTableHeaderCell"
|
||||
>
|
||||
<button
|
||||
class="kuiTableHeaderCellButton"
|
||||
ng-class="{'kuiTableHeaderCellButton-isSorted': sort.orderBy == 'full_name'}"
|
||||
ng-click="toggleSort(sort, 'full_name')"
|
||||
aria-label="{{sort.reverse ? 'Sort full name ascending' : 'Sort full name descending'}}"
|
||||
>
|
||||
<span class="kuiTableHeaderCell__liner">
|
||||
Full name
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="kuiTableSortIcon kuiIcon"
|
||||
ng-class="getSortArrowClass('full_name')"
|
||||
></span>
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<th
|
||||
scope="col"
|
||||
class="kuiTableHeaderCell"
|
||||
>
|
||||
<button
|
||||
class="kuiTableHeaderCellButton"
|
||||
ng-class="{'kuiTableHeaderCellButton-isSorted': sort.orderBy == 'username'}"
|
||||
ng-click="toggleSort(sort, 'username')"
|
||||
aria-label="{{sort.reverse ? 'Sort user name ascending' : 'Sort user name descending'}}"
|
||||
>
|
||||
<span class="kuiTableHeaderCell__liner">
|
||||
Username
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="kuiTableSortIcon kuiIcon"
|
||||
ng-class="getSortArrowClass('username')"
|
||||
></span>
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<th
|
||||
scope="col"
|
||||
class="kuiTableHeaderCell"
|
||||
>
|
||||
<button
|
||||
class="kuiTableHeaderCellButton"
|
||||
ng-class="{'kuiTableHeaderCellButton-isSorted': sort.orderBy == 'roles'}"
|
||||
ng-click="toggleSort(sort, 'roles')"
|
||||
aria-label="{{sort.reverse ? 'Sort roles ascending' : 'Sort roles descending'}}"
|
||||
>
|
||||
<span class="kuiTableHeaderCell__liner">
|
||||
Roles
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="kuiTableSortIcon kuiIcon"
|
||||
ng-class="getSortArrowClass('roles')"
|
||||
></span>
|
||||
</span>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<th scope="col" class="kuiTableHeaderCell">
|
||||
<span class="kuiTableHeaderCell__liner">
|
||||
Reserved
|
||||
<span
|
||||
class="kuiIcon fa-question-circle"
|
||||
tooltip="Reserved users are built-in and cannot be removed. Only the password can be changed."
|
||||
aria-label="Reserved users are built-in and cannot be removed. Only the password can be changed."
|
||||
></span>
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr
|
||||
ng-repeat="user in users | orderBy:'username' | filter:query | orderBy:sort.orderBy:sort.reverse"
|
||||
data-test-subj="userRow"
|
||||
class="kuiTableRow"
|
||||
>
|
||||
<td class="kuiTableRowCell kuiTableRowCell--checkBox">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="kuiCheckBox"
|
||||
ng-click="toggle(selectedUsers, user)"
|
||||
ng-checked="includes(selectedUsers, user)"
|
||||
ng-disabled="user.metadata._reserved"
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="kuiTableRowCell">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<a
|
||||
class="kuiLink"
|
||||
ng-href="{{getEditUrlHref(user.username)}}"
|
||||
data-test-subj="userRowFullName"
|
||||
>
|
||||
{{ user.full_name }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="kuiTableRowCell">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<a
|
||||
class="kuiLink"
|
||||
ng-href="{{getEditUrlHref(user.username)}}"
|
||||
data-test-subj="userRowUserName"
|
||||
>
|
||||
{{ user.username }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="kuiTableRowCell" data-test-subj="userRowRoles">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<span ng-repeat="role in user.roles">
|
||||
<a
|
||||
class="kuiLink"
|
||||
ng-href="{{getEditRoleHref(role)}}"
|
||||
>
|
||||
{{ role }}
|
||||
</a>
|
||||
<span ng-if="!$last">,</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="kuiTableRowCell">
|
||||
<div class="kuiTableRowCell__liner">
|
||||
<div
|
||||
ng-if="user.metadata._reserved"
|
||||
class="kuiIcon fa-check"
|
||||
data-test-subj="userRowReserved"
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- ToolBarFooter -->
|
||||
<div class="kuiToolBarFooter">
|
||||
<div class="kuiToolBarFooterSection">
|
||||
<div class="kuiToolBarText" ng-hide="selectedUsers.length === 0">
|
||||
{{ selectedUsers.length }} users selected
|
||||
</div>
|
||||
</div>
|
||||
<div class="kuiToolBarFooterSection">
|
||||
<!-- We need an empty section for the buttons to be positioned consistently. -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="usersReactRoot" />
|
||||
</kbn-management-app>
|
||||
|
|
|
@ -4,95 +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 { GateKeeperProvider } from 'plugins/xpack_main/services/gate_keeper';
|
||||
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(<Users changeUrl={changeUrl} apiClient={createApiClient(httpClient)} />, elem);
|
||||
};
|
||||
|
||||
routes.when(USERS_PATH, {
|
||||
template,
|
||||
resolve: {
|
||||
tribeRedirect(Private) {
|
||||
const gateKeeper = Private(GateKeeperProvider);
|
||||
gateKeeper.redirectAndNotifyIfTribe();
|
||||
},
|
||||
|
||||
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, $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();
|
||||
};
|
||||
renderReact(elem, $http, changeUrl);
|
||||
});
|
||||
},
|
||||
|
||||
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)'
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue