mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
* 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:
parent
871ce5527b
commit
4f9e8d6e80
31 changed files with 1150 additions and 437 deletions
|
@ -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';
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
@import './management/users/index';
|
||||
@import './authentication_state_page/index';
|
||||
|
|
|
@ -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>
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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';
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
|
|
128
x-pack/plugins/security/public/lib/validate_user.test.ts
Normal file
128
x-pack/plugins/security/public/lib/validate_user.test.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
142
x-pack/plugins/security/public/lib/validate_user.ts
Normal file
142
x-pack/plugins/security/public/lib/validate_user.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
@import './change_password_form/index';
|
||||
@import './edit_role/index';
|
||||
@import './edit_user/index';
|
|
@ -0,0 +1,6 @@
|
|||
.secUsersEditPage__content {
|
||||
max-width: $secFormWidth;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-grow: 0;
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
|
@ -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';
|
|
@ -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
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
|
@ -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, {
|
|
@ -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": "角色",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue