Convert account screen to React/EUI (#30977)

* WIP account management redesign

* style updates

* start implementing change password logic

* restyle

* remove api key management section

* improved change password validation

* first round of design edits

* cleanup and testing

* fix import

* fix translations

* fix error handling on user management page

* consolidate password change logic

* fix tests

* happy linter, happy life

* finish change password test

* removes unused translations

* fix typo in test

* fix change password functional test

* Design edits (#19)

- Made `fullWidth`
- Added a consistent password requirement help text
- Use `title` for toast
- Change username/email to us `dl`
- Don’t use html elements in tests

* clear password form on success

* copy edits

* fix handling of Change Password button

* use encodeURIComponent for user supplied data
This commit is contained in:
Larry Gregory 2019-04-15 16:55:56 -04:00 committed by GitHub
parent a6d977e794
commit 031682d7d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 948 additions and 256 deletions

View file

@ -6,6 +6,5 @@
export const GLOBAL_RESOURCE = '*';
export const IGNORED_TYPES = ['space'];
export const REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE = ['reserved', 'native'];
export const APPLICATION_PREFIX = 'kibana-';
export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*';

View file

@ -0,0 +1,62 @@
/*
* 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 { canUserChangePassword, getUserDisplayName, User } from './user';
describe('#getUserDisplayName', () => {
it(`uses the full name when available`, () => {
expect(
getUserDisplayName({
full_name: 'my full name',
username: 'foo',
} as User)
).toEqual('my full name');
});
it(`uses the username when full name is not available`, () => {
expect(
getUserDisplayName({
username: 'foo',
} as User)
).toEqual('foo');
});
});
describe('#canUserChangePassword', () => {
['reserved', 'native'].forEach(realm => {
it(`returns true for users in the ${realm} realm`, () => {
expect(
canUserChangePassword({
username: 'foo',
authentication_realm: {
name: 'the realm name',
type: realm,
},
} as User)
).toEqual(true);
});
});
it(`returns true when no realm is provided`, () => {
expect(
canUserChangePassword({
username: 'foo',
} as User)
).toEqual(true);
});
it(`returns false for all other realms`, () => {
expect(
canUserChangePassword({
username: 'foo',
authentication_realm: {
name: 'the realm name',
type: 'does not matter',
},
} as User)
).toEqual(false);
});
});

View file

@ -0,0 +1,37 @@
/*
* 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 interface User {
username: string;
email: string;
full_name: string;
roles: string[];
enabled: boolean;
authentication_realm?: {
name: string;
type: string;
};
lookup_realm?: {
name: string;
type: string;
};
}
const REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE = ['reserved', 'native'];
export function getUserDisplayName(user: User): string {
return user.full_name || user.username;
}
export function canUserChangePassword(user: User): boolean {
const { authentication_realm: authenticationRealm } = user;
if (!authenticationRealm) {
return true;
}
return REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(authenticationRealm.type);
}

View file

@ -0,0 +1,111 @@
/*
* 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.
*/
jest.mock('../../../lib/api', () => {
return {
UserAPIClient: {
changePassword: jest.fn(),
},
};
});
import { EuiFieldText } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { User } from '../../../../common/model/user';
import { UserAPIClient } from '../../../lib/api';
import { ChangePasswordForm } from './change_password_form';
function getCurrentPasswordField(wrapper: ReactWrapper<any>) {
return wrapper.find(EuiFieldText).filter('[data-test-subj="currentPassword"]');
}
function getNewPasswordField(wrapper: ReactWrapper<any>) {
return wrapper.find(EuiFieldText).filter('[data-test-subj="newPassword"]');
}
function getConfirmPasswordField(wrapper: ReactWrapper<any>) {
return wrapper.find(EuiFieldText).filter('[data-test-subj="confirmNewPassword"]');
}
describe('<ChangePasswordForm>', () => {
describe('for the current user', () => {
it('shows fields for current and new passwords', () => {
const user: User = {
username: 'user',
full_name: 'john smith',
email: 'john@smith.com',
enabled: true,
roles: [],
};
const wrapper = mountWithIntl(
<ChangePasswordForm user={user} isUserChangingOwnPassword={true} />
);
expect(getCurrentPasswordField(wrapper)).toHaveLength(1);
expect(getNewPasswordField(wrapper)).toHaveLength(1);
expect(getConfirmPasswordField(wrapper)).toHaveLength(1);
});
it('allows a password to be changed', () => {
const user: User = {
username: 'user',
full_name: 'john smith',
email: 'john@smith.com',
enabled: true,
roles: [],
};
const callback = jest.fn();
const wrapper = mountWithIntl(
<ChangePasswordForm
user={user}
isUserChangingOwnPassword={true}
onChangePassword={callback}
/>
);
const currentPassword = getCurrentPasswordField(wrapper);
currentPassword.props().onChange!({ target: { value: 'myCurrentPassword' } } as any);
const newPassword = getNewPasswordField(wrapper);
newPassword.props().onChange!({ target: { value: 'myNewPassword' } } as any);
const confirmPassword = getConfirmPasswordField(wrapper);
confirmPassword.props().onChange!({ target: { value: 'myNewPassword' } } as any);
wrapper.find('button[data-test-subj="changePasswordButton"]').simulate('click');
expect(UserAPIClient.changePassword).toHaveBeenCalledTimes(1);
expect(UserAPIClient.changePassword).toHaveBeenCalledWith(
'user',
'myNewPassword',
'myCurrentPassword'
);
});
});
describe('for another user', () => {
it('shows fields for new password only', () => {
const user: User = {
username: 'user',
full_name: 'john smith',
email: 'john@smith.com',
enabled: true,
roles: [],
};
const wrapper = mountWithIntl(
<ChangePasswordForm user={user} isUserChangingOwnPassword={false} />
);
expect(getCurrentPasswordField(wrapper)).toHaveLength(0);
expect(getNewPasswordField(wrapper)).toHaveLength(1);
expect(getConfirmPasswordField(wrapper)).toHaveLength(1);
});
});
});

View file

@ -0,0 +1,327 @@
/*
* 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 {
EuiButton,
// @ts-ignore
EuiButtonEmpty,
// @ts-ignore
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiFormRow,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { ChangeEvent, Component } from 'react';
import { toastNotifications } from 'ui/notify';
import { User } from '../../../../common/model/user';
import { UserAPIClient } from '../../../lib/api';
interface Props {
user: User;
isUserChangingOwnPassword: boolean;
onChangePassword?: () => void;
}
interface State {
shouldValidate: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
currentPasswordError: boolean;
changeInProgress: boolean;
}
function getInitialState(): State {
return {
shouldValidate: false,
currentPassword: '',
newPassword: '',
confirmPassword: '',
currentPasswordError: false,
changeInProgress: false,
};
}
export class ChangePasswordForm extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = getInitialState();
}
public render() {
return this.getForm();
}
private getForm = () => {
return (
<EuiForm>
{this.props.isUserChangingOwnPassword && (
<EuiFormRow
{...this.validateCurrentPassword()}
fullWidth
label={
<FormattedMessage
id="xpack.security.account.changePasswordForm.currentPasswordLabel"
defaultMessage="Current password"
/>
}
>
<EuiFieldText
data-test-subj="currentPassword"
type="password"
value={this.state.currentPassword}
onChange={this.onCurrentPasswordChange}
disabled={this.state.changeInProgress}
fullWidth
/>
</EuiFormRow>
)}
<EuiFormRow
helpText={
<FormattedMessage
id="xpack.security.account.changePasswordForm.passwordRequirements"
defaultMessage="Use at least 6 characters."
/>
}
{...this.validateNewPassword()}
fullWidth
label={
<FormattedMessage
id="xpack.security.account.changePasswordForm.newPasswordLabel"
defaultMessage="New password"
/>
}
>
<EuiFieldText
data-test-subj="newPassword"
type="password"
value={this.state.newPassword}
onChange={this.onNewPasswordChange}
disabled={this.state.changeInProgress}
fullWidth
/>
</EuiFormRow>
<EuiFormRow
{...this.validateConfirmPassword()}
fullWidth
label={
<FormattedMessage
id="xpack.security.account.changePasswordForm.confirmPasswordLabel"
defaultMessage="Confirm new password"
/>
}
>
<EuiFieldText
data-test-subj="confirmNewPassword"
type="password"
value={this.state.confirmPassword}
onChange={this.onConfirmPasswordChange}
disabled={this.state.changeInProgress}
fullWidth
/>
</EuiFormRow>
<EuiFormRow>
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
onClick={this.onChangePasswordClick}
fill
isLoading={this.state.changeInProgress}
data-test-subj="changePasswordButton"
>
<FormattedMessage
id="xpack.security.account.changePasswordForm.saveChangesButtonLabel"
defaultMessage="Change password"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={this.onCancelClick} isDisabled={this.state.changeInProgress}>
<FormattedMessage
id="xpack.security.account.changePasswordForm.cancelButtonLabel"
defaultMessage="Reset"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
</EuiForm>
);
};
private onCurrentPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ currentPassword: e.target.value, currentPasswordError: false });
};
private onNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ newPassword: e.target.value });
};
private onConfirmPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ confirmPassword: e.target.value });
};
private onCancelClick = () => {
this.setState(getInitialState());
};
private onChangePasswordClick = async () => {
this.setState({ shouldValidate: true, currentPasswordError: false }, () => {
const { isInvalid } = this.validateForm();
if (isInvalid) {
return;
}
this.setState({ changeInProgress: true }, () => this.performPasswordChange());
});
};
private validateCurrentPassword = (shouldValidate = this.state.shouldValidate) => {
if (!shouldValidate || !this.props.isUserChangingOwnPassword) {
return {
isInvalid: false,
};
}
if (this.state.currentPasswordError) {
return {
isInvalid: true,
error: (
<FormattedMessage
id="xpack.security.account.changePasswordForm.invalidPassword"
defaultMessage="Current password is incorrect."
/>
),
};
}
if (!this.state.currentPassword) {
return {
isInvalid: true,
error: (
<FormattedMessage
id="xpack.security.account.currentPasswordRequired"
defaultMessage="Current password is required."
/>
),
};
}
return {
isInvalid: false,
};
};
private validateNewPassword = (shouldValidate = this.state.shouldValidate) => {
const { newPassword } = this.state;
const minPasswordLength = 6;
if (shouldValidate && newPassword.length < minPasswordLength) {
return {
isInvalid: true,
error: (
<FormattedMessage
id="xpack.security.account.passwordLengthDescription"
defaultMessage="Password is too short."
/>
),
};
}
return {
isInvalid: false,
};
};
private validateConfirmPassword = (shouldValidate = this.state.shouldValidate) => {
const { newPassword, confirmPassword } = this.state;
if (shouldValidate && newPassword !== confirmPassword) {
return {
isInvalid: true,
error: (
<FormattedMessage
id="xpack.security.account.passwordsDoNotMatch"
defaultMessage="Passwords do not match."
/>
),
};
}
return {
isInvalid: false,
};
};
private validateForm = () => {
const validation = [
this.validateCurrentPassword(true),
this.validateNewPassword(true),
this.validateConfirmPassword(true),
];
const firstFailure = validation.find(result => result.isInvalid);
if (firstFailure) {
return firstFailure;
}
return {
isInvalid: false,
};
};
private performPasswordChange = async () => {
try {
await UserAPIClient.changePassword(
this.props.user.username,
this.state.newPassword,
this.state.currentPassword
);
this.handleChangePasswordSuccess();
} catch (e) {
this.handleChangePasswordFailure(e);
} finally {
this.setState({
changeInProgress: false,
});
}
};
private handleChangePasswordSuccess = () => {
toastNotifications.addSuccess({
title: i18n.translate('xpack.security.account.changePasswordSuccess', {
defaultMessage: 'Your password has been changed.',
}),
'data-test-subj': 'passwordUpdateSuccess',
});
this.setState({
currentPasswordError: false,
shouldValidate: false,
newPassword: '',
currentPassword: '',
confirmPassword: '',
});
if (this.props.onChangePassword) {
this.props.onChangePassword();
}
};
private handleChangePasswordFailure = (error: Record<string, any>) => {
if (error.body && error.body.statusCode === 401) {
this.setState({ currentPasswordError: true });
} else {
toastNotifications.addDanger(
i18n.translate('xpack.security.management.users.editUser.settingPasswordErrorMessage', {
defaultMessage: 'Error setting password: {message}',
values: { message: _.get(error, 'body.message') },
})
);
}
};
}

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 { ChangePasswordForm } from './change_password_form';

View file

@ -8,14 +8,15 @@ 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 { UserAPIClient } from '../../../lib/api';
class ConfirmDeleteUI extends Component {
deleteUsers = () => {
const { usersToDelete, apiClient, callback } = this.props;
const { usersToDelete, callback } = this.props;
const errors = [];
usersToDelete.forEach(async username => {
try {
await apiClient.deleteUser(username);
await UserAPIClient.deleteUser(username);
toastNotifications.addSuccess(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.confirmDelete.userSuccessfullyDeletedNotificationMessage',

View file

@ -31,6 +31,8 @@ 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';
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_@\-\$\.]*/;
@ -55,36 +57,35 @@ class EditUserUI extends Component {
};
}
async componentDidMount() {
const { apiClient, username } = this.props;
const { username } = this.props;
let { user, currentUser } = this.state;
if (username) {
try {
user = await apiClient.getUser(username);
currentUser = await apiClient.getCurrentUser();
user = await UserAPIClient.getUser(username);
currentUser = await UserAPIClient.getCurrentUser();
} catch (err) {
toastNotifications.addDanger({
title: this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.errorLoadingUserTitle',
defaultMessage: 'Error loading user'
}),
text: get(err, 'data.message') || err.message,
text: get(err, 'body.message') || err.message,
});
return;
}
}
let roles;
let roles = [];
try {
roles = await apiClient.getRoles();
roles = await UserAPIClient.getRoles();
} catch (err) {
toastNotifications.addDanger({
title: this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.errorLoadingRolesTitle',
defaultMessage: 'Error loading roles'
}),
text: get(err, 'data.message') || err.message,
text: get(err, 'body.message') || err.message,
});
return;
}
this.setState({
@ -153,10 +154,9 @@ class EditUserUI extends Component {
}
};
changePassword = async () => {
const { apiClient } = this.props;
const { user, password, currentPassword } = this.state;
try {
await apiClient.changePassword(user.username, password, currentPassword);
await UserAPIClient.changePassword(user.username, password, currentPassword);
toastNotifications.addSuccess(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordSuccessfullyChangedNotificationMessage',
@ -164,21 +164,21 @@ class EditUserUI extends Component {
})
);
} catch (e) {
if (e.status === 401) {
if (e.body.statusCode === 401) {
return this.setState({ currentPasswordError: true });
} else {
toastNotifications.addDanger(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.settingPasswordErrorMessage',
defaultMessage: 'Error setting password: {message}'
}, { message: e.data.message })
}, { message: get(e, 'body.message', 'Unknown error') })
);
}
}
this.clearPasswordForm();
};
saveUser = async () => {
const { apiClient, changeUrl } = this.props;
const { changeUrl } = this.props;
const { user, password, selectedRoles } = this.state;
const userToSave = { ...user };
userToSave.roles = selectedRoles.map(selectedRole => {
@ -188,7 +188,7 @@ class EditUserUI extends Component {
userToSave.password = password;
}
try {
await apiClient.saveUser(userToSave);
await UserAPIClient.saveUser(userToSave);
toastNotifications.addSuccess(
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.userSuccessfullySavedNotificationMessage',
@ -201,7 +201,7 @@ class EditUserUI extends Component {
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.savingUserErrorMessage',
defaultMessage: 'Error saving user: {message}'
}, { message: e.data.message })
}, { message: get(e, 'body.message', 'Unknown error') })
);
}
};
@ -213,32 +213,11 @@ class EditUserUI extends Component {
});
};
passwordFields = () => {
const { user, currentUser } = this.state;
const userIsLoggedInUser = user.username && user.username === currentUser.username;
return (
<Fragment>
{userIsLoggedInUser ? (
<EuiFormRow
label={this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.currentPasswordFormRowLabel',
defaultMessage: 'Current password'
})}
isInvalid={!!this.currentPasswordError()}
error={this.currentPasswordError()}
>
<EuiFieldText
name="currentPassword"
type="password"
onChange={event => this.setState({ currentPassword: event.target.value })}
/>
</EuiFormRow>
) : null}
<EuiFormRow
label={
userIsLoggedInUser ? this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.newPasswordFormRowLabel',
defaultMessage: 'New password'
}) : this.props.intl.formatMessage({
this.props.intl.formatMessage({
id: 'xpack.security.management.users.editUser.passwordFormRowLabel',
defaultMessage: 'Password'
})
@ -276,18 +255,19 @@ class EditUserUI extends Component {
changePasswordForm = () => {
const {
showChangePasswordForm,
password,
confirmPassword,
user: { username },
user,
currentUser,
} = this.state;
const userIsLoggedInUser = user.username && user.username === currentUser.username;
if (!showChangePasswordForm) {
return null;
}
return (
<Fragment>
<EuiHorizontalRule />
{this.passwordFields()}
{username === 'kibana' ? (
{user.username === 'kibana' ? (
<Fragment>
<EuiCallOut
title={this.props.intl.formatMessage({
@ -309,38 +289,11 @@ class EditUserUI extends Component {
<EuiSpacer />
</Fragment>
) : null}
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
size="s"
fill
disabled={
!password || !confirmPassword || this.passwordError() || this.confirmPasswordError()
}
onClick={() => {
this.changePassword(password);
}}
>
<FormattedMessage
id="xpack.security.management.users.editUser.savePasswordButtonLabel"
defaultMessage="Save password"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
onClick={() => {
this.clearPasswordForm();
}}
>
<FormattedMessage
id="xpack.security.management.users.editUser.savePasswordCancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<ChangePasswordForm
user={this.state.user}
isUserChangingOwnPassword={userIsLoggedInUser}
onChangePassword={this.toggleChangePasswordForm}
/>
</Fragment>
);
};
@ -365,7 +318,7 @@ class EditUserUI extends Component {
this.setState({ showDeleteConfirmation: false });
};
render() {
const { changeUrl, apiClient, intl } = this.props;
const { changeUrl, intl } = this.props;
const {
user,
roles,
@ -427,7 +380,6 @@ class EditUserUI extends Component {
{showDeleteConfirmation ? (
<ConfirmDelete
onCancel={this.onCancelDelete}
apiClient={apiClient}
usersToDelete={[user.username]}
callback={this.handleDelete}
/>

View file

@ -20,6 +20,7 @@ import {
import { toastNotifications } from 'ui/notify';
import { ConfirmDelete } from './confirm_delete';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { UserAPIClient } from '../../../lib/api';
class UsersUI extends Component {
constructor(props) {
@ -44,19 +45,18 @@ class UsersUI extends Component {
});
};
async loadUsers() {
const { apiClient } = this.props;
try {
const users = await apiClient.getUsers();
const users = await UserAPIClient.getUsers();
this.setState({ users });
} catch (e) {
if (e.status === 403) {
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.data.message })
}, { message: e.body.message })
);
}
}
@ -88,7 +88,7 @@ class UsersUI extends Component {
}
render() {
const { users, filter, permissionDenied, showDeleteConfirmation, selection } = this.state;
const { apiClient, intl } = this.props;
const { intl } = this.props;
if (permissionDenied) {
return (
<div className="secUsersListingPage">
@ -251,7 +251,6 @@ class UsersUI extends Component {
{showDeleteConfirmation ? (
<ConfirmDelete
onCancel={this.onCancelDelete}
apiClient={apiClient}
usersToDelete={selection.map((user) => user.username)}
callback={this.handleDelete}
/>

View file

@ -1,55 +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.
*/
import chrome from 'ui/chrome';
const usersUrl = chrome.addBasePath('/api/security/v1/users');
const rolesUrl = chrome.addBasePath('/api/security/role');
export const createApiClient = (httpClient) => {
return {
async getCurrentUser() {
const url = chrome.addBasePath('/api/security/v1/me');
const { data } = await httpClient.get(url);
return data;
},
async getUsers() {
const { data } = await httpClient.get(usersUrl);
return data;
},
async getUser(username) {
const url = `${usersUrl}/${username}`;
const { data } = await httpClient.get(url);
return data;
},
async deleteUser(username) {
const url = `${usersUrl}/${username}`;
await httpClient.delete(url);
},
async saveUser(user) {
const url = `${usersUrl}/${user.username}`;
await httpClient.post(url, user);
},
async getRoles() {
const { data } = await httpClient.get(rolesUrl);
return data;
},
async getRole(name) {
const url = `${rolesUrl}/${name}`;
const { data } = await httpClient.get(url);
return data;
},
async changePassword(username, password, currentPassword) {
const data = {
newPassword: password,
};
if (currentPassword) {
data.password = currentPassword;
}
await httpClient
.post(`${usersUrl}/${username}/password`, data);
}
};
};

View file

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

View file

@ -1,54 +1 @@
<div class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem">
<!-- Subheader -->
<div class="kuiBar kuiVerticalRhythm">
<!-- Title -->
<div class="kuiBarSection">
<h1
class="kuiTitle"
i18n-id="xpack.security.account.accountSettingsTitle"
i18n-default-message="Account Settings"
></h1>
</div>
<!-- Empty section to push content left -->
<div class="kuiBarSection"></div>
</div>
<!-- Username -->
<div class="kuiFormSection kuiVerticalRhythm">
<label
class="kuiFormLabel"
i18n-id="xpack.security.account.usernameLabel"
i18n-default-message="Username"
></label>
<div class="euiText">
<p ng-bind="::user.username" data-test-subj=usernameField></p>
</div>
</div>
<!-- Email -->
<div class="kuiFormSection">
<label
class="kuiFormLabel"
i18n-id="xpack.security.account.emailLabel"
i18n-default-message="Email"
></label>
<div class="euiText">
<p ng-bind="::accountController.getEmail()" data-test-subj=emailIdField></p>
</div>
</div>
<!-- Change password -->
<div class="kuiFormSection" ng-if="!showChangePassword">
<label class="kuiFormLabel" i18n-id="xpack.security.account.passwordLabel" i18n-default-message="Password"></label>
<div class="euiText">
<p i18n-id="xpack.security.account.changePasswordNotSupportedText" i18n-default-message="You cannot change the password for this account."></p>
</div>
</div>
<kbn-change-password-form
ng-if="showChangePassword"
require-current-password="true"
show-kibana-warning="user.metadata._reserved && user.username === 'kibana'"
on-change-password="saveNewPassword(newPassword, currentPassword, onSuccess, onIncorrectPassword)"
></kbn-change-password-form>
</div>
<div id="userProfileReactRoot" />

View file

@ -4,21 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { toastNotifications } from 'ui/notify';
import routes from 'ui/routes';
import template from './account.html';
import '../management/change_password_form/change_password_form';
import '../../services/shield_user';
import { i18n } from '@kbn/i18n';
import { REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE } from '../../../common/constants';
import { I18nContext } from 'ui/i18n';
import { AccountManagementPage } from './components';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
const renderReact = (elem, user) => {
render(
<I18nContext>
<AccountManagementPage
user={user}
/>
</I18nContext>,
elem
);
};
routes.when('/account', {
template,
k7Breadcrumbs: () => [
{
text: i18n.translate('xpack.security.account.breadcrumb', {
defaultMessage: 'Account',
defaultMessage: 'Account Management',
})
}
],
@ -28,42 +39,16 @@ routes.when('/account', {
}
},
controllerAs: 'accountController',
controller($scope, $route, Notifier, config, i18n) {
$scope.user = $route.current.locals.user;
const notifier = new Notifier();
const { authentication_realm: authenticationRealm } = $scope.user;
$scope.showChangePassword = REALMS_ELIGIBLE_FOR_PASSWORD_CHANGE.includes(authenticationRealm.type);
$scope.saveNewPassword = (newPassword, currentPassword, onSuccess, onIncorrectPassword) => {
$scope.user.newPassword = newPassword;
if (currentPassword) {
// If the currentPassword is null, we shouldn't send it.
$scope.user.password = currentPassword;
controller($scope, $route) {
$scope.$on('$destroy', () => {
const elem = document.getElementById('userProfileReactRoot');
if (elem) {
unmountComponentAtNode(elem);
}
$scope.user.$changePassword()
.then(() => toastNotifications.addSuccess({
title: i18n('xpack.security.account.updatedPasswordTitle', {
defaultMessage: 'Updated password'
}),
'data-test-subj': 'passwordUpdateSuccess',
}))
.then(onSuccess)
.catch(error => {
if (error.status === 401) {
onIncorrectPassword();
}
else notifier.error(_.get(error, 'data.message'));
});
};
this.getEmail = () => {
if ($scope.user.email) return $scope.user.email;
return i18n('xpack.security.account.noEmailMessage', {
defaultMessage: '(No email)'
});
};
});
$scope.$$postDigest(() => {
const elem = document.getElementById('userProfileReactRoot');
renderReact(elem, $route.current.locals.user);
});
}
});

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { User } from '../../../../common/model/user';
import { AccountManagementPage } from './account_management_page';
interface Options {
withFullName?: boolean;
withEmail?: boolean;
realm?: string;
}
const createUser = ({ withFullName = true, withEmail = true, realm = 'native' }: Options = {}) => {
return {
full_name: withFullName ? 'Casey Smith' : '',
username: 'csmith',
email: withEmail ? 'csmith@domain.com' : '',
enabled: true,
roles: [],
authentication_realm: {
type: realm,
name: realm,
},
lookup_realm: {
type: realm,
name: realm,
},
};
};
describe('<AccountManagementPage>', () => {
it(`displays users full name, username, and email address`, () => {
const user: User = createUser();
const wrapper = mountWithIntl(<AccountManagementPage user={user} />);
expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual(
user.full_name
);
expect(wrapper.find('[data-test-subj="username"]').text()).toEqual(user.username);
expect(wrapper.find('[data-test-subj="email"]').text()).toEqual(user.email);
});
it(`displays username when full_name is not provided`, () => {
const user: User = createUser({ withFullName: false });
const wrapper = mountWithIntl(<AccountManagementPage user={user} />);
expect(wrapper.find('EuiText[data-test-subj="userDisplayName"]').text()).toEqual(user.username);
});
it(`displays a placeholder when no email address is provided`, () => {
const user: User = createUser({ withEmail: false });
const wrapper = mountWithIntl(<AccountManagementPage user={user} />);
expect(wrapper.find('[data-test-subj="email"]').text()).toEqual('no email address');
});
it(`displays change password form for users in the native realm`, () => {
const user: User = createUser();
const wrapper = mountWithIntl(<AccountManagementPage user={user} />);
expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(1);
expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(1);
});
it(`does not display change password form for users in the saml realm`, () => {
const user: User = createUser({ realm: 'saml' });
const wrapper = mountWithIntl(<AccountManagementPage user={user} />);
expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(0);
expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(0);
});
});

View file

@ -0,0 +1,48 @@
/*
* 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 {
// @ts-ignore
EuiDescribedFormGroup,
EuiPage,
EuiPageBody,
EuiPanel,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import React, { Component } from 'react';
import { getUserDisplayName, User } from '../../../../common/model/user';
import { ChangePassword } from './change_password';
import { PersonalInfo } from './personal_info';
interface Props {
user: User;
}
export class AccountManagementPage extends Component<Props, {}> {
constructor(props: Props) {
super(props);
}
public render() {
return (
<EuiPage>
<EuiPageBody restrictWidth>
<EuiPanel>
<EuiText data-test-subj={'userDisplayName'}>
<h1>{getUserDisplayName(this.props.user)}</h1>
</EuiText>
<EuiSpacer size="xl" />
<PersonalInfo user={this.props.user} />
<ChangePassword user={this.props.user} />
</EuiPanel>
</EuiPageBody>
</EuiPage>
);
}
}

View file

@ -0,0 +1,76 @@
/*
* 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 {
// @ts-ignore
EuiButtonEmpty,
// @ts-ignore
EuiDescribedFormGroup,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { canUserChangePassword, User } from '../../../../../common/model/user';
import { ChangePasswordForm } from '../../../..//components/management/change_password_form';
interface Props {
user: User;
}
export class ChangePassword extends Component<Props, {}> {
constructor(props: Props) {
super(props);
}
public render() {
const canChangePassword = canUserChangePassword(this.props.user);
const changePasswordTitle = (
<FormattedMessage id="xpack.security.account.changePasswordTitle" defaultMessage="Password" />
);
if (canChangePassword) {
return this.getChangePasswordForm(changePasswordTitle);
}
return this.getChangePasswordUnavailable(changePasswordTitle);
}
private getChangePasswordForm = (changePasswordTitle: React.ReactElement<any>) => {
return (
<EuiDescribedFormGroup
fullWidth
title={<h3>{changePasswordTitle}</h3>}
description={
<p>
<FormattedMessage
id="xpack.security.account.changePasswordDescription"
defaultMessage="Change the password for your account."
/>
</p>
}
>
<ChangePasswordForm user={this.props.user} isUserChangingOwnPassword={true} />
</EuiDescribedFormGroup>
);
};
private getChangePasswordUnavailable(changePasswordTitle: React.ReactElement<any>) {
return (
<EuiDescribedFormGroup
fullWidth
title={<h3>{changePasswordTitle}</h3>}
description={
<p>
<FormattedMessage
id="xpack.security.account.changePasswordNotSupportedText"
defaultMessage="You cannot change the password for this account."
/>
</p>
}
>
<div />
</EuiDescribedFormGroup>
);
}
}

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 { ChangePassword } from './change_password';

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 { AccountManagementPage } from './account_management_page';

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 { PersonalInfo } from './personal_info';

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
// @ts-ignore
EuiDescribedFormGroup,
EuiFormRow,
EuiText,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { User } from '../../../../../common/model/user';
interface Props {
user: User;
}
export const PersonalInfo = (props: Props) => {
return (
<EuiDescribedFormGroup
fullWidth
title={
<h3>
<FormattedMessage
id="xpack.security.account.usernameGroupTitle"
defaultMessage="Username and email"
/>
</h3>
}
description={
<FormattedMessage
id="xpack.security.account.usernameGroupDescription"
defaultMessage="You can't change this information."
/>
}
>
<EuiFormRow fullWidth>
<EuiText size="s">
<dl>
<dt title="username" data-test-subj="username">
{props.user.username}
</dt>
<dd title="email" data-test-subj="email">
{props.user.email || (
<FormattedMessage
id="xpack.security.account.noEmailMessage"
defaultMessage="no email address"
/>
)}
</dd>
</dl>
</EuiText>
</EuiFormRow>
</EuiDescribedFormGroup>
);
};

View file

@ -13,7 +13,6 @@ import { EDIT_USERS_PATH } from './management_urls';
import { EditUser } from '../../components/management/users';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { createApiClient } from '../../lib/api';
import { I18nContext } from 'ui/i18n';
import { getEditUserBreadcrumbs, getCreateUserBreadcrumbs } from './breadcrumbs';
@ -22,7 +21,6 @@ const renderReact = (elem, httpClient, changeUrl, username) => {
<I18nContext>
<EditUser
changeUrl={changeUrl}
apiClient={createApiClient(httpClient)}
username={username}
/>
</I18nContext>,

View file

@ -11,7 +11,6 @@ import template from 'plugins/security/views/management/users.html';
import 'plugins/security/services/shield_user';
import { SECURITY_PATH, USERS_PATH } from './management_urls';
import { Users } from '../../components/management/users';
import { createApiClient } from '../../lib/api';
import { I18nContext } from 'ui/i18n';
import { getUsersBreadcrumbs } from './breadcrumbs';
@ -19,8 +18,8 @@ routes.when(SECURITY_PATH, {
redirectTo: USERS_PATH,
});
const renderReact = (elem, httpClient, changeUrl) => {
render(<I18nContext><Users changeUrl={changeUrl} apiClient={createApiClient(httpClient)} /></I18nContext>, elem);
const renderReact = (elem, changeUrl) => {
render(<I18nContext><Users changeUrl={changeUrl} /></I18nContext>, elem);
};
routes.when(USERS_PATH, {

View file

@ -7403,13 +7403,8 @@
"xpack.searchProfiler.trialLicenseTitle": "试用",
"xpack.searchProfiler.unavailableLicenseInformationMessage": "Search Profiler 不可用 - 许可信息当前不可用。",
"xpack.searchProfiler.upgradeLicenseMessage": "Search Profiler 不可用于当前的{licenseInfo}许可。请升级您的许可。",
"xpack.security.account.accountSettingsTitle": "帐户设置",
"xpack.security.account.changePasswordNotSupportedText": "不能更改此帐户的密码。",
"xpack.security.account.emailLabel": "电子邮件",
"xpack.security.account.noEmailMessage": "(无电子邮件)",
"xpack.security.account.passwordLabel": "密码",
"xpack.security.account.updatedPasswordTitle": "更新的密码",
"xpack.security.account.usernameLabel": "用户名",
"xpack.security.hacks.logoutNotification": "由于处于不活动状态,您即将退出。单击“确定”可以恢复。",
"xpack.security.hacks.warningTitle": "警告",
"xpack.security.loggedOut.login": "登录",
@ -7532,7 +7527,6 @@
"xpack.security.management.users.editUser.changingUserNameAfterCreationDescription": "用户名一经创建,将无法更改。",
"xpack.security.management.users.editUser.confirmPasswordFormRowLabel": "确认密码",
"xpack.security.management.users.editUser.createUserButtonLabel": "创建用户",
"xpack.security.management.users.editUser.currentPasswordFormRowLabel": "当前密码",
"xpack.security.management.users.editUser.deleteUserButtonLabel": "删除用户",
"xpack.security.management.users.editUser.editUserTitle": "编辑 {userName} 用户",
"xpack.security.management.users.editUser.emailAddressFormRowLabel": "电子邮件地址",
@ -7541,7 +7535,6 @@
"xpack.security.management.users.editUser.fullNameFormRowLabel": "全名",
"xpack.security.management.users.editUser.incorrectPasswordErrorMessage": "您输入的当前密码不正确",
"xpack.security.management.users.editUser.modifyingReservedUsersDescription": "保留的用户是内置的,无法删除或修改。只能更改密码。",
"xpack.security.management.users.editUser.newPasswordFormRowLabel": "新密码",
"xpack.security.management.users.editUser.newUserTitle": "新建用户",
"xpack.security.management.users.editUser.passwordDoNotMatchErrorMessage": "密码不匹配",
"xpack.security.management.users.editUser.passwordFormRowLabel": "密码",
@ -7550,8 +7543,6 @@
"xpack.security.management.users.editUser.requiredUsernameErrorMessage": "“用户名”必填",
"xpack.security.management.users.editUser.returnToUserListButtonLabel": "返回到用户列表",
"xpack.security.management.users.editUser.rolesFormRowLabel": "角色",
"xpack.security.management.users.editUser.savePasswordButtonLabel": "保存密码",
"xpack.security.management.users.editUser.savePasswordCancelButtonLabel": "取消",
"xpack.security.management.users.editUser.savingUserErrorMessage": "保存用户时出错:{message}",
"xpack.security.management.users.editUser.settingPasswordErrorMessage": "设置密码时出错:{message}",
"xpack.security.management.users.editUser.updateUserButtonLabel": "更新用户",

View file

@ -15,21 +15,20 @@ export function AccountSettingProvider({ getService }) {
async verifyAccountSettings(expectedEmail, expectedUserName) {
await userMenu.clickProvileLink();
const usernameField = await testSubjects.find('usernameField');
const usernameField = await testSubjects.find('username');
const userName = await usernameField.getVisibleText();
expect(userName).to.be(expectedUserName);
const emailIdField = await testSubjects.find('emailIdField');
const emailIdField = await testSubjects.find('email');
const emailField = await emailIdField.getVisibleText();
expect(emailField).to.be(expectedEmail);
}
async changePassword(currentPassword, newPassword) {
await testSubjects.click('changePasswordLink');
await testSubjects.setValue('newPasswordInput', newPassword);
await testSubjects.setValue('currentPasswordInput', currentPassword);
await testSubjects.setValue('confirmPasswordInput', newPassword);
await testSubjects.click('saveChangesButton');
await testSubjects.setValue('currentPassword', currentPassword);
await testSubjects.setValue('newPassword', newPassword);
await testSubjects.setValue('confirmNewPassword', newPassword);
await testSubjects.click('changePasswordButton');
await testSubjects.existOrFail('passwordUpdateSuccess');
}
}