[7.x] TypeScriptify and test user management (#36039) (#36222)

* convert user management screen to TypeScript

* rename Users to UsersListPage for consistency

* unit testing for user management page

* fix props for change password form

* test authentication state page

* add access modifiers

* remove unused translations

* reorganize user management files to be consistent with the rest of security's mgmt ui

* add missing license header

* fix import

* remove stray import

* remove unnecessary style hacks

* Update x-pack/plugins/security/public/views/management/edit_user/components/edit_user_page.tsx

Co-Authored-By: legrego <lgregorydev@gmail.com>

* address pr feedback
This commit is contained in:
Larry Gregory 2019-05-07 15:59:40 -04:00 committed by GitHub
parent 871ce5527b
commit 4f9e8d6e80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1150 additions and 437 deletions

View file

@ -8,5 +8,5 @@ export { Role, RoleIndexPrivilege, RoleKibanaPrivilege } from './role';
export { FeaturesPrivileges } from './features_privileges';
export { RawKibanaPrivileges, RawKibanaFeaturePrivileges } from './raw_kibana_privileges';
export { KibanaPrivileges } from './kibana_privileges';
export { User, getUserDisplayName } from './user';
export { User, EditUser, getUserDisplayName } from './user';
export { AuthenticatedUser, canUserChangePassword } from './authenticated_user';

View file

@ -10,6 +10,14 @@ export interface User {
full_name: string;
roles: string[];
enabled: boolean;
metadata?: {
_reserved: boolean;
};
}
export interface EditUser extends User {
password: string;
confirmPassword: string;
}
export function getUserDisplayName(user: User) {

View file

@ -1,2 +1 @@
@import './management/users/index';
@import './authentication_state_page/index';

View file

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AuthenticationStatePage renders 1`] = `
<div
className="secAuthenticationStatePage"
>
<header
className="secAuthenticationStatePage__header"
>
<div
className="secAuthenticationStatePage__content eui-textCenter"
>
<EuiSpacer
size="xxl"
/>
<span
className="secAuthenticationStatePage__logo"
>
<EuiIcon
size="xxl"
type="logoKibana"
/>
</span>
<EuiTitle
className="secAuthenticationStatePage__title"
size="l"
>
<h1>
foo
</h1>
</EuiTitle>
<EuiSpacer
size="xl"
/>
</div>
</header>
<div
className="secAuthenticationStatePage__content eui-textCenter"
>
<span>
hello world
</span>
</div>
</div>
`;

View file

@ -0,0 +1,21 @@
/*
* 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 { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { AuthenticationStatePage } from './authentication_state_page';
import React from 'react';
describe('AuthenticationStatePage', () => {
it('renders', () => {
expect(
shallowWithIntl(
<AuthenticationStatePage title={'foo'}>
<span>hello world</span>
</AuthenticationStatePage>
)
).toMatchSnapshot();
});
});

View file

@ -3,13 +3,6 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../../../lib/api', () => {
return {
UserAPIClient: {
changePassword: jest.fn(),
},
};
});
import { EuiFieldText } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
import React from 'react';
@ -42,7 +35,11 @@ describe('<ChangePasswordForm>', () => {
};
const wrapper = mountWithIntl(
<ChangePasswordForm user={user} isUserChangingOwnPassword={true} />
<ChangePasswordForm
user={user}
isUserChangingOwnPassword={true}
apiClient={new UserAPIClient()}
/>
);
expect(getCurrentPasswordField(wrapper)).toHaveLength(1);
@ -61,11 +58,15 @@ describe('<ChangePasswordForm>', () => {
const callback = jest.fn();
const apiClient = new UserAPIClient();
apiClient.changePassword = jest.fn();
const wrapper = mountWithIntl(
<ChangePasswordForm
user={user}
isUserChangingOwnPassword={true}
onChangePassword={callback}
apiClient={apiClient}
/>
);
@ -80,8 +81,8 @@ describe('<ChangePasswordForm>', () => {
wrapper.find('button[data-test-subj="changePasswordButton"]').simulate('click');
expect(UserAPIClient.changePassword).toHaveBeenCalledTimes(1);
expect(UserAPIClient.changePassword).toHaveBeenCalledWith(
expect(apiClient.changePassword).toHaveBeenCalledTimes(1);
expect(apiClient.changePassword).toHaveBeenCalledWith(
'user',
'myNewPassword',
'myCurrentPassword'
@ -100,7 +101,11 @@ describe('<ChangePasswordForm>', () => {
};
const wrapper = mountWithIntl(
<ChangePasswordForm user={user} isUserChangingOwnPassword={false} />
<ChangePasswordForm
user={user}
isUserChangingOwnPassword={false}
apiClient={new UserAPIClient()}
/>
);
expect(getCurrentPasswordField(wrapper)).toHaveLength(0);

View file

@ -26,6 +26,7 @@ interface Props {
user: User;
isUserChangingOwnPassword: boolean;
onChangePassword?: () => void;
apiClient: UserAPIClient;
}
interface State {
@ -277,7 +278,7 @@ export class ChangePasswordForm extends Component<Props, State> {
private performPasswordChange = async () => {
try {
await UserAPIClient.changePassword(
await this.props.apiClient.changePassword(
this.props.user.username,
this.state.newPassword,
this.state.currentPassword

View file

@ -1,16 +0,0 @@
// HACK -- Fix for background color full-height of browser
.secUsersEditPage,
.secUsersListingPage {
min-height: calc(100vh - 70px);
}
.secUsersListingPage__content {
flex-grow: 0;
}
.secUsersEditPage__content {
max-width: $secFormWidth;
margin-left: auto;
margin-right: auto;
flex-grow: 0;
}

View file

@ -0,0 +1,94 @@
/*
* 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 { mountWithIntl } from 'test_utils/enzyme_helpers';
import { ConfirmDeleteUsers } from './confirm_delete';
import React from 'react';
import { UserAPIClient } from '../../../lib/api';
describe('ConfirmDeleteUsers', () => {
it('renders a warning for a single user', () => {
const wrapper = mountWithIntl(
<ConfirmDeleteUsers apiClient={null as any} usersToDelete={['foo']} onCancel={jest.fn()} />
);
expect(wrapper.find('EuiModalHeaderTitle').text()).toMatchInlineSnapshot(`"Delete user foo"`);
});
it('renders a warning for a multiple users', () => {
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
apiClient={null as any}
usersToDelete={['foo', 'bar', 'baz']}
onCancel={jest.fn()}
/>
);
expect(wrapper.find('EuiModalHeaderTitle').text()).toMatchInlineSnapshot(`"Delete 3 users"`);
});
it('fires onCancel when the operation is cancelled', () => {
const onCancel = jest.fn();
const wrapper = mountWithIntl(
<ConfirmDeleteUsers apiClient={null as any} usersToDelete={['foo']} onCancel={onCancel} />
);
expect(onCancel).toBeCalledTimes(0);
wrapper.find('EuiButtonEmpty[data-test-subj="confirmModalCancelButton"]').simulate('click');
expect(onCancel).toBeCalledTimes(1);
});
it('deletes the requested users when confirmed', () => {
const onCancel = jest.fn();
const deleteUser = jest.fn();
const apiClient = new UserAPIClient();
apiClient.deleteUser = deleteUser;
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
usersToDelete={['foo', 'bar']}
apiClient={apiClient}
onCancel={onCancel}
/>
);
wrapper.find('EuiButton[data-test-subj="confirmModalConfirmButton"]').simulate('click');
expect(deleteUser).toBeCalledTimes(2);
expect(deleteUser).toBeCalledWith('foo');
expect(deleteUser).toBeCalledWith('bar');
});
it('attempts to delete all users even if some fail', () => {
const onCancel = jest.fn();
const deleteUser = jest.fn().mockImplementation(user => {
if (user === 'foo') {
return Promise.reject('something terrible happened');
}
return Promise.resolve();
});
const apiClient = new UserAPIClient();
apiClient.deleteUser = deleteUser;
const wrapper = mountWithIntl(
<ConfirmDeleteUsers
usersToDelete={['foo', 'bar']}
apiClient={apiClient}
onCancel={onCancel}
/>
);
wrapper.find('EuiButton[data-test-subj="confirmModalConfirmButton"]').simulate('click');
expect(deleteUser).toBeCalledTimes(2);
expect(deleteUser).toBeCalledWith('foo');
expect(deleteUser).toBeCalledWith('bar');
});
});

View file

@ -7,48 +7,36 @@
import React, { Component, Fragment } from 'react';
import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import { FormattedMessage, injectI18n, InjectedIntl } from '@kbn/i18n/react';
import { UserAPIClient } from '../../../lib/api';
class ConfirmDeleteUI extends Component {
deleteUsers = () => {
const { usersToDelete, callback } = this.props;
const errors = [];
usersToDelete.forEach(async username => {
try {
await UserAPIClient.deleteUser(username);
toastNotifications.addSuccess(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.userSuccessfullyDeletedNotificationMessage',
defaultMessage: 'Deleted user {username}'
}, { username })
);
} catch (e) {
errors.push(username);
toastNotifications.addDanger(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.userDeletingErrorNotificationMessage',
defaultMessage: 'Error deleting user {username}'
}, { username })
);
}
if (callback) {
callback(usersToDelete, errors);
}
});
};
render() {
interface Props {
intl: InjectedIntl;
usersToDelete: string[];
apiClient: UserAPIClient;
onCancel: () => void;
callback?: (usersToDelete: string[], errors: string[]) => void;
}
class ConfirmDeleteUI extends Component<Props, {}> {
public render() {
const { usersToDelete, onCancel, intl } = this.props;
const moreThanOne = usersToDelete.length > 1;
const title = moreThanOne
? intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.deleteMultipleUsersTitle',
defaultMessage: 'Delete {userLength} users'
}, { userLength: usersToDelete.length })
: intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.deleteOneUserTitle',
defaultMessage: 'Delete user {userLength}'
}, { userLength: usersToDelete[0] });
? intl.formatMessage(
{
id: 'xpack.security.management.users.confirmDelete.deleteMultipleUsersTitle',
defaultMessage: 'Delete {userLength} users',
},
{ userLength: usersToDelete.length }
)
: intl.formatMessage(
{
id: 'xpack.security.management.users.confirmDelete.deleteOneUserTitle',
defaultMessage: 'Delete user {userLength}',
},
{ userLength: usersToDelete[0] }
);
return (
<EuiOverlayMask>
<EuiConfirmModal
@ -57,11 +45,11 @@ class ConfirmDeleteUI extends Component {
onConfirm={this.deleteUsers}
cancelButtonText={intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.cancelButtonLabel',
defaultMessage: 'Cancel'
defaultMessage: 'Cancel',
})}
confirmButtonText={intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.confirmButtonLabel',
defaultMessage: 'Delete'
defaultMessage: 'Delete',
})}
buttonColor="danger"
>
@ -74,7 +62,11 @@ class ConfirmDeleteUI extends Component {
defaultMessage="You are about to delete these users:"
/>
</p>
<ul>{usersToDelete.map(username => <li key={username}>{username}</li>)}</ul>
<ul>
{usersToDelete.map(username => (
<li key={username}>{username}</li>
))}
</ul>
</Fragment>
) : null}
<p>
@ -88,6 +80,41 @@ class ConfirmDeleteUI extends Component {
</EuiOverlayMask>
);
}
private deleteUsers = () => {
const { usersToDelete, callback, apiClient } = this.props;
const errors: string[] = [];
usersToDelete.forEach(async username => {
try {
await apiClient.deleteUser(username);
toastNotifications.addSuccess(
this.props.intl.formatMessage(
{
id:
'xpack.security.management.users.confirmDelete.userSuccessfullyDeletedNotificationMessage',
defaultMessage: 'Deleted user {username}',
},
{ username }
)
);
} catch (e) {
errors.push(username);
toastNotifications.addDanger(
this.props.intl.formatMessage(
{
id:
'xpack.security.management.users.confirmDelete.userDeletingErrorNotificationMessage',
defaultMessage: 'Error deleting user {username}',
},
{ username }
)
);
}
if (callback) {
callback(usersToDelete, errors);
}
});
};
}
export const ConfirmDelete = injectI18n(ConfirmDeleteUI);
export const ConfirmDeleteUsers = injectI18n(ConfirmDeleteUI);

View file

@ -1,11 +0,0 @@
/*
* 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';

View file

@ -0,0 +1,7 @@
/*
* 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 { ConfirmDeleteUsers } from './confirm_delete';

View file

@ -5,45 +5,46 @@
*/
import { kfetch } from 'ui/kfetch';
import { AuthenticatedUser, Role, User } from '../../common/model';
import { AuthenticatedUser, Role, User, EditUser } from '../../common/model';
const usersUrl = '/api/security/v1/users';
const rolesUrl = '/api/security/role';
export class UserAPIClient {
public static async getCurrentUser(): Promise<AuthenticatedUser> {
public async getCurrentUser(): Promise<AuthenticatedUser> {
return await kfetch({ pathname: `/api/security/v1/me` });
}
public static async getUsers(): Promise<User[]> {
public async getUsers(): Promise<User[]> {
return await kfetch({ pathname: usersUrl });
}
public static async getUser(username: string): Promise<User> {
public async getUser(username: string): Promise<User> {
const url = `${usersUrl}/${encodeURIComponent(username)}`;
return await kfetch({ pathname: url });
}
public static async deleteUser(username: string) {
public async deleteUser(username: string) {
const url = `${usersUrl}/${encodeURIComponent(username)}`;
await kfetch({ pathname: url, method: 'DELETE' }, {});
}
public static async saveUser(user: User) {
public async saveUser(user: EditUser) {
const url = `${usersUrl}/${encodeURIComponent(user.username)}`;
await kfetch({ pathname: url, body: JSON.stringify(user), method: 'POST' });
}
public static async getRoles(): Promise<Role[]> {
public async getRoles(): Promise<Role[]> {
return await kfetch({ pathname: rolesUrl });
}
public static async getRole(name: string): Promise<Role> {
public async getRole(name: string): Promise<Role> {
const url = `${rolesUrl}/${encodeURIComponent(name)}`;
return await kfetch({ pathname: url });
}
public static async changePassword(username: string, password: string, currentPassword: string) {
public async changePassword(username: string, password: string, currentPassword: string) {
const data: Record<string, string> = {
newPassword: password,
};

View file

@ -0,0 +1,128 @@
/*
* 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 { UserValidator, UserValidationResult } from './validate_user';
import { User, EditUser } from '../../common/model';
function expectValid(result: UserValidationResult) {
expect(result.isInvalid).toBe(false);
}
function expectInvalid(result: UserValidationResult) {
expect(result.isInvalid).toBe(true);
}
describe('UserValidator', () => {
describe('#validateUsername', () => {
it(`returns 'valid' if validation is disabled`, () => {
expectValid(new UserValidator().validateUsername({} as User));
});
it(`returns 'invalid' if username is missing`, () => {
expectInvalid(new UserValidator({ shouldValidate: true }).validateUsername({} as User));
});
it(`returns 'invalid' if username contains invalid characters`, () => {
expectInvalid(
new UserValidator({ shouldValidate: true }).validateUsername({
username: '!@#$%^&*()',
} as User)
);
});
it(`returns 'valid' for correct usernames`, () => {
expectValid(
new UserValidator({ shouldValidate: true }).validateUsername({
username: 'my_user',
} as User)
);
});
});
describe('#validateEmail', () => {
it(`returns 'valid' if validation is disabled`, () => {
expectValid(new UserValidator().validateEmail({} as EditUser));
});
it(`returns 'valid' if email is missing`, () => {
expectValid(new UserValidator({ shouldValidate: true }).validateEmail({} as EditUser));
});
it(`returns 'invalid' for invalid emails`, () => {
expectInvalid(
new UserValidator({ shouldValidate: true }).validateEmail({
email: 'asf',
} as EditUser)
);
});
it(`returns 'valid' for correct emails`, () => {
expectValid(
new UserValidator({ shouldValidate: true }).validateEmail({
email: 'foo@bar.co',
} as EditUser)
);
});
});
describe('#validatePassword', () => {
it(`returns 'valid' if validation is disabled`, () => {
expectValid(new UserValidator().validatePassword({} as EditUser));
});
it(`returns 'invalid' if password is missing`, () => {
expectInvalid(new UserValidator({ shouldValidate: true }).validatePassword({} as EditUser));
});
it(`returns 'invalid' for invalid password`, () => {
expectInvalid(
new UserValidator({ shouldValidate: true }).validatePassword({
password: 'short',
} as EditUser)
);
});
it(`returns 'valid' for correct passwords`, () => {
expectValid(
new UserValidator({ shouldValidate: true }).validatePassword({
password: 'changeme',
} as EditUser)
);
});
});
describe('#validateConfirmPassword', () => {
it(`returns 'valid' if validation is disabled`, () => {
expectValid(new UserValidator().validateConfirmPassword({} as EditUser));
});
it(`returns 'invalid' if confirm password is missing`, () => {
expectInvalid(
new UserValidator({ shouldValidate: true }).validateConfirmPassword({
password: 'changeme',
} as EditUser)
);
});
it(`returns 'invalid' for mismatched passwords`, () => {
expectInvalid(
new UserValidator({ shouldValidate: true }).validateConfirmPassword({
password: 'changeme',
confirmPassword: 'changeyou',
} as EditUser)
);
});
it(`returns 'valid' for correct passwords`, () => {
expectValid(
new UserValidator({ shouldValidate: true }).validateConfirmPassword({
password: 'changeme',
confirmPassword: 'changeme',
} as EditUser)
);
});
});
});

View file

@ -0,0 +1,142 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { User, EditUser } from '../../common/model';
interface UserValidatorOptions {
shouldValidate?: boolean;
}
export interface UserValidationResult {
isInvalid: boolean;
error?: string;
}
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 UserValidator {
private shouldValidate?: boolean;
constructor(options: UserValidatorOptions = {}) {
this.shouldValidate = options.shouldValidate;
}
public enableValidation() {
this.shouldValidate = true;
}
public disableValidation() {
this.shouldValidate = false;
}
public validateUsername(user: User): UserValidationResult {
if (!this.shouldValidate) {
return valid();
}
const { username } = user;
if (!username) {
return invalid(
i18n.translate('xpack.security.management.users.editUser.requiredUsernameErrorMessage', {
defaultMessage: 'Username is required',
})
);
} else if (username && !username.match(validUsernameRegex)) {
return invalid(
i18n.translate(
'xpack.security.management.users.editUser.usernameAllowedCharactersErrorMessage',
{
defaultMessage:
'Username must begin with a letter or underscore and contain only letters, underscores, and numbers',
}
)
);
}
return valid();
}
public validateEmail(user: EditUser): UserValidationResult {
if (!this.shouldValidate) {
return valid();
}
const { email } = user;
if (email && !email.match(validEmailRegex)) {
return invalid(
i18n.translate('xpack.security.management.users.editUser.validEmailRequiredErrorMessage', {
defaultMessage: 'Email address is invalid',
})
);
}
return valid();
}
public validatePassword(user: EditUser): UserValidationResult {
if (!this.shouldValidate) {
return valid();
}
const { password } = user;
if (!password || password.length < 6) {
return invalid(
i18n.translate('xpack.security.management.users.editUser.passwordLengthErrorMessage', {
defaultMessage: 'Password must be at least 6 characters',
})
);
}
return valid();
}
public validateConfirmPassword(user: EditUser): UserValidationResult {
if (!this.shouldValidate) {
return valid();
}
const { password, confirmPassword } = user;
if (password && confirmPassword !== null && password !== confirmPassword) {
return invalid(
i18n.translate('xpack.security.management.users.editUser.passwordDoNotMatchErrorMessage', {
defaultMessage: 'Passwords do not match',
})
);
}
return valid();
}
public validateForSave(user: EditUser, isNewUser: boolean): UserValidationResult {
const { isInvalid: isUsernameInvalid } = this.validateUsername(user);
const { isInvalid: isEmailInvalid } = this.validateEmail(user);
let isPasswordInvalid = false;
let isConfirmPasswordInvalid = false;
if (isNewUser) {
isPasswordInvalid = this.validatePassword(user).isInvalid;
isConfirmPasswordInvalid = this.validateConfirmPassword(user).isInvalid;
}
if (isUsernameInvalid || isEmailInvalid || isPasswordInvalid || isConfirmPasswordInvalid) {
return invalid();
}
return valid();
}
}
function invalid(error?: string): UserValidationResult {
return {
isInvalid: true,
error,
};
}
function valid(): UserValidationResult {
return {
isInvalid: false,
};
}

View file

@ -9,6 +9,7 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { UserAPIClient } from '../../../../lib/api';
import { AuthenticatedUser, canUserChangePassword } from '../../../../../common/model';
import { ChangePasswordForm } from '../../../../components/management/change_password_form';
@ -44,7 +45,11 @@ export class ChangePassword extends Component<Props, {}> {
</p>
}
>
<ChangePasswordForm user={this.props.user} isUserChangingOwnPassword={true} />
<ChangePasswordForm
user={this.props.user}
isUserChangingOwnPassword={true}
apiClient={new UserAPIClient()}
/>
</EuiDescribedFormGroup>
);
};

View file

@ -1,2 +1,3 @@
@import './change_password_form/index';
@import './edit_role/index';
@import './edit_user/index';

View file

@ -0,0 +1,6 @@
.secUsersEditPage__content {
max-width: $secFormWidth;
margin-left: auto;
margin-right: auto;
flex-grow: 0;
}

View file

@ -0,0 +1,137 @@
/*
* 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 { mountWithIntl } from 'test_utils/enzyme_helpers';
import { EditUserPage } from './edit_user_page';
import React from 'react';
import { UserAPIClient } from '../../../../lib/api';
import { User, Role } from '../../../../../common/model';
import { ReactWrapper } from 'enzyme';
const buildClient = () => {
const apiClient = new UserAPIClient();
const createUser = (username: string) => {
const user: User = {
username,
full_name: 'my full name',
email: 'foo@bar.com',
roles: ['idk', 'something'],
enabled: true,
};
if (username === 'reserved_user') {
user.metadata = {
_reserved: true,
};
}
return Promise.resolve(user);
};
apiClient.getUser = jest.fn().mockImplementation(createUser);
apiClient.getCurrentUser = jest.fn().mockImplementation(() => createUser('current_user'));
apiClient.getRoles = jest.fn().mockImplementation(() => {
return Promise.resolve([
{
name: 'role 1',
elasticsearch: {
cluster: ['all'],
indices: [],
run_as: [],
},
kibana: [],
},
{
name: 'role 2',
elasticsearch: {
cluster: [],
indices: [],
run_as: ['bar'],
},
kibana: [],
},
] as Role[]);
});
return apiClient;
};
function expectSaveButton(wrapper: ReactWrapper<any, any>) {
expect(wrapper.find('EuiButton[data-test-subj="userFormSaveButton"]')).toHaveLength(1);
}
function expectMissingSaveButton(wrapper: ReactWrapper<any, any>) {
expect(wrapper.find('EuiButton[data-test-subj="userFormSaveButton"]')).toHaveLength(0);
}
describe('EditUserPage', () => {
it('allows reserved users to be viewed', async () => {
const apiClient = buildClient();
const wrapper = mountWithIntl(
<EditUserPage.WrappedComponent
username={'reserved_user'}
apiClient={apiClient}
changeUrl={path => path}
intl={null as any}
/>
);
await waitForRender(wrapper);
expect(apiClient.getUser).toBeCalledTimes(1);
expect(apiClient.getCurrentUser).toBeCalledTimes(1);
expectMissingSaveButton(wrapper);
});
it('allows new users to be created', async () => {
const apiClient = buildClient();
const wrapper = mountWithIntl(
<EditUserPage.WrappedComponent
username={''}
apiClient={apiClient}
changeUrl={path => path}
intl={null as any}
/>
);
await waitForRender(wrapper);
expect(apiClient.getUser).toBeCalledTimes(0);
expect(apiClient.getCurrentUser).toBeCalledTimes(0);
expectSaveButton(wrapper);
});
it('allows existing users to be edited', async () => {
const apiClient = buildClient();
const wrapper = mountWithIntl(
<EditUserPage.WrappedComponent
username={'existing_user'}
apiClient={apiClient}
changeUrl={path => path}
intl={null as any}
/>
);
await waitForRender(wrapper);
expect(apiClient.getUser).toBeCalledTimes(1);
expect(apiClient.getCurrentUser).toBeCalledTimes(1);
expectSaveButton(wrapper);
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -3,9 +3,8 @@
* 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 { get } from 'lodash';
import React, { Component, Fragment } from 'react';
import React, { Component, Fragment, ChangeEvent } from 'react';
import {
EuiButton,
EuiCallOut,
@ -28,46 +27,76 @@ import {
EuiSpacer,
} from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import { USERS_PATH } from '../../../views/management/management_urls';
import { ConfirmDelete } from './confirm_delete';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import { UserAPIClient } from '../../../lib/api';
import { ChangePasswordForm } from '../change_password_form';
import { FormattedMessage, injectI18n, InjectedIntl } from '@kbn/i18n/react';
import { UserValidator, UserValidationResult } from '../../../../lib/validate_user';
import { User, EditUser, Role } from '../../../../../common/model';
import { USERS_PATH } from '../../../../views/management/management_urls';
import { ConfirmDeleteUsers } from '../../../../components/management/users';
import { UserAPIClient } from '../../../../lib/api';
import { ChangePasswordForm } from '../../../../components/management/change_password_form';
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_@\-\$\.]*/;
class EditUserUI extends Component {
constructor(props) {
interface Props {
username: string;
intl: InjectedIntl;
changeUrl: (path: string) => void;
apiClient: UserAPIClient;
}
interface State {
isLoaded: boolean;
isNewUser: boolean;
currentUser: User | null;
showChangePasswordForm: boolean;
showDeleteConfirmation: boolean;
user: EditUser;
roles: Role[];
selectedRoles: Array<{ label: string }>;
formError: UserValidationResult | null;
}
class EditUserPageUI extends Component<Props, State> {
private validator: UserValidator;
constructor(props: Props) {
super(props);
this.validator = new UserValidator({ shouldValidate: false });
this.state = {
isLoaded: false,
isNewUser: true,
currentUser: {},
currentUser: null,
showChangePasswordForm: false,
showDeleteConfirmation: false,
user: {
email: null,
username: null,
full_name: null,
email: '',
username: '',
full_name: '',
roles: [],
enabled: true,
password: '',
confirmPassword: '',
},
roles: [],
selectedRoles: [],
password: null,
confirmPassword: null,
formError: null,
};
}
async componentDidMount() {
const { username } = this.props;
public async componentDidMount() {
const { username, apiClient } = this.props;
let { user, currentUser } = this.state;
if (username) {
try {
user = await UserAPIClient.getUser(username);
currentUser = await UserAPIClient.getCurrentUser();
user = {
...(await apiClient.getUser(username)),
password: '',
confirmPassword: '',
};
currentUser = await apiClient.getCurrentUser();
} catch (err) {
toastNotifications.addDanger({
title: this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.errorLoadingUserTitle',
defaultMessage: 'Error loading user'
defaultMessage: 'Error loading user',
}),
text: get(err, 'body.message') || err.message,
});
@ -75,14 +104,14 @@ class EditUserUI extends Component {
}
}
let roles = [];
let roles: Role[] = [];
try {
roles = await UserAPIClient.getRoles();
roles = await apiClient.getRoles();
} catch (err) {
toastNotifications.addDanger({
title: this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.errorLoadingRolesTitle',
defaultMessage: 'Error loading roles'
defaultMessage: 'Error loading roles',
}),
text: get(err, 'body.message') || err.message,
});
@ -97,154 +126,90 @@ class EditUserUI extends Component {
selectedRoles: user.roles.map(role => ({ label: role })) || [],
});
}
handleDelete = (usernames, errors) => {
private handleDelete = (usernames: string[], errors: string[]) => {
if (errors.length === 0) {
const { changeUrl } = this.props;
changeUrl(USERS_PATH);
}
};
passwordError = () => {
const { password } = this.state;
if (password !== null && password.length < 6) {
return this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordLengthErrorMessage',
defaultMessage: 'Password must be at least 6 characters'
private saveUser = async () => {
this.validator.enableValidation();
const result = this.validator.validateForSave(this.state.user, this.state.isNewUser);
if (result.isInvalid) {
this.setState({
formError: result,
});
}
};
currentPasswordError = () => {
const { currentPasswordError } = this.state;
if (currentPasswordError) {
return this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.incorrectPasswordErrorMessage',
defaultMessage: 'The current password you entered is incorrect'
} else {
this.setState({
formError: null,
});
}
};
confirmPasswordError = () => {
const { password, confirmPassword } = this.state;
if (password && confirmPassword !== null && password !== confirmPassword) {
return this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordDoNotMatchErrorMessage',
defaultMessage: 'Passwords do not match'
const { changeUrl, apiClient } = this.props;
const { user, isNewUser, selectedRoles } = this.state;
const userToSave: EditUser = { ...user };
if (!isNewUser) {
delete userToSave.password;
}
delete userToSave.confirmPassword;
userToSave.roles = selectedRoles.map(selectedRole => {
return selectedRole.label;
});
}
};
usernameError = () => {
const { username } = this.state.user;
if (username !== null && !username) {
return this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.requiredUsernameErrorMessage',
defaultMessage: 'Username is required'
});
} else if (username && !username.match(validUsernameRegex)) {
return this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.usernameAllowedCharactersErrorMessage',
defaultMessage: 'Username must begin with a letter or underscore and contain only letters, underscores, and numbers'
});
}
};
emailError = () => {
const { email } = this.state.user;
if (email !== null && email !== '' && !email.match(validEmailRegex)) {
return this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.validEmailRequiredErrorMessage',
defaultMessage: 'Email address is invalid'
});
}
};
changePassword = async () => {
const { user, password, currentPassword } = this.state;
try {
await UserAPIClient.changePassword(user.username, password, currentPassword);
toastNotifications.addSuccess(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordSuccessfullyChangedNotificationMessage',
defaultMessage: 'Password changed.'
})
);
} catch (e) {
if (e.body.statusCode === 401) {
return this.setState({ currentPasswordError: true });
} else {
try {
await apiClient.saveUser(userToSave);
toastNotifications.addSuccess(
this.props.intl.formatMessage(
{
id:
'xpack.security.management.users.editUser.userSuccessfullySavedNotificationMessage',
defaultMessage: 'Saved user {message}',
},
{ message: user.username }
)
);
changeUrl(USERS_PATH);
} catch (e) {
toastNotifications.addDanger(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.settingPasswordErrorMessage',
defaultMessage: 'Error setting password: {message}'
}, { message: get(e, 'body.message', 'Unknown error') })
this.props.intl.formatMessage(
{
id: 'xpack.security.management.users.editUser.savingUserErrorMessage',
defaultMessage: 'Error saving user: {message}',
},
{ message: get(e, 'body.message', 'Unknown error') }
)
);
}
}
this.clearPasswordForm();
};
saveUser = async () => {
const { 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 UserAPIClient.saveUser(userToSave);
toastNotifications.addSuccess(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.userSuccessfullySavedNotificationMessage',
defaultMessage: 'Saved user {message}'
}, { message: user.username })
);
changeUrl(USERS_PATH);
} catch (e) {
toastNotifications.addDanger(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.savingUserErrorMessage',
defaultMessage: 'Error saving user: {message}'
}, { message: get(e, 'body.message', 'Unknown error') })
);
}
};
clearPasswordForm = () => {
this.setState({
showChangePasswordForm: false,
password: null,
confirmPassword: null,
});
};
passwordFields = () => {
private passwordFields = () => {
return (
<Fragment>
<EuiFormRow
label={
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordFormRowLabel',
defaultMessage: 'Password'
})
}
isInvalid={!!this.passwordError()}
error={this.passwordError()}
label={this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordFormRowLabel',
defaultMessage: 'Password',
})}
{...this.validator.validatePassword(this.state.user)}
>
<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 || '' })}
onChange={this.onPasswordChange}
/>
</EuiFormRow>
<EuiFormRow
label={this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.confirmPasswordFormRowLabel',
defaultMessage: 'Confirm password'
defaultMessage: 'Confirm password',
})}
isInvalid={!!this.confirmPasswordError()}
error={this.confirmPasswordError()}
{...this.validator.validateConfirmPassword(this.state.user)}
>
<EuiFieldText
data-test-subj="passwordConfirmationInput"
onChange={event => this.setState({ confirmPassword: event.target.value })}
onBlur={event => this.setState({ confirmPassword: event.target.value || '' })}
onChange={this.onConfirmPasswordChange}
name="confirm_password"
type="password"
/>
@ -252,14 +217,13 @@ class EditUserUI extends Component {
</Fragment>
);
};
changePasswordForm = () => {
const {
showChangePasswordForm,
user,
currentUser,
} = this.state;
const userIsLoggedInUser = user.username && user.username === currentUser.username;
private changePasswordForm = () => {
const { showChangePasswordForm, user, currentUser } = this.state;
const userIsLoggedInUser = Boolean(
currentUser && user.username && user.username === currentUser.username
);
if (!showChangePasswordForm) {
return null;
@ -272,7 +236,7 @@ class EditUserUI extends Component {
<EuiCallOut
title={this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.changePasswordExtraStepTitle',
defaultMessage: 'Extra step needed'
defaultMessage: 'Extra step needed',
})}
color="warning"
iconType="help"
@ -293,31 +257,99 @@ class EditUserUI extends Component {
user={this.state.user}
isUserChangingOwnPassword={userIsLoggedInUser}
onChangePassword={this.toggleChangePasswordForm}
apiClient={this.props.apiClient}
/>
</Fragment>
);
};
toggleChangePasswordForm = () => {
private toggleChangePasswordForm = () => {
const { showChangePasswordForm } = this.state;
this.setState({ showChangePasswordForm: !showChangePasswordForm });
};
onRolesChange = selectedRoles => {
private onUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
const user = {
...this.state.user,
username: e.target.value || '',
};
const formError = this.validator.validateForSave(user, this.state.isNewUser);
this.setState({
user,
formError,
});
};
private onEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
const user = {
...this.state.user,
email: e.target.value || '',
};
const formError = this.validator.validateForSave(user, this.state.isNewUser);
this.setState({
user,
formError,
});
};
private onFullNameChange = (e: ChangeEvent<HTMLInputElement>) => {
const user = {
...this.state.user,
full_name: e.target.value || '',
};
const formError = this.validator.validateForSave(user, this.state.isNewUser);
this.setState({
user,
formError,
});
};
private onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const user = {
...this.state.user,
password: e.target.value || '',
};
const formError = this.validator.validateForSave(user, this.state.isNewUser);
this.setState({
user,
formError,
});
};
private onConfirmPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const user = {
...this.state.user,
confirmPassword: e.target.value || '',
};
const formError = this.validator.validateForSave(user, this.state.isNewUser);
this.setState({
user,
formError,
});
};
private onRolesChange = (selectedRoles: Array<{ label: string }>) => {
this.setState({
selectedRoles,
});
};
cannotSaveUser = () => {
private cannotSaveUser = () => {
const { user, isNewUser } = this.state;
return (
!user.username ||
this.emailError() ||
(isNewUser && (this.passwordError() || this.confirmPasswordError()))
);
const result = this.validator.validateForSave(user, isNewUser);
return result.isInvalid;
};
onCancelDelete = () => {
private onCancelDelete = () => {
this.setState({ showDeleteConfirmation: false });
};
render() {
public render() {
const { changeUrl, intl } = this.props;
const {
user,
@ -343,18 +375,18 @@ class EditUserUI extends Component {
<EuiPageContentHeaderSection>
<EuiTitle>
<h2>
{isNewUser ?
{isNewUser ? (
<FormattedMessage
id="xpack.security.management.users.editUser.newUserTitle"
defaultMessage="New user"
/>
:
) : (
<FormattedMessage
id="xpack.security.management.users.editUser.editUserTitle"
defaultMessage="Edit {userName} user"
values={{ userName: user.username }}
/>
}
)}
</h2>
</EuiTitle>
</EuiPageContentHeaderSection>
@ -378,10 +410,11 @@ class EditUserUI extends Component {
)}
{showDeleteConfirmation ? (
<ConfirmDelete
<ConfirmDeleteUsers
onCancel={this.onCancelDelete}
usersToDelete={[user.username]}
callback={this.handleDelete}
apiClient={this.props.apiClient}
/>
) : null}
@ -390,41 +423,29 @@ class EditUserUI extends Component {
event.preventDefault();
}}
>
<EuiForm>
<EuiForm {...this.state.formError}>
<EuiFormRow
isInvalid={!!this.usernameError()}
error={this.usernameError()}
{...this.validator.validateUsername(this.state.user)}
helpText={
!isNewUser && !reserved
? intl.formatMessage({
id: 'xpack.security.management.users.editUser.changingUserNameAfterCreationDescription',
defaultMessage: `Usernames can't be changed after creation.`
})
id:
'xpack.security.management.users.editUser.changingUserNameAfterCreationDescription',
defaultMessage: `Usernames can't be changed after creation.`,
})
: null
}
label={intl.formatMessage({
id: 'xpack.security.management.users.editUser.usernameFormRowLabel',
defaultMessage: 'Username'
defaultMessage: '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 },
});
}}
onChange={this.onUsernameChange}
/>
</EuiFormRow>
{isNewUser ? this.passwordFields() : null}
@ -433,59 +454,28 @@ class EditUserUI extends Component {
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.security.management.users.editUser.fullNameFormRowLabel',
defaultMessage: 'Full name'
defaultMessage: '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,
},
});
}}
onChange={this.onFullNameChange}
/>
</EuiFormRow>
<EuiFormRow
isInvalid={!!this.emailError()}
error={this.emailError()}
{...this.validator.validateEmail(this.state.user)}
label={intl.formatMessage({
id: 'xpack.security.management.users.editUser.emailAddressFormRowLabel',
defaultMessage: 'Email address'
defaultMessage: '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,
},
});
}}
onChange={this.onEmailChange}
/>
</EuiFormRow>
</Fragment>
@ -493,18 +483,17 @@ class EditUserUI extends Component {
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.security.management.users.editUser.rolesFormRowLabel',
defaultMessage: 'Roles'
defaultMessage: 'Roles',
})}
>
<EuiComboBox
data-test-subj="userFormRolesDropdown"
placeholder={intl.formatMessage({
id: 'xpack.security.management.users.editUser.addRolesPlaceholder',
defaultMessage: 'Add roles'
defaultMessage: 'Add roles',
})}
onChange={this.onRolesChange}
isDisabled={reserved}
name="roles"
options={roles.map(role => {
return { 'data-test-subj': `roleOption-${role.name}`, label: role.name };
})}
@ -543,16 +532,17 @@ class EditUserUI extends Component {
data-test-subj="userFormSaveButton"
onClick={() => this.saveUser()}
>
{isNewUser ?
{isNewUser ? (
<FormattedMessage
id="xpack.security.management.users.editUser.createUserButtonLabel"
defaultMessage="Create user"
/>
:
) : (
<FormattedMessage
id="xpack.security.management.users.editUser.updateUserButtonLabel"
defaultMessage="Update user"
/>}
/>
)}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -594,4 +584,4 @@ class EditUserUI extends Component {
}
}
export const EditUser = injectI18n(EditUserUI);
export const EditUserPage = injectI18n(EditUserPageUI);

View file

@ -0,0 +1,7 @@
/*
* 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 { EditUserPage } from './edit_user_page';

View file

@ -4,24 +4,26 @@
* you may not use this file except in compliance with the Elastic License.
*/
import routes from 'ui/routes';
import template from 'plugins/security/views/management/edit_user.html';
import template from 'plugins/security/views/management/edit_user/edit_user.html';
import 'angular-resource';
import 'ui/angular_ui_select';
import 'plugins/security/services/shield_user';
import 'plugins/security/services/shield_role';
import { EDIT_USERS_PATH } from './management_urls';
import { EditUser } from '../../components/management/users';
import { EDIT_USERS_PATH } from '../management_urls';
import { EditUserPage } from './components';
import { UserAPIClient } from '../../../lib/api';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nContext } from 'ui/i18n';
import { getEditUserBreadcrumbs, getCreateUserBreadcrumbs } from './breadcrumbs';
import { getEditUserBreadcrumbs, getCreateUserBreadcrumbs } from '../breadcrumbs';
const renderReact = (elem, httpClient, changeUrl, username) => {
render(
<I18nContext>
<EditUser
<EditUserPage
changeUrl={changeUrl}
username={username}
apiClient={new UserAPIClient()}
/>
</I18nContext>,
elem

View file

@ -6,9 +6,9 @@
import 'plugins/security/views/management/change_password_form/change_password_form';
import 'plugins/security/views/management/password_form/password_form';
import 'plugins/security/views/management/users';
import 'plugins/security/views/management/users_grid/users';
import 'plugins/security/views/management/roles_grid/roles';
import 'plugins/security/views/management/edit_user';
import 'plugins/security/views/management/edit_user/edit_user';
import 'plugins/security/views/management/edit_role/index';
import routes from 'ui/routes';
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';

View file

@ -0,0 +1,7 @@
/*
* 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 { UsersListPage } from './users_list_page';

View file

@ -0,0 +1,68 @@
/*
* 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 { UserAPIClient } from '../../../../lib/api';
import { User } from '../../../../../common/model';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { UsersListPage } from './users_list_page';
import React from 'react';
import { ReactWrapper } from 'enzyme';
describe('UsersListPage', () => {
it('renders the list of users', async () => {
const apiClient = new UserAPIClient();
apiClient.getUsers = jest.fn().mockImplementation(() => {
return Promise.resolve<User[]>([
{
username: 'foo',
email: 'foo@bar.net',
full_name: 'foo bar',
roles: ['kibana_user'],
enabled: true,
},
{
username: 'reserved',
email: 'reserved@bar.net',
full_name: '',
roles: ['superuser'],
enabled: true,
metadata: {
_reserved: true,
},
},
]);
});
const wrapper = mountWithIntl(<UsersListPage apiClient={apiClient} />);
await waitForRender(wrapper);
expect(apiClient.getUsers).toBeCalledTimes(1);
expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1);
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
});
it('renders a forbidden message if user is not authorized', async () => {
const apiClient = new UserAPIClient();
apiClient.getUsers = jest.fn().mockImplementation(() => {
return Promise.reject({ body: { statusCode: 403 } });
});
const wrapper = mountWithIntl(<UsersListPage apiClient={apiClient} />);
await waitForRender(wrapper);
expect(apiClient.getUsers).toBeCalledTimes(1);
expect(wrapper.find('[data-test-subj="permissionDeniedMessage"]')).toHaveLength(1);
expect(wrapper.find('EuiInMemoryTable')).toHaveLength(0);
});
});
async function waitForRender(wrapper: ReactWrapper<any, any>) {
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -19,75 +19,41 @@ import {
EuiEmptyPrompt,
} from '@elastic/eui';
import { toastNotifications } from 'ui/notify';
import { ConfirmDelete } from './confirm_delete';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { UserAPIClient } from '../../../lib/api';
import { injectI18n, FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import { ConfirmDeleteUsers } from '../../../../components/management/users';
import { User } from '../../../../../common/model';
import { UserAPIClient } from '../../../../lib/api';
class UsersUI extends Component {
constructor(props) {
interface Props {
intl: InjectedIntl;
apiClient: UserAPIClient;
}
interface State {
users: User[];
selection: User[];
showDeleteConfirmation: boolean;
permissionDenied: boolean;
filter: string;
}
class UsersListPageUI extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
users: [],
selection: [],
showDeleteConfirmation: false,
permissionDenied: false,
filter: '',
};
}
componentDidMount() {
public 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() {
try {
const users = await UserAPIClient.getUsers();
this.setState({ users });
} catch (e) {
if (e.body.statusCode === 403) {
this.setState({ permissionDenied: true });
} else {
toastNotifications.addDanger(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.fetchingUsersErrorMessage',
defaultMessage: 'Error fetching users: {message}'
}, { message: e.body.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 })}
>
<FormattedMessage
id="xpack.security.management.users.deleteUsersButtonLabel"
defaultMessage="Delete {numSelected} user{numSelected, plural, one { } other {s}}"
values={{
numSelected: numSelected,
}}
/>
</EuiButton>
);
}
onCancelDelete = () => {
this.setState({ showDeleteConfirmation: false });
}
render() {
public render() {
const { users, filter, permissionDenied, showDeleteConfirmation, selection } = this.state;
const { intl } = this.props;
if (permissionDenied) {
@ -121,19 +87,25 @@ class UsersUI extends Component {
const columns = [
{
field: 'full_name',
name: intl.formatMessage({ id: 'xpack.security.management.users.fullNameColumnName', defaultMessage: 'Full Name' }),
name: intl.formatMessage({
id: 'xpack.security.management.users.fullNameColumnName',
defaultMessage: 'Full Name',
}),
sortable: true,
truncateText: true,
render: fullName => {
render: (fullName: string) => {
return <div data-test-subj="userRowFullName">{fullName}</div>;
},
},
{
field: 'username',
name: intl.formatMessage({ id: 'xpack.security.management.users.userNameColumnName', defaultMessage: 'User Name' }),
name: intl.formatMessage({
id: 'xpack.security.management.users.userNameColumnName',
defaultMessage: 'User Name',
}),
sortable: true,
truncateText: true,
render: username => (
render: (username: string) => (
<EuiLink data-test-subj="userRowUserName" href={`${path}users/edit/${username}`}>
{username}
</EuiLink>
@ -143,18 +115,21 @@ class UsersUI extends Component {
field: 'email',
name: intl.formatMessage({
id: 'xpack.security.management.users.emailAddressColumnName',
defaultMessage: 'Email Address'
defaultMessage: 'Email Address',
}),
sortable: true,
truncateText: true,
render: email => {
render: (email: string) => {
return <div data-test-subj="userRowEmail">{email}</div>;
},
},
{
field: 'roles',
name: intl.formatMessage({ id: 'xpack.security.management.users.rolesColumnName', defaultMessage: 'Roles' }),
render: rolenames => {
name: intl.formatMessage({
id: 'xpack.security.management.users.rolesColumnName',
defaultMessage: 'Roles',
}),
render: (rolenames: string[]) => {
const roleLinks = rolenames.map((rolename, index) => {
return (
<Fragment key={rolename}>
@ -168,16 +143,19 @@ class UsersUI extends Component {
},
{
field: 'metadata._reserved',
name: intl.formatMessage({ id: 'xpack.security.management.users.reservedColumnName', defaultMessage: 'Reserved' }),
name: intl.formatMessage({
id: 'xpack.security.management.users.reservedColumnName',
defaultMessage: 'Reserved',
}),
sortable: false,
width: '100px',
align: 'right',
description:
intl.formatMessage({
id: 'xpack.security.management.users.reservedColumnDescription',
defaultMessage: 'Reserved users are built-in and cannot be removed. Only the password can be changed.'
}),
render: reserved =>
description: intl.formatMessage({
id: 'xpack.security.management.users.reservedColumnDescription',
defaultMessage:
'Reserved users are built-in and cannot be removed. Only the password can be changed.',
}),
render: (reserved?: boolean) =>
reserved ? (
<EuiIcon aria-label="Reserved user" data-test-subj="reservedUser" type="check" />
) : null,
@ -190,16 +168,18 @@ class UsersUI extends Component {
const selectionConfig = {
itemId: 'username',
selectable: user => !user.metadata._reserved,
selectableMessage: selectable => (!selectable ? 'User is a system user' : undefined),
onSelectionChange: selection => this.setState({ selection }),
selectable: (user: User) => !(user.metadata && user.metadata._reserved),
selectableMessage: (selectable: boolean) =>
!selectable ? 'User is a system user' : undefined,
onSelectionChange: (updatedSelection: User[]) =>
this.setState({ selection: updatedSelection }),
};
const search = {
toolsLeft: this.renderToolsLeft(),
box: {
incremental: true,
},
onChange: query => {
onChange: (query: any) => {
this.setState({
filter: query.queryText,
});
@ -218,10 +198,11 @@ class UsersUI extends Component {
};
const usersToShow = filter
? users.filter(({ username, roles, full_name: fullName = '', email = '' }) => {
const normalized = `${username} ${roles.join(' ')} ${fullName} ${email}`.toLowerCase();
const normalizedQuery = filter.toLowerCase();
return normalized.indexOf(normalizedQuery) !== -1;
}) : users;
const normalized = `${username} ${roles.join(' ')} ${fullName} ${email}`.toLowerCase();
const normalizedQuery = filter.toLowerCase();
return normalized.indexOf(normalizedQuery) !== -1;
})
: users;
return (
<div className="secUsersListingPage">
<EuiPageContent className="secUsersListingPage__content">
@ -237,10 +218,7 @@ class UsersUI extends Component {
</EuiTitle>
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection>
<EuiButton
data-test-subj="createUserButton"
href="#/management/security/users/edit"
>
<EuiButton data-test-subj="createUserButton" href="#/management/security/users/edit">
<FormattedMessage
id="xpack.security.management.users.createNewUserButtonLabel"
defaultMessage="Create user"
@ -249,33 +227,95 @@ class UsersUI extends Component {
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiPageContentBody>
{showDeleteConfirmation ? (
<ConfirmDelete
<ConfirmDeleteUsers
onCancel={this.onCancelDelete}
usersToDelete={selection.map((user) => user.username)}
usersToDelete={selection.map(user => user.username)}
callback={this.handleDelete}
apiClient={this.props.apiClient}
/>
) : null}
<EuiInMemoryTable
itemId="username"
columns={columns}
selection={selectionConfig}
pagination={pagination}
items={usersToShow}
loading={users.length === 0}
search={search}
sorting={sorting}
rowProps={rowProps}
isSelectable
/>
{
// @ts-ignore missing responsive from typedef
<EuiInMemoryTable
itemId="username"
columns={columns}
selection={selectionConfig}
pagination={pagination}
items={usersToShow}
loading={users.length === 0}
search={search}
sorting={sorting}
// @ts-ignore missing responsive from typedef
rowProps={rowProps}
isSelectable
/>
}
</EuiPageContentBody>
</EuiPageContent>
</div>
);
}
private handleDelete = (usernames: string[], errors: string[]) => {
const { users } = this.state;
this.setState({
selection: [],
showDeleteConfirmation: false,
users: users.filter(({ username }) => {
return !usernames.includes(username) || errors.includes(username);
}),
});
};
private async loadUsers() {
try {
const users = await this.props.apiClient.getUsers();
this.setState({ users });
} catch (e) {
if (e.body.statusCode === 403) {
this.setState({ permissionDenied: true });
} else {
toastNotifications.addDanger(
this.props.intl.formatMessage(
{
id: 'xpack.security.management.users.fetchingUsersErrorMessage',
defaultMessage: 'Error fetching users: {message}',
},
{ message: e.body.message }
)
);
}
}
}
private 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 })}
>
<FormattedMessage
id="xpack.security.management.users.deleteUsersButtonLabel"
defaultMessage="Delete {numSelected} user{numSelected, plural, one { } other {s}}"
values={{
numSelected,
}}
/>
</EuiButton>
);
}
private onCancelDelete = () => {
this.setState({ showDeleteConfirmation: false });
};
}
export const Users = injectI18n(UsersUI);
export const UsersListPage = injectI18n(UsersListPageUI);

View file

@ -7,19 +7,20 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import routes from 'ui/routes';
import template from 'plugins/security/views/management/users.html';
import template from 'plugins/security/views/management/users_grid/users.html';
import 'plugins/security/services/shield_user';
import { SECURITY_PATH, USERS_PATH } from './management_urls';
import { Users } from '../../components/management/users';
import { SECURITY_PATH, USERS_PATH } from '../management_urls';
import { UsersListPage } from './components';
import { UserAPIClient } from '../../../lib/api';
import { I18nContext } from 'ui/i18n';
import { getUsersBreadcrumbs } from './breadcrumbs';
import { getUsersBreadcrumbs } from '../breadcrumbs';
routes.when(SECURITY_PATH, {
redirectTo: USERS_PATH,
});
const renderReact = (elem, changeUrl) => {
render(<I18nContext><Users changeUrl={changeUrl} /></I18nContext>, elem);
render(<I18nContext><UsersListPage changeUrl={changeUrl} apiClient={new UserAPIClient()} /></I18nContext>, elem);
};
routes.when(USERS_PATH, {

View file

@ -7455,13 +7455,11 @@
"xpack.security.management.users.editUser.errorLoadingRolesTitle": "加载角色时出错",
"xpack.security.management.users.editUser.errorLoadingUserTitle": "加载用户时出错",
"xpack.security.management.users.editUser.fullNameFormRowLabel": "全名",
"xpack.security.management.users.editUser.incorrectPasswordErrorMessage": "您输入的当前密码不正确",
"xpack.security.management.users.editUser.modifyingReservedUsersDescription": "保留的用户是内置的,无法删除或修改。只能更改密码。",
"xpack.security.management.users.editUser.newUserTitle": "新建用户",
"xpack.security.management.users.editUser.passwordDoNotMatchErrorMessage": "密码不匹配",
"xpack.security.management.users.editUser.passwordFormRowLabel": "密码",
"xpack.security.management.users.editUser.passwordLengthErrorMessage": "密码长度必须至少为 6 个字符",
"xpack.security.management.users.editUser.passwordSuccessfullyChangedNotificationMessage": "密码已更改。",
"xpack.security.management.users.editUser.requiredUsernameErrorMessage": "“用户名”必填",
"xpack.security.management.users.editUser.returnToUserListButtonLabel": "返回到用户列表",
"xpack.security.management.users.editUser.rolesFormRowLabel": "角色",