From 58f5aaaaccc5a34418dfc44a701f71dfa94dbb7a Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Mon, 15 Apr 2019 16:55:56 -0400 Subject: [PATCH] Convert account screen to React/EUI (#30977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- x-pack/plugins/security/common/constants.ts | 1 - .../security/common/model/user.test.ts | 62 ++++ x-pack/plugins/security/common/model/user.ts | 37 ++ .../change_password_form.test.tsx | 111 ++++++ .../change_password_form.tsx | 327 ++++++++++++++++++ .../management/change_password_form/index.ts | 7 + .../management/users/confirm_delete.js | 5 +- .../components/management/users/edit_user.js | 104 ++---- .../components/management/users/users.js | 11 +- x-pack/plugins/security/public/lib/api.js | 55 --- x-pack/plugins/security/public/lib/api.ts | 60 ++++ .../public/views/account/account.html | 55 +-- .../security/public/views/account/account.js | 67 ++-- .../account_management_page.test.tsx | 70 ++++ .../components/account_management_page.tsx | 48 +++ .../change_password/change_password.tsx | 76 ++++ .../components/change_password/index.ts | 7 + .../public/views/account/components/index.ts | 7 + .../account/components/personal_info/index.ts | 7 + .../personal_info/personal_info.tsx | 58 ++++ .../public/views/management/edit_user.js | 2 - .../security/public/views/management/users.js | 5 +- .../translations/translations/zh-CN.json | 9 - .../page_objects/accountsetting_page.js | 13 +- 24 files changed, 948 insertions(+), 256 deletions(-) create mode 100644 x-pack/plugins/security/common/model/user.test.ts create mode 100644 x-pack/plugins/security/common/model/user.ts create mode 100644 x-pack/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx create mode 100644 x-pack/plugins/security/public/components/management/change_password_form/change_password_form.tsx create mode 100644 x-pack/plugins/security/public/components/management/change_password_form/index.ts delete mode 100644 x-pack/plugins/security/public/lib/api.js create mode 100644 x-pack/plugins/security/public/lib/api.ts create mode 100644 x-pack/plugins/security/public/views/account/components/account_management_page.test.tsx create mode 100644 x-pack/plugins/security/public/views/account/components/account_management_page.tsx create mode 100644 x-pack/plugins/security/public/views/account/components/change_password/change_password.tsx create mode 100644 x-pack/plugins/security/public/views/account/components/change_password/index.ts create mode 100644 x-pack/plugins/security/public/views/account/components/index.ts create mode 100644 x-pack/plugins/security/public/views/account/components/personal_info/index.ts create mode 100644 x-pack/plugins/security/public/views/account/components/personal_info/personal_info.tsx diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index bca0684209ba11..2a255ecd335e50 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -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-*'; diff --git a/x-pack/plugins/security/common/model/user.test.ts b/x-pack/plugins/security/common/model/user.test.ts new file mode 100644 index 00000000000000..3f15ca3cf1ab3b --- /dev/null +++ b/x-pack/plugins/security/common/model/user.test.ts @@ -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); + }); +}); diff --git a/x-pack/plugins/security/common/model/user.ts b/x-pack/plugins/security/common/model/user.ts new file mode 100644 index 00000000000000..007661062c991f --- /dev/null +++ b/x-pack/plugins/security/common/model/user.ts @@ -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); +} diff --git a/x-pack/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx b/x-pack/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx new file mode 100644 index 00000000000000..8d2adfd78884da --- /dev/null +++ b/x-pack/plugins/security/public/components/management/change_password_form/change_password_form.test.tsx @@ -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) { + return wrapper.find(EuiFieldText).filter('[data-test-subj="currentPassword"]'); +} + +function getNewPasswordField(wrapper: ReactWrapper) { + return wrapper.find(EuiFieldText).filter('[data-test-subj="newPassword"]'); +} + +function getConfirmPasswordField(wrapper: ReactWrapper) { + return wrapper.find(EuiFieldText).filter('[data-test-subj="confirmNewPassword"]'); +} + +describe('', () => { + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(getCurrentPasswordField(wrapper)).toHaveLength(0); + expect(getNewPasswordField(wrapper)).toHaveLength(1); + expect(getConfirmPasswordField(wrapper)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/security/public/components/management/change_password_form/change_password_form.tsx b/x-pack/plugins/security/public/components/management/change_password_form/change_password_form.tsx new file mode 100644 index 00000000000000..c83dab2d7a50e2 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/change_password_form/change_password_form.tsx @@ -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 { + constructor(props: Props) { + super(props); + this.state = getInitialState(); + } + + public render() { + return this.getForm(); + } + + private getForm = () => { + return ( + + {this.props.isUserChangingOwnPassword && ( + + } + > + + + )} + + + } + {...this.validateNewPassword()} + fullWidth + label={ + + } + > + + + + } + > + + + + + + + + + + + + + + + + + + ); + }; + + private onCurrentPasswordChange = (e: ChangeEvent) => { + this.setState({ currentPassword: e.target.value, currentPasswordError: false }); + }; + + private onNewPasswordChange = (e: ChangeEvent) => { + this.setState({ newPassword: e.target.value }); + }; + + private onConfirmPasswordChange = (e: ChangeEvent) => { + 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: ( + + ), + }; + } + + if (!this.state.currentPassword) { + return { + isInvalid: true, + error: ( + + ), + }; + } + + 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: ( + + ), + }; + } + + return { + isInvalid: false, + }; + }; + + private validateConfirmPassword = (shouldValidate = this.state.shouldValidate) => { + const { newPassword, confirmPassword } = this.state; + if (shouldValidate && newPassword !== confirmPassword) { + return { + isInvalid: true, + error: ( + + ), + }; + } + + 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) => { + 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') }, + }) + ); + } + }; +} diff --git a/x-pack/plugins/security/public/components/management/change_password_form/index.ts b/x-pack/plugins/security/public/components/management/change_password_form/index.ts new file mode 100644 index 00000000000000..808ca4495ac946 --- /dev/null +++ b/x-pack/plugins/security/public/components/management/change_password_form/index.ts @@ -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'; diff --git a/x-pack/plugins/security/public/components/management/users/confirm_delete.js b/x-pack/plugins/security/public/components/management/users/confirm_delete.js index 2b5b78084a8ed9..887db0a1235931 100644 --- a/x-pack/plugins/security/public/components/management/users/confirm_delete.js +++ b/x-pack/plugins/security/public/components/management/users/confirm_delete.js @@ -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', diff --git a/x-pack/plugins/security/public/components/management/users/edit_user.js b/x-pack/plugins/security/public/components/management/users/edit_user.js index d3ce0d4da62477..bf13d59cc54dea 100644 --- a/x-pack/plugins/security/public/components/management/users/edit_user.js +++ b/x-pack/plugins/security/public/components/management/users/edit_user.js @@ -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 ( - {userIsLoggedInUser ? ( - - this.setState({ currentPassword: event.target.value })} - /> - - ) : null} { const { showChangePasswordForm, - password, - confirmPassword, - user: { username }, + user, + currentUser, } = this.state; + + const userIsLoggedInUser = user.username && user.username === currentUser.username; + if (!showChangePasswordForm) { return null; } return ( - {this.passwordFields()} - {username === 'kibana' ? ( + {user.username === 'kibana' ? ( ) : null} - - - { - this.changePassword(password); - }} - > - - - - - { - this.clearPasswordForm(); - }} - > - - - - + ); }; @@ -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 ? ( diff --git a/x-pack/plugins/security/public/components/management/users/users.js b/x-pack/plugins/security/public/components/management/users/users.js index 8a526c70355a7b..baa7f9a3056d51 100644 --- a/x-pack/plugins/security/public/components/management/users/users.js +++ b/x-pack/plugins/security/public/components/management/users/users.js @@ -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 (
@@ -251,7 +251,6 @@ class UsersUI extends Component { {showDeleteConfirmation ? ( user.username)} callback={this.handleDelete} /> diff --git a/x-pack/plugins/security/public/lib/api.js b/x-pack/plugins/security/public/lib/api.js deleted file mode 100644 index a41afcc0d4a3f8..00000000000000 --- a/x-pack/plugins/security/public/lib/api.js +++ /dev/null @@ -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); - } - }; -}; diff --git a/x-pack/plugins/security/public/lib/api.ts b/x-pack/plugins/security/public/lib/api.ts new file mode 100644 index 00000000000000..14128942dea6cf --- /dev/null +++ b/x-pack/plugins/security/public/lib/api.ts @@ -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 { + return await kfetch({ pathname: `/api/security/v1/me` }); + } + + public static async getUsers(): Promise { + return await kfetch({ pathname: usersUrl }); + } + + public static async getUser(username: string): Promise { + 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 { + return await kfetch({ pathname: rolesUrl }); + } + + public static async getRole(name: string): Promise { + const url = `${rolesUrl}/${encodeURIComponent(name)}`; + return await kfetch({ pathname: url }); + } + + public static async changePassword(username: string, password: string, currentPassword: string) { + const data: Record = { + newPassword: password, + }; + if (currentPassword) { + data.password = currentPassword; + } + await kfetch({ + pathname: `${usersUrl}/${encodeURIComponent(username)}/password`, + method: 'POST', + body: JSON.stringify(data), + }); + } +} diff --git a/x-pack/plugins/security/public/views/account/account.html b/x-pack/plugins/security/public/views/account/account.html index d0ba4b2681883a..0935c415b18295 100644 --- a/x-pack/plugins/security/public/views/account/account.html +++ b/x-pack/plugins/security/public/views/account/account.html @@ -1,54 +1 @@ -
- -
- -
-

-
- - -
-
- - -
- -
-

-
-
- - -
- -
-

-
-
- - -
- -
-

-
-
- -
+
diff --git a/x-pack/plugins/security/public/views/account/account.js b/x-pack/plugins/security/public/views/account/account.js index d2ab51d0caa44b..fcf5364d5f3f3b 100644 --- a/x-pack/plugins/security/public/views/account/account.js +++ b/x-pack/plugins/security/public/views/account/account.js @@ -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( + + + , + 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); + }); } }); diff --git a/x-pack/plugins/security/public/views/account/components/account_management_page.test.tsx b/x-pack/plugins/security/public/views/account/components/account_management_page.test.tsx new file mode 100644 index 00000000000000..cdc67cb918d727 --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/account_management_page.test.tsx @@ -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('', () => { + it(`displays users full name, username, and email address`, () => { + const user: User = createUser(); + const wrapper = mountWithIntl(); + 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(); + 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(); + 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(); + 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(); + expect(wrapper.find('EuiFieldText[data-test-subj="currentPassword"]')).toHaveLength(0); + expect(wrapper.find('EuiFieldText[data-test-subj="newPassword"]')).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/security/public/views/account/components/account_management_page.tsx b/x-pack/plugins/security/public/views/account/components/account_management_page.tsx new file mode 100644 index 00000000000000..43f145a1e15c02 --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/account_management_page.tsx @@ -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 { + constructor(props: Props) { + super(props); + } + + public render() { + return ( + + + + +

{getUserDisplayName(this.props.user)}

+
+ + + + + + +
+
+
+ ); + } +} diff --git a/x-pack/plugins/security/public/views/account/components/change_password/change_password.tsx b/x-pack/plugins/security/public/views/account/components/change_password/change_password.tsx new file mode 100644 index 00000000000000..b92897489e3292 --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/change_password/change_password.tsx @@ -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 { + constructor(props: Props) { + super(props); + } + + public render() { + const canChangePassword = canUserChangePassword(this.props.user); + + const changePasswordTitle = ( + + ); + + if (canChangePassword) { + return this.getChangePasswordForm(changePasswordTitle); + } + return this.getChangePasswordUnavailable(changePasswordTitle); + } + + private getChangePasswordForm = (changePasswordTitle: React.ReactElement) => { + return ( + {changePasswordTitle}} + description={ +

+ +

+ } + > + +
+ ); + }; + + private getChangePasswordUnavailable(changePasswordTitle: React.ReactElement) { + return ( + {changePasswordTitle}} + description={ +

+ +

+ } + > +
+ + ); + } +} diff --git a/x-pack/plugins/security/public/views/account/components/change_password/index.ts b/x-pack/plugins/security/public/views/account/components/change_password/index.ts new file mode 100644 index 00000000000000..ccd810bb814c00 --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/change_password/index.ts @@ -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'; diff --git a/x-pack/plugins/security/public/views/account/components/index.ts b/x-pack/plugins/security/public/views/account/components/index.ts new file mode 100644 index 00000000000000..0f119b7cc0b1d8 --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/index.ts @@ -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'; diff --git a/x-pack/plugins/security/public/views/account/components/personal_info/index.ts b/x-pack/plugins/security/public/views/account/components/personal_info/index.ts new file mode 100644 index 00000000000000..5980157f5b76e2 --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/personal_info/index.ts @@ -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'; diff --git a/x-pack/plugins/security/public/views/account/components/personal_info/personal_info.tsx b/x-pack/plugins/security/public/views/account/components/personal_info/personal_info.tsx new file mode 100644 index 00000000000000..2c4058851451da --- /dev/null +++ b/x-pack/plugins/security/public/views/account/components/personal_info/personal_info.tsx @@ -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 ( + + + + } + description={ + + } + > + + +
+
+ {props.user.username} +
+
+ {props.user.email || ( + + )} +
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/security/public/views/management/edit_user.js b/x-pack/plugins/security/public/views/management/edit_user.js index 23da6db3fb1b3b..147d451b15ef9c 100644 --- a/x-pack/plugins/security/public/views/management/edit_user.js +++ b/x-pack/plugins/security/public/views/management/edit_user.js @@ -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) => { , diff --git a/x-pack/plugins/security/public/views/management/users.js b/x-pack/plugins/security/public/views/management/users.js index 5ce5c0c81314fb..d29bad4e1d2d2f 100644 --- a/x-pack/plugins/security/public/views/management/users.js +++ b/x-pack/plugins/security/public/views/management/users.js @@ -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(, elem); +const renderReact = (elem, changeUrl) => { + render(, elem); }; routes.when(USERS_PATH, { diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 82d0c1a1decf9d..0268d4bc5b8110 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "更新用户", diff --git a/x-pack/test/functional/page_objects/accountsetting_page.js b/x-pack/test/functional/page_objects/accountsetting_page.js index a49b9544ce7e30..27f703a5dd427d 100644 --- a/x-pack/test/functional/page_objects/accountsetting_page.js +++ b/x-pack/test/functional/page_objects/accountsetting_page.js @@ -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'); } }